| /** |
| * Licensed 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. See accompanying LICENSE file. |
| */ |
| package org.apache.hadoop.security.authentication.server; |
| |
| import java.io.IOException; |
| |
| import javax.servlet.http.Cookie; |
| import javax.servlet.ServletException; |
| import javax.servlet.http.HttpServletRequest; |
| import javax.servlet.http.HttpServletResponse; |
| |
| import java.util.ArrayList; |
| import java.util.Date; |
| import java.util.List; |
| import java.util.Properties; |
| import java.text.ParseException; |
| |
| import java.security.interfaces.RSAPublicKey; |
| |
| import com.google.common.annotations.VisibleForTesting; |
| import org.apache.hadoop.security.authentication.client.AuthenticationException; |
| import org.apache.hadoop.security.authentication.util.CertificateUtil; |
| import org.slf4j.Logger; |
| import org.slf4j.LoggerFactory; |
| |
| import com.nimbusds.jwt.SignedJWT; |
| import com.nimbusds.jose.JOSEException; |
| import com.nimbusds.jose.JWSObject; |
| import com.nimbusds.jose.JWSVerifier; |
| import com.nimbusds.jose.crypto.RSASSAVerifier; |
| |
| /** |
| * The {@link JWTRedirectAuthenticationHandler} extends |
| * AltKerberosAuthenticationHandler to add WebSSO behavior for UIs. The expected |
| * SSO token is a JsonWebToken (JWT). The supported algorithm is RS256 which |
| * uses PKI between the token issuer and consumer. The flow requires a redirect |
| * to a configured authentication server URL and a subsequent request with the |
| * expected JWT token. This token is cryptographically verified and validated. |
| * The user identity is then extracted from the token and used to create an |
| * AuthenticationToken - as expected by the AuthenticationFilter. |
| * |
| * <p> |
| * The supported configuration properties are: |
| * </p> |
| * <ul> |
| * <li>authentication.provider.url: the full URL to the authentication server. |
| * This is the URL that the handler will redirect the browser to in order to |
| * authenticate the user. It does not have a default value.</li> |
| * <li>public.key.pem: This is the PEM formatted public key of the issuer of the |
| * JWT token. It is required for verifying that the issuer is a trusted party. |
| * DO NOT include the PEM header and footer portions of the PEM encoded |
| * certificate. It does not have a default value.</li> |
| * <li>expected.jwt.audiences: This is a list of strings that identify |
| * acceptable audiences for the JWT token. The audience is a way for the issuer |
| * to indicate what entity/s that the token is intended for. Default value is |
| * null which indicates that all audiences will be accepted.</li> |
| * <li>jwt.cookie.name: the name of the cookie that contains the JWT token. |
| * Default value is "hadoop-jwt".</li> |
| * </ul> |
| */ |
| public class JWTRedirectAuthenticationHandler extends |
| AltKerberosAuthenticationHandler { |
| private static Logger LOG = LoggerFactory |
| .getLogger(JWTRedirectAuthenticationHandler.class); |
| |
| public static final String AUTHENTICATION_PROVIDER_URL = |
| "authentication.provider.url"; |
| public static final String PUBLIC_KEY_PEM = "public.key.pem"; |
| public static final String EXPECTED_JWT_AUDIENCES = "expected.jwt.audiences"; |
| public static final String JWT_COOKIE_NAME = "jwt.cookie.name"; |
| private static final String ORIGINAL_URL_QUERY_PARAM = "originalUrl="; |
| private String authenticationProviderUrl = null; |
| private RSAPublicKey publicKey = null; |
| private List<String> audiences = null; |
| private String cookieName = "hadoop-jwt"; |
| |
| /** |
| * Primarily for testing, this provides a way to set the publicKey for |
| * signature verification without needing to get a PEM encoded value. |
| * |
| * @param pk publicKey for the token signtature verification |
| */ |
| public void setPublicKey(RSAPublicKey pk) { |
| publicKey = pk; |
| } |
| |
| /** |
| * Initializes the authentication handler instance. |
| * <p> |
| * This method is invoked by the {@link AuthenticationFilter#init} method. |
| * </p> |
| * @param config |
| * configuration properties to initialize the handler. |
| * |
| * @throws ServletException |
| * thrown if the handler could not be initialized. |
| */ |
| @Override |
| public void init(Properties config) throws ServletException { |
| super.init(config); |
| // setup the URL to redirect to for authentication |
| authenticationProviderUrl = config |
| .getProperty(AUTHENTICATION_PROVIDER_URL); |
| if (authenticationProviderUrl == null) { |
| throw new ServletException( |
| "Authentication provider URL must not be null - configure: " |
| + AUTHENTICATION_PROVIDER_URL); |
| } |
| |
| // setup the public key of the token issuer for verification |
| if (publicKey == null) { |
| String pemPublicKey = config.getProperty(PUBLIC_KEY_PEM); |
| if (pemPublicKey == null) { |
| throw new ServletException( |
| "Public key for signature validation must be provisioned."); |
| } |
| publicKey = CertificateUtil.parseRSAPublicKey(pemPublicKey); |
| } |
| // setup the list of valid audiences for token validation |
| String auds = config.getProperty(EXPECTED_JWT_AUDIENCES); |
| if (auds != null) { |
| // parse into the list |
| String[] audArray = auds.split(","); |
| audiences = new ArrayList<String>(); |
| for (String a : audArray) { |
| audiences.add(a); |
| } |
| } |
| |
| // setup custom cookie name if configured |
| String customCookieName = config.getProperty(JWT_COOKIE_NAME); |
| if (customCookieName != null) { |
| cookieName = customCookieName; |
| } |
| } |
| |
| @Override |
| public AuthenticationToken alternateAuthenticate(HttpServletRequest request, |
| HttpServletResponse response) throws IOException, |
| AuthenticationException { |
| AuthenticationToken token = null; |
| |
| String serializedJWT = null; |
| HttpServletRequest req = (HttpServletRequest) request; |
| serializedJWT = getJWTFromCookie(req); |
| if (serializedJWT == null) { |
| String loginURL = constructLoginURL(request); |
| LOG.info("sending redirect to: " + loginURL); |
| ((HttpServletResponse) response).sendRedirect(loginURL); |
| } else { |
| String userName = null; |
| SignedJWT jwtToken = null; |
| boolean valid = false; |
| try { |
| jwtToken = SignedJWT.parse(serializedJWT); |
| valid = validateToken(jwtToken); |
| if (valid) { |
| userName = jwtToken.getJWTClaimsSet().getSubject(); |
| LOG.info("USERNAME: " + userName); |
| } else { |
| LOG.warn("jwtToken failed validation: " + jwtToken.serialize()); |
| } |
| } catch(ParseException pe) { |
| // unable to parse the token let's try and get another one |
| LOG.warn("Unable to parse the JWT token", pe); |
| } |
| if (valid) { |
| LOG.debug("Issuing AuthenticationToken for user."); |
| token = new AuthenticationToken(userName, userName, getType()); |
| } else { |
| String loginURL = constructLoginURL(request); |
| LOG.info("token validation failed - sending redirect to: " + loginURL); |
| ((HttpServletResponse) response).sendRedirect(loginURL); |
| } |
| } |
| return token; |
| } |
| |
| /** |
| * Encapsulate the acquisition of the JWT token from HTTP cookies within the |
| * request. |
| * |
| * @param req servlet request to get the JWT token from |
| * @return serialized JWT token |
| */ |
| protected String getJWTFromCookie(HttpServletRequest req) { |
| String serializedJWT = null; |
| Cookie[] cookies = req.getCookies(); |
| if (cookies != null) { |
| for (Cookie cookie : cookies) { |
| if (cookieName.equals(cookie.getName())) { |
| LOG.info(cookieName |
| + " cookie has been found and is being processed"); |
| serializedJWT = cookie.getValue(); |
| break; |
| } |
| } |
| } |
| return serializedJWT; |
| } |
| |
| /** |
| * Create the URL to be used for authentication of the user in the absence of |
| * a JWT token within the incoming request. |
| * |
| * @param request for getting the original request URL |
| * @return url to use as login url for redirect |
| */ |
| @VisibleForTesting |
| String constructLoginURL(HttpServletRequest request) { |
| String delimiter = "?"; |
| if (authenticationProviderUrl.contains("?")) { |
| delimiter = "&"; |
| } |
| String loginURL = authenticationProviderUrl + delimiter |
| + ORIGINAL_URL_QUERY_PARAM |
| + request.getRequestURL().toString() + getOriginalQueryString(request); |
| return loginURL; |
| } |
| |
| private String getOriginalQueryString(HttpServletRequest request) { |
| String originalQueryString = request.getQueryString(); |
| return (originalQueryString == null) ? "" : "?" + originalQueryString; |
| } |
| |
| /** |
| * This method provides a single method for validating the JWT for use in |
| * request processing. It provides for the override of specific aspects of |
| * this implementation through submethods used within but also allows for the |
| * override of the entire token validation algorithm. |
| * |
| * @param jwtToken the token to validate |
| * @return true if valid |
| */ |
| protected boolean validateToken(SignedJWT jwtToken) { |
| boolean sigValid = validateSignature(jwtToken); |
| if (!sigValid) { |
| LOG.warn("Signature could not be verified"); |
| } |
| boolean audValid = validateAudiences(jwtToken); |
| if (!audValid) { |
| LOG.warn("Audience validation failed."); |
| } |
| boolean expValid = validateExpiration(jwtToken); |
| if (!expValid) { |
| LOG.info("Expiration validation failed."); |
| } |
| |
| return sigValid && audValid && expValid; |
| } |
| |
| /** |
| * Verify the signature of the JWT token in this method. This method depends |
| * on the public key that was established during init based upon the |
| * provisioned public key. Override this method in subclasses in order to |
| * customize the signature verification behavior. |
| * |
| * @param jwtToken the token that contains the signature to be validated |
| * @return valid true if signature verifies successfully; false otherwise |
| */ |
| protected boolean validateSignature(SignedJWT jwtToken) { |
| boolean valid = false; |
| if (JWSObject.State.SIGNED == jwtToken.getState()) { |
| LOG.debug("JWT token is in a SIGNED state"); |
| if (jwtToken.getSignature() != null) { |
| LOG.debug("JWT token signature is not null"); |
| try { |
| JWSVerifier verifier = new RSASSAVerifier(publicKey); |
| if (jwtToken.verify(verifier)) { |
| valid = true; |
| LOG.debug("JWT token has been successfully verified"); |
| } else { |
| LOG.warn("JWT signature verification failed."); |
| } |
| } catch (JOSEException je) { |
| LOG.warn("Error while validating signature", je); |
| } |
| } |
| } |
| return valid; |
| } |
| |
| /** |
| * Validate whether any of the accepted audience claims is present in the |
| * issued token claims list for audience. Override this method in subclasses |
| * in order to customize the audience validation behavior. |
| * |
| * @param jwtToken |
| * the JWT token where the allowed audiences will be found |
| * @return true if an expected audience is present, otherwise false |
| */ |
| protected boolean validateAudiences(SignedJWT jwtToken) { |
| boolean valid = false; |
| try { |
| List<String> tokenAudienceList = jwtToken.getJWTClaimsSet() |
| .getAudience(); |
| // if there were no expected audiences configured then just |
| // consider any audience acceptable |
| if (audiences == null) { |
| valid = true; |
| } else { |
| // if any of the configured audiences is found then consider it |
| // acceptable |
| boolean found = false; |
| for (String aud : tokenAudienceList) { |
| if (audiences.contains(aud)) { |
| LOG.debug("JWT token audience has been successfully validated"); |
| valid = true; |
| break; |
| } |
| } |
| if (!valid) { |
| LOG.warn("JWT audience validation failed."); |
| } |
| } |
| } catch (ParseException pe) { |
| LOG.warn("Unable to parse the JWT token.", pe); |
| } |
| return valid; |
| } |
| |
| /** |
| * Validate that the expiration time of the JWT token has not been violated. |
| * If it has then throw an AuthenticationException. Override this method in |
| * subclasses in order to customize the expiration validation behavior. |
| * |
| * @param jwtToken the token that contains the expiration date to validate |
| * @return valid true if the token has not expired; false otherwise |
| */ |
| protected boolean validateExpiration(SignedJWT jwtToken) { |
| boolean valid = false; |
| try { |
| Date expires = jwtToken.getJWTClaimsSet().getExpirationTime(); |
| if (expires == null || new Date().before(expires)) { |
| LOG.debug("JWT token expiration date has been " |
| + "successfully validated"); |
| valid = true; |
| } else { |
| LOG.warn("JWT expiration date validation failed."); |
| } |
| } catch (ParseException pe) { |
| LOG.warn("JWT expiration date validation failed.", pe); |
| } |
| return valid; |
| } |
| } |