// // ======================================================================== // Copyright (c) 1995-2017 Mort Bay Consulting Pty. Ltd. // ------------------------------------------------------------------------ // All rights reserved. This program and the accompanying materials // are made available under the terms of the Eclipse Public License v1.0 // and Apache License v2.0 which accompanies this distribution. // // The Eclipse Public License is available at // http://www.eclipse.org/legal/epl-v10.html // // The Apache License v2.0 is available at // http://www.opensource.org/licenses/apache2.0.php // // You may elect to redistribute this code under either of these licenses. // ======================================================================== // package org.eclipse.jetty.security; import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.Set; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CopyOnWriteArraySet; import javax.servlet.HttpConstraintElement; import javax.servlet.HttpMethodConstraintElement; import javax.servlet.ServletSecurityElement; import javax.servlet.annotation.ServletSecurity.EmptyRoleSemantic; import javax.servlet.annotation.ServletSecurity.TransportGuarantee; import org.eclipse.jetty.http.HttpStatus; import org.eclipse.jetty.http.PathMap; import org.eclipse.jetty.server.HttpConfiguration; import org.eclipse.jetty.server.Request; import org.eclipse.jetty.server.Response; import org.eclipse.jetty.server.UserIdentity; import org.eclipse.jetty.server.handler.ContextHandler; import org.eclipse.jetty.util.URIUtil; import org.eclipse.jetty.util.log.Log; import org.eclipse.jetty.util.log.Logger; import org.eclipse.jetty.util.security.Constraint; /** * ConstraintSecurityHandler * <p> * Handler to enforce SecurityConstraints. This implementation is servlet spec * 3.1 compliant and pre-computes the constraint combinations for runtime * efficiency. */ public class ConstraintSecurityHandler extends SecurityHandler implements ConstraintAware { private static final Logger LOG = Log.getLogger(SecurityHandler.class); //use same as SecurityHandler private static final String OMISSION_SUFFIX = ".omission"; private static final String ALL_METHODS = "*"; private final List<ConstraintMapping> _constraintMappings= new CopyOnWriteArrayList<>(); private final Set<String> _roles = new CopyOnWriteArraySet<>(); private final PathMap<Map<String, RoleInfo>> _constraintMap = new PathMap<>(); private boolean _denyUncoveredMethods = false; /* ------------------------------------------------------------ */ public static Constraint createConstraint() { return new Constraint(); } /* ------------------------------------------------------------ */ public static Constraint createConstraint(Constraint constraint) { try { return (Constraint)constraint.clone(); } catch (CloneNotSupportedException e) { throw new IllegalStateException (e); } } /* ------------------------------------------------------------ */ /** * Create a security constraint * * @param name the name of the constraint * @param authenticate true to authenticate * @param roles list of roles * @param dataConstraint the data constraint * @return the constraint */ public static Constraint createConstraint (String name, boolean authenticate, String[] roles, int dataConstraint) { Constraint constraint = createConstraint(); if (name != null) constraint.setName(name); constraint.setAuthenticate(authenticate); constraint.setRoles(roles); constraint.setDataConstraint(dataConstraint); return constraint; } /* ------------------------------------------------------------ */ /** * Create a Constraint * * @param name the name * @param element the http constraint element * @return the created constraint */ public static Constraint createConstraint (String name, HttpConstraintElement element) { return createConstraint(name, element.getRolesAllowed(), element.getEmptyRoleSemantic(), element.getTransportGuarantee()); } /* ------------------------------------------------------------ */ /** * Create Constraint * * @param name the name * @param rolesAllowed the list of allowed roles * @param permitOrDeny the permission semantic * @param transport the transport guarantee * @return the created constraint */ public static Constraint createConstraint (String name, String[] rolesAllowed, EmptyRoleSemantic permitOrDeny, TransportGuarantee transport) { Constraint constraint = createConstraint(); if (rolesAllowed == null || rolesAllowed.length==0) { if (permitOrDeny.equals(EmptyRoleSemantic.DENY)) { //Equivalent to <auth-constraint> with no roles constraint.setName(name+"-Deny"); constraint.setAuthenticate(true); } else { //Equivalent to no <auth-constraint> constraint.setName(name+"-Permit"); constraint.setAuthenticate(false); } } else { //Equivalent to <auth-constraint> with list of <security-role-name>s constraint.setAuthenticate(true); constraint.setRoles(rolesAllowed); constraint.setName(name+"-RolesAllowed"); } //Equivalent to //<user-data-constraint><transport-guarantee>CONFIDENTIAL</transport-guarantee></user-data-constraint> constraint.setDataConstraint((transport.equals(TransportGuarantee.CONFIDENTIAL)?Constraint.DC_CONFIDENTIAL:Constraint.DC_NONE)); return constraint; } /* ------------------------------------------------------------ */ public static List<ConstraintMapping> getConstraintMappingsForPath(String pathSpec, List<ConstraintMapping> constraintMappings) { if (pathSpec == null || "".equals(pathSpec.trim()) || constraintMappings == null || constraintMappings.size() == 0) return Collections.emptyList(); List<ConstraintMapping> mappings = new ArrayList<ConstraintMapping>(); for (ConstraintMapping mapping:constraintMappings) { if (pathSpec.equals(mapping.getPathSpec())) { mappings.add(mapping); } } return mappings; } /* ------------------------------------------------------------ */ /** Take out of the constraint mappings those that match the * given path. * * @param pathSpec the path spec * @param constraintMappings a new list minus the matching constraints * @return the list of constraint mappings */ public static List<ConstraintMapping> removeConstraintMappingsForPath(String pathSpec, List<ConstraintMapping> constraintMappings) { if (pathSpec == null || "".equals(pathSpec.trim()) || constraintMappings == null || constraintMappings.size() == 0) return Collections.emptyList(); List<ConstraintMapping> mappings = new ArrayList<ConstraintMapping>(); for (ConstraintMapping mapping:constraintMappings) { //Remove the matching mappings by only copying in non-matching mappings if (!pathSpec.equals(mapping.getPathSpec())) { mappings.add(mapping); } } return mappings; } /* ------------------------------------------------------------ */ /** * Generate Constraints and ContraintMappings for the given url pattern and ServletSecurityElement * * @param name the name * @param pathSpec the path spec * @param securityElement the servlet security element * @return the list of constraint mappings */ public static List<ConstraintMapping> createConstraintsWithMappingsForPath (String name, String pathSpec, ServletSecurityElement securityElement) { List<ConstraintMapping> mappings = new ArrayList<ConstraintMapping>(); //Create a constraint that will describe the default case (ie if not overridden by specific HttpMethodConstraints) Constraint httpConstraint = null; ConstraintMapping httpConstraintMapping = null; if (securityElement.getEmptyRoleSemantic() != EmptyRoleSemantic.PERMIT || securityElement.getRolesAllowed().length != 0 || securityElement.getTransportGuarantee() != TransportGuarantee.NONE) { httpConstraint = ConstraintSecurityHandler.createConstraint(name, securityElement); //Create a mapping for the pathSpec for the default case httpConstraintMapping = new ConstraintMapping(); httpConstraintMapping.setPathSpec(pathSpec); httpConstraintMapping.setConstraint(httpConstraint); mappings.add(httpConstraintMapping); } //See Spec 13.4.1.2 p127 List<String> methodOmissions = new ArrayList<String>(); //make constraint mappings for this url for each of the HttpMethodConstraintElements Collection<HttpMethodConstraintElement> methodConstraintElements = securityElement.getHttpMethodConstraints(); if (methodConstraintElements != null) { for (HttpMethodConstraintElement methodConstraintElement:methodConstraintElements) { //Make a Constraint that captures the <auth-constraint> and <user-data-constraint> elements supplied for the HttpMethodConstraintElement Constraint methodConstraint = ConstraintSecurityHandler.createConstraint(name, methodConstraintElement); ConstraintMapping mapping = new ConstraintMapping(); mapping.setConstraint(methodConstraint); mapping.setPathSpec(pathSpec); if (methodConstraintElement.getMethodName() != null) { mapping.setMethod(methodConstraintElement.getMethodName()); //See spec 13.4.1.2 p127 - add an omission for every method name to the default constraint methodOmissions.add(methodConstraintElement.getMethodName()); } mappings.add(mapping); } } //See spec 13.4.1.2 p127 - add an omission for every method name to the default constraint //UNLESS the default constraint contains all default values. In that case, we won't add it. See Servlet Spec 3.1 pg 129 if (methodOmissions.size() > 0 && httpConstraintMapping != null) httpConstraintMapping.setMethodOmissions(methodOmissions.toArray(new String[methodOmissions.size()])); return mappings; } /* ------------------------------------------------------------ */ /** * @return Returns the constraintMappings. */ @Override public List<ConstraintMapping> getConstraintMappings() { return _constraintMappings; } /* ------------------------------------------------------------ */ @Override public Set<String> getRoles() { return _roles; } /* ------------------------------------------------------------ */ /** * Process the constraints following the combining rules in Servlet 3.0 EA * spec section 13.7.1 Note that much of the logic is in the RoleInfo class. * * @param constraintMappings * The constraintMappings to set, from which the set of known roles * is determined. */ public void setConstraintMappings(List<ConstraintMapping> constraintMappings) { setConstraintMappings(constraintMappings,null); } /** * Process the constraints following the combining rules in Servlet 3.0 EA * spec section 13.7.1 Note that much of the logic is in the RoleInfo class. * * @param constraintMappings * The constraintMappings to set as array, from which the set of known roles * is determined. Needed to retain API compatibility for 7.x */ public void setConstraintMappings( ConstraintMapping[] constraintMappings ) { setConstraintMappings( Arrays.asList(constraintMappings), null); } /* ------------------------------------------------------------ */ /** * Process the constraints following the combining rules in Servlet 3.0 EA * spec section 13.7.1 Note that much of the logic is in the RoleInfo class. * * @param constraintMappings * The constraintMappings to set. * @param roles The known roles (or null to determine them from the mappings) */ @Override public void setConstraintMappings(List<ConstraintMapping> constraintMappings, Set<String> roles) { _constraintMappings.clear(); _constraintMappings.addAll(constraintMappings); if (roles==null) { roles = new HashSet<>(); for (ConstraintMapping cm : constraintMappings) { String[] cmr = cm.getConstraint().getRoles(); if (cmr!=null) { for (String r : cmr) if (!ALL_METHODS.equals(r)) roles.add(r); } } } setRoles(roles); if (isStarted()) { for (ConstraintMapping mapping : _constraintMappings) { processConstraintMapping(mapping); } } } /* ------------------------------------------------------------ */ /** * Set the known roles. * This may be overridden by a subsequent call to {@link #setConstraintMappings(ConstraintMapping[])} or * {@link #setConstraintMappings(List, Set)}. * @param roles The known roles (or null to determine them from the mappings) */ public void setRoles(Set<String> roles) { _roles.clear(); _roles.addAll(roles); } /* ------------------------------------------------------------ */ /** * @see org.eclipse.jetty.security.ConstraintAware#addConstraintMapping(org.eclipse.jetty.security.ConstraintMapping) */ @Override public void addConstraintMapping(ConstraintMapping mapping) { _constraintMappings.add(mapping); if (mapping.getConstraint()!=null && mapping.getConstraint().getRoles()!=null) { //allow for lazy role naming: if a role is named in a security constraint, try and //add it to the list of declared roles (ie as if it was declared with a security-role for (String role : mapping.getConstraint().getRoles()) { if ("*".equals(role) || "**".equals(role)) continue; addRole(role); } } if (isStarted()) { processConstraintMapping(mapping); } } /* ------------------------------------------------------------ */ /** * @see org.eclipse.jetty.security.ConstraintAware#addRole(java.lang.String) */ @Override public void addRole(String role) { //add to list of declared roles boolean modified = _roles.add(role); if (isStarted() && modified) { // Add the new role to currently defined any role role infos for (Map<String,RoleInfo> map : _constraintMap.values()) { for (RoleInfo info : map.values()) { if (info.isAnyRole()) info.addRole(role); } } } } /* ------------------------------------------------------------ */ /** * @see org.eclipse.jetty.security.SecurityHandler#doStart() */ @Override protected void doStart() throws Exception { _constraintMap.clear(); if (_constraintMappings!=null) { for (ConstraintMapping mapping : _constraintMappings) { processConstraintMapping(mapping); } } //Servlet Spec 3.1 pg 147 sec 13.8.4.2 log paths for which there are uncovered http methods checkPathsWithUncoveredHttpMethods(); super.doStart(); } /* ------------------------------------------------------------ */ @Override protected void doStop() throws Exception { super.doStop(); _constraintMap.clear(); } /* ------------------------------------------------------------ */ /** * Create and combine the constraint with the existing processed * constraints. * * @param mapping the constraint mapping */ protected void processConstraintMapping(ConstraintMapping mapping) { Map<String, RoleInfo> mappings = _constraintMap.get(mapping.getPathSpec()); if (mappings == null) { mappings = new HashMap<String,RoleInfo>(); _constraintMap.put(mapping.getPathSpec(),mappings); } RoleInfo allMethodsRoleInfo = mappings.get(ALL_METHODS); if (allMethodsRoleInfo != null && allMethodsRoleInfo.isForbidden()) return; if (mapping.getMethodOmissions() != null && mapping.getMethodOmissions().length > 0) { processConstraintMappingWithMethodOmissions(mapping, mappings); return; } String httpMethod = mapping.getMethod(); if (httpMethod==null) httpMethod=ALL_METHODS; RoleInfo roleInfo = mappings.get(httpMethod); if (roleInfo == null) { roleInfo = new RoleInfo(); mappings.put(httpMethod,roleInfo); if (allMethodsRoleInfo != null) { roleInfo.combine(allMethodsRoleInfo); } } if (roleInfo.isForbidden()) return; //add in info from the constraint configureRoleInfo(roleInfo, mapping); if (roleInfo.isForbidden()) { if (httpMethod.equals(ALL_METHODS)) { mappings.clear(); mappings.put(ALL_METHODS,roleInfo); } } } /* ------------------------------------------------------------ */ /** Constraints that name method omissions are dealt with differently. * We create an entry in the mappings with key "<method>.omission". This entry * is only ever combined with other omissions for the same method to produce a * consolidated RoleInfo. Then, when we wish to find the relevant constraints for * a given Request (in prepareConstraintInfo()), we consult 3 types of entries in * the mappings: an entry that names the method of the Request specifically, an * entry that names constraints that apply to all methods, entries of the form * <method>.omission, where the method of the Request is not named in the omission. * @param mapping the constraint mapping * @param mappings the mappings of roles */ protected void processConstraintMappingWithMethodOmissions (ConstraintMapping mapping, Map<String, RoleInfo> mappings) { String[] omissions = mapping.getMethodOmissions(); StringBuilder sb = new StringBuilder(); for (int i=0; i<omissions.length; i++) { if (i > 0) sb.append("."); sb.append(omissions[i]); } sb.append(OMISSION_SUFFIX); RoleInfo ri = new RoleInfo(); mappings.put(sb.toString(), ri); configureRoleInfo(ri, mapping); } /* ------------------------------------------------------------ */ /** * Initialize or update the RoleInfo from the constraint * @param ri the role info * @param mapping the constraint mapping */ protected void configureRoleInfo (RoleInfo ri, ConstraintMapping mapping) { Constraint constraint = mapping.getConstraint(); boolean forbidden = constraint.isForbidden(); ri.setForbidden(forbidden); //set up the data constraint (NOTE: must be done after setForbidden, as it nulls out the data constraint //which we need in order to do combining of omissions in prepareConstraintInfo UserDataConstraint userDataConstraint = UserDataConstraint.get(mapping.getConstraint().getDataConstraint()); ri.setUserDataConstraint(userDataConstraint); //if forbidden, no point setting up roles if (!ri.isForbidden()) { //add in the roles boolean checked = mapping.getConstraint().getAuthenticate(); ri.setChecked(checked); if (ri.isChecked()) { if (mapping.getConstraint().isAnyRole()) { // * means matches any defined role for (String role : _roles) ri.addRole(role); ri.setAnyRole(true); } else if (mapping.getConstraint().isAnyAuth()) { //being authenticated is sufficient, not necessary to check roles ri.setAnyAuth(true); } else { //user must be in one of the named roles String[] newRoles = mapping.getConstraint().getRoles(); for (String role : newRoles) { //check role has been defined if (!_roles.contains(role)) throw new IllegalArgumentException("Attempt to use undeclared role: " + role + ", known roles: " + _roles); ri.addRole(role); } } } } } /* ------------------------------------------------------------ */ /** * Find constraints that apply to the given path. * In order to do this, we consult 3 different types of information stored in the mappings for each path - each mapping * represents a merged set of user data constraints, roles etc -: * <ol> * <li>A mapping of an exact method name </li> * <li>A mapping with key * that matches every method name</li> * <li>Mappings with keys of the form "<method>.<method>.<method>.omission" that indicates it will match every method name EXCEPT those given</li> * </ol> * * @see org.eclipse.jetty.security.SecurityHandler#prepareConstraintInfo(java.lang.String, org.eclipse.jetty.server.Request) */ @Override protected RoleInfo prepareConstraintInfo(String pathInContext, Request request) { Map<String, RoleInfo> mappings = _constraintMap.match(pathInContext); if (mappings != null) { String httpMethod = request.getMethod(); RoleInfo roleInfo = mappings.get(httpMethod); if (roleInfo == null) { //No specific http-method names matched List<RoleInfo> applicableConstraints = new ArrayList<RoleInfo>(); //Get info for constraint that matches all methods if it exists RoleInfo all = mappings.get(ALL_METHODS); if (all != null) applicableConstraints.add(all); //Get info for constraints that name method omissions where target method name is not omitted //(ie matches because target method is not omitted, hence considered covered by the constraint) for (Entry<String, RoleInfo> entry: mappings.entrySet()) { if (entry.getKey() != null && entry.getKey().endsWith(OMISSION_SUFFIX) && ! entry.getKey().contains(httpMethod)) applicableConstraints.add(entry.getValue()); } if (applicableConstraints.size() == 0 && isDenyUncoveredHttpMethods()) { roleInfo = new RoleInfo(); roleInfo.setForbidden(true); } else if (applicableConstraints.size() == 1) roleInfo = applicableConstraints.get(0); else { roleInfo = new RoleInfo(); roleInfo.setUserDataConstraint(UserDataConstraint.None); for (RoleInfo r:applicableConstraints) roleInfo.combine(r); } } return roleInfo; } return null; } @Override protected boolean checkUserDataPermissions(String pathInContext, Request request, Response response, RoleInfo roleInfo) throws IOException { if (roleInfo == null) return true; if (roleInfo.isForbidden()) return false; UserDataConstraint dataConstraint = roleInfo.getUserDataConstraint(); if (dataConstraint == null || dataConstraint == UserDataConstraint.None) return true; HttpConfiguration httpConfig = Request.getBaseRequest(request).getHttpChannel().getHttpConfiguration(); if (dataConstraint == UserDataConstraint.Confidential || dataConstraint == UserDataConstraint.Integral) { if (request.isSecure()) return true; if (httpConfig.getSecurePort() > 0) { String scheme = httpConfig.getSecureScheme(); int port = httpConfig.getSecurePort(); String url = URIUtil.newURI(scheme, request.getServerName(), port,request.getRequestURI(),request.getQueryString()); response.setContentLength(0); response.sendRedirect(url); } else response.sendError(HttpStatus.FORBIDDEN_403,"!Secure"); request.setHandled(true); return false; } else { throw new IllegalArgumentException("Invalid dataConstraint value: " + dataConstraint); } } @Override protected boolean isAuthMandatory(Request baseRequest, Response base_response, Object constraintInfo) { return constraintInfo != null && ((RoleInfo)constraintInfo).isChecked(); } /* ------------------------------------------------------------ */ /** * @see org.eclipse.jetty.security.SecurityHandler#checkWebResourcePermissions(java.lang.String, org.eclipse.jetty.server.Request, org.eclipse.jetty.server.Response, java.lang.Object, org.eclipse.jetty.server.UserIdentity) */ @Override protected boolean checkWebResourcePermissions(String pathInContext, Request request, Response response, Object constraintInfo, UserIdentity userIdentity) throws IOException { if (constraintInfo == null) { return true; } RoleInfo roleInfo = (RoleInfo)constraintInfo; if (!roleInfo.isChecked()) { return true; } //handle ** role constraint if (roleInfo.isAnyAuth() && request.getUserPrincipal() != null) { return true; } //check if user is any of the allowed roles boolean isUserInRole = false; for (String role : roleInfo.getRoles()) { if (userIdentity.isUserInRole(role, null)) { isUserInRole = true; break; } } //handle * role constraint if (roleInfo.isAnyRole() && request.getUserPrincipal() != null && isUserInRole) { return true; } //normal role check if (isUserInRole) { return true; } return false; } /* ------------------------------------------------------------ */ @Override public void dump(Appendable out,String indent) throws IOException { // TODO these should all be beans dumpBeans(out,indent, Collections.singleton(getLoginService()), Collections.singleton(getIdentityService()), Collections.singleton(getAuthenticator()), Collections.singleton(_roles), _constraintMap.entrySet()); } /* ------------------------------------------------------------ */ /** * @see org.eclipse.jetty.security.ConstraintAware#setDenyUncoveredHttpMethods(boolean) */ @Override public void setDenyUncoveredHttpMethods(boolean deny) { _denyUncoveredMethods = deny; } /* ------------------------------------------------------------ */ @Override public boolean isDenyUncoveredHttpMethods() { return _denyUncoveredMethods; } /* ------------------------------------------------------------ */ /** * Servlet spec 3.1 pg. 147. */ @Override public boolean checkPathsWithUncoveredHttpMethods() { Set<String> paths = getPathsWithUncoveredHttpMethods(); if (paths != null && !paths.isEmpty()) { for (String p:paths) LOG.warn("{} has uncovered http methods for path: {}",ContextHandler.getCurrentContext(), p); if (LOG.isDebugEnabled()) LOG.debug(new Throwable()); return true; } return false; } /* ------------------------------------------------------------ */ /** * Servlet spec 3.1 pg. 147. * The container must check all the combined security constraint * information and log any methods that are not protected and the * urls at which they are not protected * * @return list of paths for which there are uncovered methods */ public Set<String> getPathsWithUncoveredHttpMethods () { //if automatically denying uncovered methods, there are no uncovered methods if (_denyUncoveredMethods) return Collections.emptySet(); Set<String> uncoveredPaths = new HashSet<String>(); for (String path:_constraintMap.keySet()) { Map<String, RoleInfo> methodMappings = _constraintMap.get(path); //Each key is either: // : an exact method name // : * which means that the constraint applies to every method // : a name of the form <method>.<method>.<method>.omission, which means it applies to every method EXCEPT those named if (methodMappings.get(ALL_METHODS) != null) continue; //can't be any uncovered methods for this url path boolean hasOmissions = omissionsExist(path, methodMappings); for (String method:methodMappings.keySet()) { if (method.endsWith(OMISSION_SUFFIX)) { Set<String> omittedMethods = getOmittedMethods(method); for (String m:omittedMethods) { if (!methodMappings.containsKey(m)) uncoveredPaths.add(path); } } else { //an exact method name if (!hasOmissions) //a http-method does not have http-method-omission to cover the other method names uncoveredPaths.add(path); } } } return uncoveredPaths; } /* ------------------------------------------------------------ */ /** * Check if any http method omissions exist in the list of method * to auth info mappings. * * @param path the path * @param methodMappings the method mappings * @return true if ommision exist */ protected boolean omissionsExist (String path, Map<String, RoleInfo> methodMappings) { if (methodMappings == null) return false; boolean hasOmissions = false; for (String m:methodMappings.keySet()) { if (m.endsWith(OMISSION_SUFFIX)) hasOmissions = true; } return hasOmissions; } /* ------------------------------------------------------------ */ /** * Given a string of the form <code><method>.<method>.omission</code> * split out the individual method names. * * @param omission the method * @return the list of strings */ protected Set<String> getOmittedMethods (String omission) { if (omission == null || !omission.endsWith(OMISSION_SUFFIX)) return Collections.emptySet(); String[] strings = omission.split("\\."); Set<String> methods = new HashSet<String>(); for (int i=0;i<strings.length-1;i++) methods.add(strings[i]); return methods; } }