From ce635733144456bce6bcf8664c5850ef6b60aa49 Mon Sep 17 00:00:00 2001 From: Owen O'Malley Date: Tue, 7 Apr 2015 08:09:41 -0700 Subject: [PATCH] HADOOP-11717. Support JWT tokens for web single sign on to the Hadoop servers. (Larry McCay via omalley) --- hadoop-common-project/hadoop-auth/pom.xml | 11 + .../JWTRedirectAuthenticationHandler.java | 363 +++++++++++++++ .../authentication/util/CertificateUtil.java | 65 +++ .../TestJWTRedirectAuthentictionHandler.java | 418 ++++++++++++++++++ .../util/TestCertificateUtil.java | 96 ++++ .../hadoop-common/CHANGES.txt | 3 + hadoop-project/pom.xml | 13 + 7 files changed, 969 insertions(+) create mode 100644 hadoop-common-project/hadoop-auth/src/main/java/org/apache/hadoop/security/authentication/server/JWTRedirectAuthenticationHandler.java create mode 100644 hadoop-common-project/hadoop-auth/src/main/java/org/apache/hadoop/security/authentication/util/CertificateUtil.java create mode 100644 hadoop-common-project/hadoop-auth/src/test/java/org/apache/hadoop/security/authentication/server/TestJWTRedirectAuthentictionHandler.java create mode 100644 hadoop-common-project/hadoop-auth/src/test/java/org/apache/hadoop/security/authentication/util/TestCertificateUtil.java diff --git a/hadoop-common-project/hadoop-auth/pom.xml b/hadoop-common-project/hadoop-auth/pom.xml index 5f7d77434b..3999d5a2e1 100644 --- a/hadoop-common-project/hadoop-auth/pom.xml +++ b/hadoop-common-project/hadoop-auth/pom.xml @@ -107,6 +107,17 @@ httpclient compile + + com.nimbusds + nimbus-jose-jwt + compile + + + org.bouncycastle + bcprov-jdk15on + + + org.apache.directory.server apacheds-kerberos-codec diff --git a/hadoop-common-project/hadoop-auth/src/main/java/org/apache/hadoop/security/authentication/server/JWTRedirectAuthenticationHandler.java b/hadoop-common-project/hadoop-auth/src/main/java/org/apache/hadoop/security/authentication/server/JWTRedirectAuthenticationHandler.java new file mode 100644 index 0000000000..42df6a0cbc --- /dev/null +++ b/hadoop-common-project/hadoop-auth/src/main/java/org/apache/hadoop/security/authentication/server/JWTRedirectAuthenticationHandler.java @@ -0,0 +1,363 @@ +/** + * 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.io.ByteArrayInputStream; +import java.io.UnsupportedEncodingException; +import java.security.PublicKey; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.security.cert.CertificateException; +import java.security.interfaces.RSAPublicKey; + +import org.apache.commons.codec.binary.Base64; +import org.apache.hadoop.security.authentication.client.AuthenticationException; +import org.apache.hadoop.security.authentication.server.AltKerberosAuthenticationHandler; +import org.apache.hadoop.security.authentication.server.AuthenticationToken; +import org.apache.hadoop.security.authentication.util.CertificateUtil; +import org.apache.hadoop.security.authentication.util.KerberosName; +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. + * + *

+ * The supported configuration properties are: + *

+ */ +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 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 + */ + public void setPublicKey(RSAPublicKey pk) { + publicKey = pk; + } + + /** + * Initializes the authentication handler instance. + *

+ * This method is invoked by the {@link AuthenticationFilter#init} method. + * + * @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(); + 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, response); + 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, response); + 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 serializedJWT + * @param req + * @return serialized JWT token + */ + protected String getJWTFromCookie(HttpServletRequest req) { + String serializedJWT = null; + Cookie[] cookies = req.getCookies(); + String userName = null; + 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 + * @param response + * @return url to use as login url for redirect + */ + protected String constructLoginURL(HttpServletRequest request, + HttpServletResponse response) { + String delimiter = "?"; + if (authenticationProviderUrl.contains("?")) { + delimiter = "&"; + } + String loginURL = authenticationProviderUrl + delimiter + + ORIGINAL_URL_QUERY_PARAM + + request.getRequestURL().toString(); + return loginURL; + } + + /** + * 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 + * @return true if valid + * @throws AuthenticationException + */ + 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 + * @throws AuthenticationException + */ + 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 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 + * @throws AuthenticationException + */ + 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; + } +} diff --git a/hadoop-common-project/hadoop-auth/src/main/java/org/apache/hadoop/security/authentication/util/CertificateUtil.java b/hadoop-common-project/hadoop-auth/src/main/java/org/apache/hadoop/security/authentication/util/CertificateUtil.java new file mode 100644 index 0000000000..77b253034a --- /dev/null +++ b/hadoop-common-project/hadoop-auth/src/main/java/org/apache/hadoop/security/authentication/util/CertificateUtil.java @@ -0,0 +1,65 @@ +/** + * 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.apache.hadoop.security.authentication.util; + +import java.io.ByteArrayInputStream; +import java.io.UnsupportedEncodingException; +import java.security.PublicKey; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.security.interfaces.RSAPublicKey; + +import javax.servlet.ServletException; + +public class CertificateUtil { + private static final String PEM_HEADER = "-----BEGIN CERTIFICATE-----\n"; + private static final String PEM_FOOTER = "\n-----END CERTIFICATE-----"; + + /** + * Gets an RSAPublicKey from the provided PEM encoding. + * + * @param pem + * - the pem encoding from config without the header and footer + * @return RSAPublicKey + */ + public static RSAPublicKey parseRSAPublicKey(String pem) throws ServletException { + String fullPem = PEM_HEADER + pem + PEM_FOOTER; + PublicKey key = null; + try { + CertificateFactory fact = CertificateFactory.getInstance("X.509"); + ByteArrayInputStream is = new ByteArrayInputStream( + fullPem.getBytes("UTF8")); + + X509Certificate cer = (X509Certificate) fact.generateCertificate(is); + key = cer.getPublicKey(); + } catch (CertificateException ce) { + String message = null; + if (pem.startsWith(PEM_HEADER)) { + message = "CertificateException - be sure not to include PEM header " + + "and footer in the PEM configuration element."; + } else { + message = "CertificateException - PEM may be corrupt"; + } + throw new ServletException(message, ce); + } catch (UnsupportedEncodingException uee) { + throw new ServletException(uee); + } + return (RSAPublicKey) key; + } +} diff --git a/hadoop-common-project/hadoop-auth/src/test/java/org/apache/hadoop/security/authentication/server/TestJWTRedirectAuthentictionHandler.java b/hadoop-common-project/hadoop-auth/src/test/java/org/apache/hadoop/security/authentication/server/TestJWTRedirectAuthentictionHandler.java new file mode 100644 index 0000000000..4ac95354de --- /dev/null +++ b/hadoop-common-project/hadoop-auth/src/test/java/org/apache/hadoop/security/authentication/server/TestJWTRedirectAuthentictionHandler.java @@ -0,0 +1,418 @@ +/** + * 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 static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.io.File; +import java.net.MalformedURLException; +import java.net.URL; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.util.Arrays; +import java.util.List; +import java.util.ArrayList; +import java.util.Properties; +import java.util.Vector; +import java.util.Date; + +import javax.servlet.ServletException; +import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.apache.hadoop.minikdc.KerberosSecurityTestcase; +import org.apache.hadoop.security.authentication.KerberosTestUtils; +import org.apache.hadoop.security.authentication.client.AuthenticationException; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; + +import com.nimbusds.jose.*; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jose.crypto.RSASSAVerifier; +import com.nimbusds.jose.util.Base64URL; + +public class TestJWTRedirectAuthentictionHandler extends + KerberosSecurityTestcase { + private static final String SERVICE_URL = "https://localhost:8888/resource"; + private static final String REDIRECT_LOCATION = + "https://localhost:8443/authserver?originalUrl=" + SERVICE_URL; + RSAPublicKey publicKey = null; + RSAPrivateKey privateKey = null; + JWTRedirectAuthenticationHandler handler = null; + + @Test + public void testNoPublicKeyJWT() throws Exception { + try { + Properties props = getProperties(); + handler.init(props); + + SignedJWT jwt = getJWT("bob", new Date(new Date().getTime() + 5000), + privateKey); + + Cookie cookie = new Cookie("hadoop-jwt", jwt.serialize()); + HttpServletRequest request = Mockito.mock(HttpServletRequest.class); + Mockito.when(request.getCookies()).thenReturn(new Cookie[] { cookie }); + Mockito.when(request.getRequestURL()).thenReturn( + new StringBuffer(SERVICE_URL)); + HttpServletResponse response = Mockito.mock(HttpServletResponse.class); + Mockito.when(response.encodeRedirectURL(SERVICE_URL)).thenReturn( + SERVICE_URL); + + AuthenticationToken token = handler.alternateAuthenticate(request, + response); + fail("alternateAuthentication should have thrown a ServletException"); + } catch (ServletException se) { + assertTrue(se.getMessage().contains( + "Public key for signature validation must be provisioned")); + } catch (AuthenticationException ae) { + fail("alternateAuthentication should NOT have thrown a AuthenticationException"); + } + } + + @Test + public void testCustomCookieNameJWT() throws Exception { + try { + handler.setPublicKey(publicKey); + + Properties props = getProperties(); + props.put(JWTRedirectAuthenticationHandler.JWT_COOKIE_NAME, "jowt"); + handler.init(props); + + SignedJWT jwt = getJWT("bob", new Date(new Date().getTime() + 5000), + privateKey); + + Cookie cookie = new Cookie("jowt", jwt.serialize()); + HttpServletRequest request = Mockito.mock(HttpServletRequest.class); + Mockito.when(request.getCookies()).thenReturn(new Cookie[] { cookie }); + Mockito.when(request.getRequestURL()).thenReturn( + new StringBuffer(SERVICE_URL)); + HttpServletResponse response = Mockito.mock(HttpServletResponse.class); + Mockito.when(response.encodeRedirectURL(SERVICE_URL)).thenReturn( + SERVICE_URL); + + AuthenticationToken token = handler.alternateAuthenticate(request, + response); + Assert.assertEquals("bob", token.getUserName()); + } catch (ServletException se) { + fail("alternateAuthentication should NOT have thrown a ServletException: " + + se.getMessage()); + } catch (AuthenticationException ae) { + fail("alternateAuthentication should NOT have thrown a AuthenticationException"); + } + } + + @Test + public void testNoProviderURLJWT() throws Exception { + try { + handler.setPublicKey(publicKey); + + Properties props = getProperties(); + props + .remove(JWTRedirectAuthenticationHandler.AUTHENTICATION_PROVIDER_URL); + handler.init(props); + + SignedJWT jwt = getJWT("bob", new Date(new Date().getTime() + 5000), + privateKey); + + Cookie cookie = new Cookie("hadoop-jwt", jwt.serialize()); + HttpServletRequest request = Mockito.mock(HttpServletRequest.class); + Mockito.when(request.getCookies()).thenReturn(new Cookie[] { cookie }); + Mockito.when(request.getRequestURL()).thenReturn( + new StringBuffer(SERVICE_URL)); + HttpServletResponse response = Mockito.mock(HttpServletResponse.class); + Mockito.when(response.encodeRedirectURL(SERVICE_URL)).thenReturn( + SERVICE_URL); + + AuthenticationToken token = handler.alternateAuthenticate(request, + response); + fail("alternateAuthentication should have thrown an AuthenticationException"); + } catch (ServletException se) { + assertTrue(se.getMessage().contains( + "Authentication provider URL must not be null")); + } catch (AuthenticationException ae) { + fail("alternateAuthentication should NOT have thrown a AuthenticationException"); + } + } + + @Test + public void testUnableToParseJWT() throws Exception { + try { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + + KeyPair kp = kpg.genKeyPair(); + RSAPublicKey publicKey = (RSAPublicKey) kp.getPublic(); + + handler.setPublicKey(publicKey); + + Properties props = getProperties(); + handler.init(props); + + SignedJWT jwt = getJWT("bob", new Date(new Date().getTime() + 5000), + privateKey); + + Cookie cookie = new Cookie("hadoop-jwt", "ljm" + jwt.serialize()); + HttpServletRequest request = Mockito.mock(HttpServletRequest.class); + Mockito.when(request.getCookies()).thenReturn(new Cookie[] { cookie }); + Mockito.when(request.getRequestURL()).thenReturn( + new StringBuffer(SERVICE_URL)); + HttpServletResponse response = Mockito.mock(HttpServletResponse.class); + Mockito.when(response.encodeRedirectURL(SERVICE_URL)).thenReturn( + SERVICE_URL); + + AuthenticationToken token = handler.alternateAuthenticate(request, + response); + Mockito.verify(response).sendRedirect(REDIRECT_LOCATION); + } catch (ServletException se) { + fail("alternateAuthentication should NOT have thrown a ServletException"); + } catch (AuthenticationException ae) { + fail("alternateAuthentication should NOT have thrown a AuthenticationException"); + } + } + + @Test + public void testFailedSignatureValidationJWT() throws Exception { + try { + + // Create a public key that doesn't match the one needed to + // verify the signature - in order to make it fail verification... + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + + KeyPair kp = kpg.genKeyPair(); + RSAPublicKey publicKey = (RSAPublicKey) kp.getPublic(); + + handler.setPublicKey(publicKey); + + Properties props = getProperties(); + handler.init(props); + + SignedJWT jwt = getJWT("bob", new Date(new Date().getTime() + 5000), + privateKey); + + Cookie cookie = new Cookie("hadoop-jwt", jwt.serialize()); + HttpServletRequest request = Mockito.mock(HttpServletRequest.class); + Mockito.when(request.getCookies()).thenReturn(new Cookie[] { cookie }); + Mockito.when(request.getRequestURL()).thenReturn( + new StringBuffer(SERVICE_URL)); + HttpServletResponse response = Mockito.mock(HttpServletResponse.class); + Mockito.when(response.encodeRedirectURL(SERVICE_URL)).thenReturn( + SERVICE_URL); + + AuthenticationToken token = handler.alternateAuthenticate(request, + response); + Mockito.verify(response).sendRedirect(REDIRECT_LOCATION); + } catch (ServletException se) { + fail("alternateAuthentication should NOT have thrown a ServletException"); + } catch (AuthenticationException ae) { + fail("alternateAuthentication should NOT have thrown a AuthenticationException"); + } + } + + @Test + public void testExpiredJWT() throws Exception { + try { + handler.setPublicKey(publicKey); + + Properties props = getProperties(); + handler.init(props); + + SignedJWT jwt = getJWT("bob", new Date(new Date().getTime() - 1000), + privateKey); + + Cookie cookie = new Cookie("hadoop-jwt", jwt.serialize()); + HttpServletRequest request = Mockito.mock(HttpServletRequest.class); + Mockito.when(request.getCookies()).thenReturn(new Cookie[] { cookie }); + Mockito.when(request.getRequestURL()).thenReturn( + new StringBuffer(SERVICE_URL)); + HttpServletResponse response = Mockito.mock(HttpServletResponse.class); + Mockito.when(response.encodeRedirectURL(SERVICE_URL)).thenReturn( + SERVICE_URL); + + AuthenticationToken token = handler.alternateAuthenticate(request, + response); + Mockito.verify(response).sendRedirect(REDIRECT_LOCATION); + } catch (ServletException se) { + fail("alternateAuthentication should NOT have thrown a ServletException"); + } catch (AuthenticationException ae) { + fail("alternateAuthentication should NOT have thrown a AuthenticationException"); + } + } + + @Test + public void testInvalidAudienceJWT() throws Exception { + try { + handler.setPublicKey(publicKey); + + Properties props = getProperties(); + props + .put(JWTRedirectAuthenticationHandler.EXPECTED_JWT_AUDIENCES, "foo"); + handler.init(props); + + SignedJWT jwt = getJWT("bob", new Date(new Date().getTime() + 5000), + privateKey); + + Cookie cookie = new Cookie("hadoop-jwt", jwt.serialize()); + HttpServletRequest request = Mockito.mock(HttpServletRequest.class); + Mockito.when(request.getCookies()).thenReturn(new Cookie[] { cookie }); + Mockito.when(request.getRequestURL()).thenReturn( + new StringBuffer(SERVICE_URL)); + HttpServletResponse response = Mockito.mock(HttpServletResponse.class); + Mockito.when(response.encodeRedirectURL(SERVICE_URL)).thenReturn( + SERVICE_URL); + + AuthenticationToken token = handler.alternateAuthenticate(request, + response); + Mockito.verify(response).sendRedirect(REDIRECT_LOCATION); + } catch (ServletException se) { + fail("alternateAuthentication should NOT have thrown a ServletException"); + } catch (AuthenticationException ae) { + fail("alternateAuthentication should NOT have thrown a AuthenticationException"); + } + } + + @Test + public void testValidAudienceJWT() throws Exception { + try { + handler.setPublicKey(publicKey); + + Properties props = getProperties(); + props + .put(JWTRedirectAuthenticationHandler.EXPECTED_JWT_AUDIENCES, "bar"); + handler.init(props); + + SignedJWT jwt = getJWT("bob", new Date(new Date().getTime() + 5000), + privateKey); + + Cookie cookie = new Cookie("hadoop-jwt", jwt.serialize()); + HttpServletRequest request = Mockito.mock(HttpServletRequest.class); + Mockito.when(request.getCookies()).thenReturn(new Cookie[] { cookie }); + Mockito.when(request.getRequestURL()).thenReturn( + new StringBuffer(SERVICE_URL)); + HttpServletResponse response = Mockito.mock(HttpServletResponse.class); + Mockito.when(response.encodeRedirectURL(SERVICE_URL)).thenReturn( + SERVICE_URL); + + AuthenticationToken token = handler.alternateAuthenticate(request, + response); + Assert.assertEquals("bob", token.getUserName()); + } catch (ServletException se) { + fail("alternateAuthentication should NOT have thrown a ServletException"); + } catch (AuthenticationException ae) { + fail("alternateAuthentication should NOT have thrown an AuthenticationException"); + } + } + + @Test + public void testValidJWT() throws Exception { + try { + handler.setPublicKey(publicKey); + + Properties props = getProperties(); + handler.init(props); + + SignedJWT jwt = getJWT("alice", new Date(new Date().getTime() + 5000), + privateKey); + + Cookie cookie = new Cookie("hadoop-jwt", jwt.serialize()); + HttpServletRequest request = Mockito.mock(HttpServletRequest.class); + Mockito.when(request.getCookies()).thenReturn(new Cookie[] { cookie }); + Mockito.when(request.getRequestURL()).thenReturn( + new StringBuffer(SERVICE_URL)); + HttpServletResponse response = Mockito.mock(HttpServletResponse.class); + Mockito.when(response.encodeRedirectURL(SERVICE_URL)).thenReturn( + SERVICE_URL); + + AuthenticationToken token = handler.alternateAuthenticate(request, + response); + Assert.assertNotNull("Token should not be null.", token); + Assert.assertEquals("alice", token.getUserName()); + } catch (ServletException se) { + fail("alternateAuthentication should NOT have thrown a ServletException."); + } catch (AuthenticationException ae) { + fail("alternateAuthentication should NOT have thrown an AuthenticationException"); + } + } + + @Before + public void setup() throws Exception, NoSuchAlgorithmException { + setupKerberosRequirements(); + + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + + KeyPair kp = kpg.genKeyPair(); + publicKey = (RSAPublicKey) kp.getPublic(); + privateKey = (RSAPrivateKey) kp.getPrivate(); + + handler = new JWTRedirectAuthenticationHandler(); + } + + protected void setupKerberosRequirements() throws Exception { + String[] keytabUsers = new String[] { "HTTP/host1", "HTTP/host2", + "HTTP2/host1", "XHTTP/host" }; + String keytab = KerberosTestUtils.getKeytabFile(); + getKdc().createPrincipal(new File(keytab), keytabUsers); + } + + @After + public void teardown() throws Exception { + handler.destroy(); + } + + protected Properties getProperties() { + Properties props = new Properties(); + props.setProperty( + JWTRedirectAuthenticationHandler.AUTHENTICATION_PROVIDER_URL, + "https://localhost:8443/authserver"); + props.setProperty("kerberos.principal", + KerberosTestUtils.getServerPrincipal()); + props.setProperty("kerberos.keytab", KerberosTestUtils.getKeytabFile()); + return props; + } + + protected SignedJWT getJWT(String sub, Date expires, RSAPrivateKey privateKey) + throws Exception { + JWTClaimsSet claimsSet = new JWTClaimsSet(); + claimsSet.setSubject(sub); + claimsSet.setIssueTime(new Date(new Date().getTime())); + claimsSet.setIssuer("https://c2id.com"); + claimsSet.setCustomClaim("scope", "openid"); + claimsSet.setExpirationTime(expires); + List aud = new ArrayList(); + aud.add("bar"); + claimsSet.setAudience("bar"); + + JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.RS256).build(); + + SignedJWT signedJWT = new SignedJWT(header, claimsSet); + Base64URL sigInput = Base64URL.encode(signedJWT.getSigningInput()); + JWSSigner signer = new RSASSASigner(privateKey); + + signedJWT.sign(signer); + + return signedJWT; + } +} diff --git a/hadoop-common-project/hadoop-auth/src/test/java/org/apache/hadoop/security/authentication/util/TestCertificateUtil.java b/hadoop-common-project/hadoop-auth/src/test/java/org/apache/hadoop/security/authentication/util/TestCertificateUtil.java new file mode 100644 index 0000000000..f52b6d21c7 --- /dev/null +++ b/hadoop-common-project/hadoop-auth/src/test/java/org/apache/hadoop/security/authentication/util/TestCertificateUtil.java @@ -0,0 +1,96 @@ +/** + * 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.apache.hadoop.security.authentication.util; + +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.security.interfaces.RSAPublicKey; + +import javax.servlet.ServletException; + +import org.junit.Test; + +public class TestCertificateUtil { + + @Test + public void testInvalidPEMWithHeaderAndFooter() throws Exception { + String pem = "-----BEGIN CERTIFICATE-----\n" + + "MIICOjCCAaOgAwIBAgIJANXi/oWxvJNzMA0GCSqGSIb3DQEBBQUAMF8xCzAJBgNVBAYTAlVTMQ0w" + + "CwYDVQQIEwRUZXN0MQ0wCwYDVQQHEwRUZXN0MQ8wDQYDVQQKEwZIYWRvb3AxDTALBgNVBAsTBFRl" + + "c3QxEjAQBgNVBAMTCWxvY2FsaG9zdDAeFw0xNTAxMDIyMTE5MjRaFw0xNjAxMDIyMTE5MjRaMF8x" + + "CzAJBgNVBAYTAlVTMQ0wCwYDVQQIEwRUZXN0MQ0wCwYDVQQHEwRUZXN0MQ8wDQYDVQQKEwZIYWRv" + + "b3AxDTALBgNVBAsTBFRlc3QxEjAQBgNVBAMTCWxvY2FsaG9zdDCBnzANBgkqhkiG9w0BAQEFAAOB" + + "jQAwgYkCgYEAwpfpLdi7dWTHNzETt+L7618/dWUQFb/C7o1jIxFgbKOVIB6d5YmvUbJck5PYxFkz" + + "C25fmU5H71WGOI1Kle5TFDmIo+hqh5xqu1YNRZz9i6D94g+2AyYr9BpvH4ZfdHs7r9AU7c3kq68V" + + "7OPuuaHb25J8isiOyA3RiWuJGQlXTdkCAwEAATANBgkqhkiG9w0BAQUFAAOBgQAdRUyCUqE9sdim" + + "Fbll9BuZDKV16WXeWGq+kTd7ETe7l0fqXjq5EnrifOai0L/pXwVvS2jrFkKQRlRxRGUNaeEBZ2Wy" + + "9aTyR+HGHCfvwoCegc9rAVw/DLaRriSO/jnEXzYK6XLVKH+hx5UXrJ7Oyc7JjZUc3g9kCWORThCX" + + "Mzc1xA==" + "\n-----END CERTIFICATE-----"; + try { + CertificateUtil.parseRSAPublicKey(pem); + fail("Should not have thrown ServletException"); + } catch (ServletException se) { + assertTrue(se.getMessage().contains("PEM header")); + } + } + + @Test + public void testCorruptPEM() throws Exception { + String pem = "LJMLJMMIICOjCCAaOgAwIBAgIJANXi/oWxvJNzMA0GCSqGSIb3DQEBBQUAMF8xCzAJBgNVBAYTAlVTMQ0w" + + "CwYDVQQIEwRUZXN0MQ0wCwYDVQQHEwRUZXN0MQ8wDQYDVQQKEwZIYWRvb3AxDTALBgNVBAsTBFRl" + + "c3QxEjAQBgNVBAMTCWxvY2FsaG9zdDAeFw0xNTAxMDIyMTE5MjRaFw0xNjAxMDIyMTE5MjRaMF8x" + + "CzAJBgNVBAYTAlVTMQ0wCwYDVQQIEwRUZXN0MQ0wCwYDVQQHEwRUZXN0MQ8wDQYDVQQKEwZIYWRv" + + "b3AxDTALBgNVBAsTBFRlc3QxEjAQBgNVBAMTCWxvY2FsaG9zdDCBnzANBgkqhkiG9w0BAQEFAAOB" + + "jQAwgYkCgYEAwpfpLdi7dWTHNzETt+L7618/dWUQFb/C7o1jIxFgbKOVIB6d5YmvUbJck5PYxFkz" + + "C25fmU5H71WGOI1Kle5TFDmIo+hqh5xqu1YNRZz9i6D94g+2AyYr9BpvH4ZfdHs7r9AU7c3kq68V" + + "7OPuuaHb25J8isiOyA3RiWuJGQlXTdkCAwEAATANBgkqhkiG9w0BAQUFAAOBgQAdRUyCUqE9sdim" + + "Fbll9BuZDKV16WXeWGq+kTd7ETe7l0fqXjq5EnrifOai0L/pXwVvS2jrFkKQRlRxRGUNaeEBZ2Wy" + + "9aTyR+HGHCfvwoCegc9rAVw/DLaRriSO/jnEXzYK6XLVKH+hx5UXrJ7Oyc7JjZUc3g9kCWORThCX" + + "Mzc1xA=="; + try { + CertificateUtil.parseRSAPublicKey(pem); + fail("Should not have thrown ServletException"); + } catch (ServletException se) { + assertTrue(se.getMessage().contains("corrupt")); + } + } + + @Test + public void testValidPEM() throws Exception { + String pem = "MIICOjCCAaOgAwIBAgIJANXi/oWxvJNzMA0GCSqGSIb3DQEBBQUAMF8xCzAJBgNVBAYTAlVTMQ0w" + + "CwYDVQQIEwRUZXN0MQ0wCwYDVQQHEwRUZXN0MQ8wDQYDVQQKEwZIYWRvb3AxDTALBgNVBAsTBFRl" + + "c3QxEjAQBgNVBAMTCWxvY2FsaG9zdDAeFw0xNTAxMDIyMTE5MjRaFw0xNjAxMDIyMTE5MjRaMF8x" + + "CzAJBgNVBAYTAlVTMQ0wCwYDVQQIEwRUZXN0MQ0wCwYDVQQHEwRUZXN0MQ8wDQYDVQQKEwZIYWRv" + + "b3AxDTALBgNVBAsTBFRlc3QxEjAQBgNVBAMTCWxvY2FsaG9zdDCBnzANBgkqhkiG9w0BAQEFAAOB" + + "jQAwgYkCgYEAwpfpLdi7dWTHNzETt+L7618/dWUQFb/C7o1jIxFgbKOVIB6d5YmvUbJck5PYxFkz" + + "C25fmU5H71WGOI1Kle5TFDmIo+hqh5xqu1YNRZz9i6D94g+2AyYr9BpvH4ZfdHs7r9AU7c3kq68V" + + "7OPuuaHb25J8isiOyA3RiWuJGQlXTdkCAwEAATANBgkqhkiG9w0BAQUFAAOBgQAdRUyCUqE9sdim" + + "Fbll9BuZDKV16WXeWGq+kTd7ETe7l0fqXjq5EnrifOai0L/pXwVvS2jrFkKQRlRxRGUNaeEBZ2Wy" + + "9aTyR+HGHCfvwoCegc9rAVw/DLaRriSO/jnEXzYK6XLVKH+hx5UXrJ7Oyc7JjZUc3g9kCWORThCX" + + "Mzc1xA=="; + try { + RSAPublicKey pk = CertificateUtil.parseRSAPublicKey(pem); + assertTrue(pk != null); + assertTrue(pk.getAlgorithm().equals("RSA")); + } catch (ServletException se) { + fail("Should not have thrown ServletException"); + } + } + +} diff --git a/hadoop-common-project/hadoop-common/CHANGES.txt b/hadoop-common-project/hadoop-common/CHANGES.txt index f52e09f95c..5a8cda4b21 100644 --- a/hadoop-common-project/hadoop-common/CHANGES.txt +++ b/hadoop-common-project/hadoop-common/CHANGES.txt @@ -481,6 +481,9 @@ Release 2.8.0 - UNRELEASED HADOOP-9805. Refactor RawLocalFileSystem#rename for improved testability. (Jean-Pierre Matsumoto via cnauroth) + HADOOP-11717. Support JWT tokens for web single sign on to the Hadoop + servers. (Larry McCay via omalley) + OPTIMIZATIONS HADOOP-11785. Reduce the number of listStatus operation in distcp diff --git a/hadoop-project/pom.xml b/hadoop-project/pom.xml index a59ec06903..d12cc4296a 100644 --- a/hadoop-project/pom.xml +++ b/hadoop-project/pom.xml @@ -951,6 +951,19 @@ test + + com.nimbusds + nimbus-jose-jwt + 3.9 + compile + + + org.bouncycastle + bcprov-jdk15on + + + +