/* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ package org.surfnet.oaaas.auth; import com.sun.jersey.api.client.Client; import com.sun.jersey.api.client.ClientResponse; import com.sun.jersey.api.client.config.ClientConfig; import com.sun.jersey.api.client.config.DefaultClientConfig; import org.apache.commons.codec.binary.Base64; import org.apache.commons.lang.StringUtils; import org.codehaus.jackson.map.ObjectMapper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.core.io.ClassPathResource; import org.springframework.util.Assert; import org.surfnet.oaaas.model.TokenResponseCache; import org.surfnet.oaaas.model.TokenResponseCacheImpl; import org.surfnet.oaaas.model.VerifyTokenResponse; import javax.servlet.*; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.ws.rs.core.HttpHeaders; import java.io.IOException; import java.util.Properties; /** * {@link Filter} which can be used to protect all relevant resources by * validating the oauth access token with the Authorization server. This is an * example configuration: * <p/> * <pre> * {@code * <filter> * <filter-name>authorization-server</filter-name> * <filter-class>org.surfnet.oaaas.auth.AuthorizationServerFilter</filter-class> * <init-param> * <param-name>resource-server-key</param-name> * <param-value>university-foo</param-value> * </init-param> * <init-param> * <param-name>resource-server-secret</param-name> * <param-value>58b749f7-acb3-44b7-a38c-53d5ad740cf6</param-value> * </init-param> * <init-param> * <param-name>authorization-server-url</param-name> * <param-value>http://<host-name>/v1/tokeninfo</param-value> * </init-param> * <init-param> * <param-name>type-information-is-included</param-name> * <param-value>true</param-value> * </init-param> * </filter> * <filter-mapping> * <filter-name>authorization-server</filter-name> * <url-pattern>/*</url-pattern> * </filter-mapping> * } * </pre> * <p/> * The response of the Authorization Server is put on the * {@link HttpServletRequest} with the name * {@link AuthorizationServerFilter#VERIFY_TOKEN_RESPONSE}. * <p/> * Of course it might be better to use a properties file depending on the * environment (e.g. OTAP) to get the name, secret and url. This can be achieved * simple to provide an apis.application.properties file on the classpath or configure a * properties file name as init-param (to have multiple resource servers in the same tomcat instance). * <p/> * See {@link AuthorizationServerFilter#init(FilterConfig)} * <p/> * <p/> * Also note that by default the responses from the Authorization Server are not * cached. This in configurable in the properties file used by this Filter. Again * see {@link AuthorizationServerFilter#init(FilterConfig)} * <p/> * The cache behaviour can also be changed if you override * {@link AuthorizationServerFilter#cacheAccessTokens()} and to configure the * cache differently override {@link AuthorizationServerFilter#buildCache()} */ public class AuthorizationServerFilter implements Filter { private static final Logger LOG = LoggerFactory.getLogger(AuthorizationServerFilter.class); /* * Endpoint of the authorization server (e.g. something like * http://<host-name>/v1/tokeninfo) */ private String authorizationServerUrl; /* * Base64-encoded concatenation of the name of the resource server and the * secret separated with a colon */ private String authorizationValue; /* * Client to make GET calls to the authorization server */ private Client client; /* * Constant for the access token (oauth2 spec) */ private static final String BEARER = "bearer"; /* * Constant name of the request attribute where the response is stored */ public static final String VERIFY_TOKEN_RESPONSE = "VERIFY_TOKEN_RESPONSE"; /* * If not overridden by a subclass / configured otherwise we don't cache the answers from the authorization * server */ private boolean cacheEnabled; private TokenResponseCache cache; /* * By default we respond to preflight CORS requests and have a lenient policy as we are secured by OAuth2 */ private boolean allowCorsRequests = true; /* * Key and secret obtained out-of-band to authenticate against the * authorization server */ private String resourceServerKey; private String resourceServerSecret; /** * Whether (java) type information is included in the VerifyTokenResponse. */ private boolean typeInformationIsIncluded = false; private ObjectMapper objectMapper; @Override public void init(FilterConfig filterConfig) throws ServletException { /* * First check on the presence of a init-param where to look for the properties to support * multiple resource servers in the same war. Then look for second best apis-resource-server.properties file, then * try to use the filter config if parameters are present. If this also * fails trust on the setters (e.g. probably in test modus), but apply * fail-fast strategy */ ClassPathResource res = null; String propertiesFile = filterConfig.getInitParameter("apis-resource-server.properties.file"); if (StringUtils.isNotEmpty(propertiesFile)) { res = new ClassPathResource(propertiesFile); } if (res == null || !res.exists()) { res = new ClassPathResource("apis-resource-server.properties"); } if (res != null && res.exists()) { Properties prop = new Properties(); try { prop.load(res.getInputStream()); } catch (IOException e) { throw new RuntimeException("Error in reading the apis-resource-server.properties file", e); } resourceServerKey = prop.getProperty("adminService.resourceServerKey"); resourceServerSecret = prop.getProperty("adminService.resourceServerSecret"); authorizationServerUrl = prop.getProperty("adminService.tokenVerificationUrl"); cacheEnabled = Boolean.valueOf(prop.getProperty("adminService.cacheEnabled")); String allowCorsRequestsProperty = prop.getProperty("adminService.allowCorsRequests"); if (StringUtils.isNotEmpty(allowCorsRequestsProperty)) { allowCorsRequests = Boolean.valueOf(allowCorsRequestsProperty); } String typeInformationIsIncludedProperty = prop.getProperty("adminService.jsonTypeInfoIncluded"); if (StringUtils.isNotEmpty(typeInformationIsIncludedProperty)) { typeInformationIsIncluded = Boolean.valueOf(typeInformationIsIncludedProperty); } } else if (filterConfig.getInitParameter("resource-server-key") != null) { resourceServerKey = filterConfig.getInitParameter("resource-server-key"); resourceServerSecret = filterConfig.getInitParameter("resource-server-secret"); authorizationServerUrl = filterConfig.getInitParameter("authorization-server-url"); typeInformationIsIncluded = Boolean.valueOf(filterConfig.getInitParameter("type-information-is-included")); } Assert.hasText(resourceServerKey, "Must provide a resource server key"); Assert.hasText(resourceServerSecret, "Must provide a resource server secret"); Assert.hasText(authorizationServerUrl, "Must provide a authorization server url"); this.authorizationValue = new String(Base64.encodeBase64(resourceServerKey.concat(":").concat(resourceServerSecret) .getBytes())); if (cacheAccessTokens()) { this.cache = buildCache(); Assert.notNull(this.cache); } this.client = createClient(); this.objectMapper = createObjectMapper(typeInformationIsIncluded); } protected ObjectMapper createObjectMapper(boolean typeInformationIsIncluded) { ObjectMapper mapper = new ObjectMapperProvider().getContext(ObjectMapper.class); if (typeInformationIsIncluded) { mapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL); } else { mapper.disableDefaultTyping(); } return mapper; } /** * @return Client */ protected Client createClient() { ClientConfig cc = new DefaultClientConfig(); cc.getClasses().add(ObjectMapperProvider.class); return Client.create(cc); } @SuppressWarnings({"rawtypes", "unchecked"}) protected TokenResponseCache buildCache() { return new TokenResponseCacheImpl(1000, 60 * 5); } @Override public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) servletRequest; HttpServletResponse response = (HttpServletResponse) servletResponse; if (handledCorsPreflightRequest(request, response)) { return; } /* * The Access Token from the Client app as documented in * http://tools.ietf.org/html/draft-ietf-oauth-v2#section-7 */ final String accessToken = getAccessToken(request); if (accessToken != null) { VerifyTokenResponse tokenResponse = getVerifyTokenResponse(accessToken); if (isValidResponse(tokenResponse)) { request.setAttribute(VERIFY_TOKEN_RESPONSE, tokenResponse); chain.doFilter(request, response); return; } } sendError(response, HttpServletResponse.SC_FORBIDDEN, "OAuth2 endpoint"); } protected VerifyTokenResponse getVerifyTokenResponse(String accessToken) { VerifyTokenResponse verifyTokenResponse = null; if (cacheAccessTokens()) { verifyTokenResponse = cache.getVerifyToken(accessToken); if (verifyTokenResponse != null) { return verifyTokenResponse; } } if (verifyTokenResponse == null) { ClientResponse res = client.resource(String.format("%s?access_token=%s", authorizationServerUrl, accessToken)) .header(HttpHeaders.AUTHORIZATION, "Basic " + authorizationValue).accept("application/json") .get(ClientResponse.class); try { String responseString = res.getEntity(String.class); int statusCode = res.getClientResponseStatus().getStatusCode(); LOG.debug("Got verify token response (status: {}): '{}'", statusCode, responseString); if (statusCode == HttpServletResponse.SC_OK) { verifyTokenResponse = objectMapper.readValue(responseString, VerifyTokenResponse.class); } } catch (Exception e) { LOG.error("Exception in reading result from AuthorizationServer", e); // anti-pattern, but null case is explicitly handled } } if (isValidResponse(verifyTokenResponse) && cacheAccessTokens()) { cache.storeVerifyToken(accessToken, verifyTokenResponse); } return verifyTokenResponse; } protected void sendError(HttpServletResponse response, int statusCode, String reason) { LOG.warn("No valid access-token on request. Will respond with error response: {} {}", statusCode, reason); try { response.sendError(statusCode, reason); response.flushBuffer(); } catch (IOException e) { throw new RuntimeException(reason, e); } } protected boolean cacheAccessTokens() { return cacheEnabled; } /* * http://www.w3.org/TR/cors/#resource-preflight-requests */ protected boolean handledCorsPreflightRequest(HttpServletRequest request, HttpServletResponse response) throws IOException { if (!this.allowCorsRequests || StringUtils.isBlank(request.getHeader("Origin"))) { return false; } /* * We must do this anyway, this being (probably) a CORS request */ response.setHeader("Access-Control-Allow-Origin", "*"); if (StringUtils.isNotBlank(request.getHeader("Access-Control-Request-Method")) && request.getMethod().equalsIgnoreCase("OPTIONS")) { /* * We don't want to propogate the request any further */ response.setHeader("Access-Control-Allow-Methods", getAccessControlAllowedMethods()); String requestHeaders = request.getHeader("Access-Control-Request-Headers"); if (StringUtils.isNotBlank(requestHeaders)) { response.setHeader("Access-Control-Allow-Headers", getAllowedHeaders(requestHeaders)); } response.setHeader("Access-Control-Max-Age", getAccessControlMaxAge()); response.setStatus(HttpServletResponse.SC_OK); response.flushBuffer(); return true; } return false; } protected String getAllowedHeaders(String requestHeaders) { return requestHeaders; } protected String getAccessControlMaxAge() { return "86400"; } protected String getAccessControlAllowedMethods() { return "GET, OPTIONS, HEAD, PUT, PATCH, POST, DELETE"; } private boolean isValidResponse(VerifyTokenResponse tokenResponse) { return tokenResponse != null && tokenResponse.getPrincipal() != null && tokenResponse.getError() == null; } private String getAccessToken(HttpServletRequest request) { String accessToken = null; String header = request.getHeader(HttpHeaders.AUTHORIZATION); if (header != null) { int space = header.indexOf(' '); if (space > 0) { String method = header.substring(0, space); if (BEARER.equalsIgnoreCase(method)) { accessToken = header.substring(space + 1); } } } return accessToken; } @Override public void destroy() { } public void setAuthorizationServerUrl(String authorizationServerUrl) { this.authorizationServerUrl = authorizationServerUrl; } public void setResourceServerSecret(String resourceServerSecret) { this.resourceServerSecret = resourceServerSecret; } public void setResourceServerKey(String resourceServerKey) { this.resourceServerKey = resourceServerKey; } public void setCacheEnabled(boolean cacheEnabled) { this.cacheEnabled = cacheEnabled; } public void setAllowCorsRequests(boolean allowCorsRequests) { this.allowCorsRequests = allowCorsRequests; } public void setTypeInformationIsIncluded(boolean typeInformationIsIncluded) { this.typeInformationIsIncluded = typeInformationIsIncluded; } }