From c28a15d4b1bd40aae6097114b215303646f9fc34 Mon Sep 17 00:00:00 2001 From: Bjørn Christian Seime Date: Mon, 9 Jul 2018 16:44:38 +0200 Subject: Move AthenzPrincipalFilter to jdisc-security-filters --- .../security/athenz/AthenzPrincipalFilter.java | 95 ++++++++++ .../configdefinitions/athenz-principal-filter.def | 8 + .../security/athenz/AthenzPrincipalFilterTest.java | 200 +++++++++++++++++++++ 3 files changed, 303 insertions(+) create mode 100644 jdisc-security-filters/src/main/java/com/yahoo/jdisc/http/filter/security/athenz/AthenzPrincipalFilter.java create mode 100644 jdisc-security-filters/src/main/resources/configdefinitions/athenz-principal-filter.def create mode 100644 jdisc-security-filters/src/test/java/com/yahoo/jdisc/http/filter/security/athenz/AthenzPrincipalFilterTest.java (limited to 'jdisc-security-filters/src') diff --git a/jdisc-security-filters/src/main/java/com/yahoo/jdisc/http/filter/security/athenz/AthenzPrincipalFilter.java b/jdisc-security-filters/src/main/java/com/yahoo/jdisc/http/filter/security/athenz/AthenzPrincipalFilter.java new file mode 100644 index 00000000000..ad6c82138e1 --- /dev/null +++ b/jdisc-security-filters/src/main/java/com/yahoo/jdisc/http/filter/security/athenz/AthenzPrincipalFilter.java @@ -0,0 +1,95 @@ +// Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.jdisc.http.filter.security.athenz; + +import com.google.inject.Inject; +import com.yahoo.jdisc.Response; +import com.yahoo.jdisc.http.filter.DiscFilterRequest; +import com.yahoo.jdisc.http.filter.security.cors.CorsFilterConfig; +import com.yahoo.jdisc.http.filter.security.cors.CorsRequestFilterBase; +import com.yahoo.vespa.athenz.api.AthenzPrincipal; +import com.yahoo.vespa.athenz.api.NToken; +import com.yahoo.vespa.athenz.utils.AthenzIdentities; +import com.yahoo.vespa.athenz.utils.ntoken.NTokenValidator; + +import java.nio.file.Paths; +import java.security.cert.X509Certificate; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; + + +/** + * Authenticates Athenz principal, either through: + * 1. TLS client authentication (based on Athenz x509 identity certficiate). + * 2. The principal token (NToken) header. + * The TLS authentication is based on the following assumptions: + * - The underlying connector is configured with 'clientAuth' set to either WANT_AUTH or NEED_AUTH. + * - The trust store is configured with the Athenz CA certificates only. + * + * @author bjorncs + */ +public class AthenzPrincipalFilter extends CorsRequestFilterBase { + + private final NTokenValidator validator; + private final String principalTokenHeader; + + @Inject + public AthenzPrincipalFilter(AthenzPrincipalFilterConfig athenzPrincipalFilterConfig, CorsFilterConfig corsConfig) { + this(new NTokenValidator(Paths.get(athenzPrincipalFilterConfig.athenzConfFile())), + athenzPrincipalFilterConfig.principalHeaderName(), + new HashSet<>(corsConfig.allowedUrls())); + } + + AthenzPrincipalFilter(NTokenValidator validator, + String principalTokenHeader, + Set corsAllowedUrls) { + super(corsAllowedUrls); + this.validator = validator; + this.principalTokenHeader = principalTokenHeader; + } + + @Override + public Optional filterRequest(DiscFilterRequest request) { + try { + Optional certificatePrincipal = getClientCertificate(request) + .map(AthenzIdentities::from) + .map(AthenzPrincipal::new); + Optional nTokenPrincipal = getPrincipalToken(request, principalTokenHeader) + .map(validator::validate); + + if (!certificatePrincipal.isPresent() && !nTokenPrincipal.isPresent()) { + String errorMessage = "Unable to authenticate Athenz identity. " + + "Either client certificate or principal token is required."; + return Optional.of(new ErrorResponse(Response.Status.UNAUTHORIZED, errorMessage)); + } + if (certificatePrincipal.isPresent() && nTokenPrincipal.isPresent() + && !certificatePrincipal.get().getIdentity().equals(nTokenPrincipal.get().getIdentity())) { + String errorMessage = String.format( + "Identity in principal token does not match x509 CN: token-identity=%s, cert-identity=%s", + nTokenPrincipal.get().getIdentity().getFullName(), + certificatePrincipal.get().getIdentity().getFullName()); + return Optional.of(new ErrorResponse(Response.Status.UNAUTHORIZED, errorMessage)); + } + AthenzPrincipal principal = nTokenPrincipal.orElseGet(certificatePrincipal::get); + request.setUserPrincipal(principal); + request.setRemoteUser(principal.getName()); + return Optional.empty(); + } catch (Exception e) { + return Optional.of(new ErrorResponse(Response.Status.UNAUTHORIZED, e.getMessage())); + } + } + + private static Optional getClientCertificate(DiscFilterRequest request) { + List chain = request.getClientCertificateChain(); + if (chain.isEmpty()) return Optional.empty(); + return Optional.of(chain.get(0)); + } + + private static Optional getPrincipalToken(DiscFilterRequest request, String principalTokenHeaderName) { + return Optional.ofNullable(request.getHeader(principalTokenHeaderName)) + .filter(token -> !token.isEmpty()) + .map(NToken::new); + } + +} diff --git a/jdisc-security-filters/src/main/resources/configdefinitions/athenz-principal-filter.def b/jdisc-security-filters/src/main/resources/configdefinitions/athenz-principal-filter.def new file mode 100644 index 00000000000..59e481b0d80 --- /dev/null +++ b/jdisc-security-filters/src/main/resources/configdefinitions/athenz-principal-filter.def @@ -0,0 +1,8 @@ +# Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +namespace=jdisc.http.filter.security.athenz + +# Principal header name +principalHeaderName string default="Athenz-Principal-Auth" + +# Path to athenz.conf file +athenzConfFile string diff --git a/jdisc-security-filters/src/test/java/com/yahoo/jdisc/http/filter/security/athenz/AthenzPrincipalFilterTest.java b/jdisc-security-filters/src/test/java/com/yahoo/jdisc/http/filter/security/athenz/AthenzPrincipalFilterTest.java new file mode 100644 index 00000000000..be5ab9c1d77 --- /dev/null +++ b/jdisc-security-filters/src/test/java/com/yahoo/jdisc/http/filter/security/athenz/AthenzPrincipalFilterTest.java @@ -0,0 +1,200 @@ +// Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.jdisc.http.filter.security.athenz; + +import com.yahoo.jdisc.Response; +import com.yahoo.jdisc.handler.ContentChannel; +import com.yahoo.jdisc.handler.ReadableContentChannel; +import com.yahoo.jdisc.handler.ResponseHandler; +import com.yahoo.jdisc.http.filter.DiscFilterRequest; +import com.yahoo.vespa.athenz.api.AthenzIdentity; +import com.yahoo.vespa.athenz.api.AthenzPrincipal; +import com.yahoo.vespa.athenz.api.AthenzUser; +import com.yahoo.vespa.athenz.api.NToken; +import com.yahoo.vespa.athenz.tls.KeyAlgorithm; +import com.yahoo.vespa.athenz.tls.KeyUtils; +import com.yahoo.vespa.athenz.tls.X509CertificateBuilder; +import com.yahoo.vespa.athenz.utils.ntoken.NTokenValidator; +import org.junit.Before; +import org.junit.Test; + +import javax.security.auth.x500.X500Principal; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.UncheckedIOException; +import java.security.KeyPair; +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; +import java.util.Set; + +import static com.yahoo.jdisc.Response.Status.UNAUTHORIZED; +import static com.yahoo.vespa.athenz.tls.SignatureAlgorithm.SHA256_WITH_RSA; +import static java.util.Collections.emptyList; +import static java.util.Collections.singleton; +import static java.util.Collections.singletonList; +import static java.util.stream.Collectors.joining; +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.notNullValue; +import static org.junit.Assert.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * @author bjorncs + */ +public class AthenzPrincipalFilterTest { + + private static final NToken NTOKEN = new NToken("dummy"); + private static final String ATHENZ_PRINCIPAL_HEADER = "Athenz-Principal-Auth"; + private static final AthenzIdentity IDENTITY = AthenzUser.fromUserId("bob"); + private static final X509Certificate CERTIFICATE = createSelfSignedCertificate(IDENTITY); + private static final String ORIGIN = "http://localhost"; + private static final Set CORS_ALLOWED_URLS = singleton(ORIGIN); + + private NTokenValidator validator; + + @Before + public void before() { + validator = mock(NTokenValidator.class); + } + + @Test + public void valid_ntoken_is_accepted() { + DiscFilterRequest request = createRequestMock(); + AthenzPrincipal principal = new AthenzPrincipal(IDENTITY, NTOKEN); + when(request.getHeader(ATHENZ_PRINCIPAL_HEADER)).thenReturn(NTOKEN.getRawToken()); + when(request.getClientCertificateChain()).thenReturn(emptyList()); + when(validator.validate(NTOKEN)).thenReturn(principal); + + AthenzPrincipalFilter filter = new AthenzPrincipalFilter(validator, ATHENZ_PRINCIPAL_HEADER, CORS_ALLOWED_URLS); + filter.filter(request, new ResponseHandlerMock()); + + verify(request).setUserPrincipal(principal); + } + + private DiscFilterRequest createRequestMock() { + DiscFilterRequest request = mock(DiscFilterRequest.class); + when(request.getHeader("Origin")).thenReturn(ORIGIN); + return request; + } + + @Test + public void missing_token_and_certificate_is_unauthorized() { + DiscFilterRequest request = createRequestMock(); + when(request.getHeader(ATHENZ_PRINCIPAL_HEADER)).thenReturn(null); + when(request.getClientCertificateChain()).thenReturn(emptyList()); + + ResponseHandlerMock responseHandler = new ResponseHandlerMock(); + + AthenzPrincipalFilter filter = new AthenzPrincipalFilter(validator, ATHENZ_PRINCIPAL_HEADER, CORS_ALLOWED_URLS); + filter.filter(request, responseHandler); + + assertUnauthorized(responseHandler, "Unable to authenticate Athenz identity"); + } + + @Test + public void invalid_token_is_unauthorized() { + DiscFilterRequest request = createRequestMock(); + String errorMessage = "Invalid token"; + when(request.getHeader(ATHENZ_PRINCIPAL_HEADER)).thenReturn(NTOKEN.getRawToken()); + when(request.getClientCertificateChain()).thenReturn(emptyList()); + when(validator.validate(NTOKEN)).thenThrow(new NTokenValidator.InvalidTokenException(errorMessage)); + + ResponseHandlerMock responseHandler = new ResponseHandlerMock(); + + AthenzPrincipalFilter filter = new AthenzPrincipalFilter(validator, ATHENZ_PRINCIPAL_HEADER, CORS_ALLOWED_URLS); + filter.filter(request, responseHandler); + + assertUnauthorized(responseHandler, errorMessage); + } + + @Test + public void certificate_is_accepted() { + DiscFilterRequest request = createRequestMock(); + when(request.getHeader(ATHENZ_PRINCIPAL_HEADER)).thenReturn(null); + when(request.getClientCertificateChain()).thenReturn(singletonList(CERTIFICATE)); + + ResponseHandlerMock responseHandler = new ResponseHandlerMock(); + + AthenzPrincipalFilter filter = new AthenzPrincipalFilter(validator, ATHENZ_PRINCIPAL_HEADER, CORS_ALLOWED_URLS); + filter.filter(request, responseHandler); + + AthenzPrincipal expectedPrincipal = new AthenzPrincipal(IDENTITY); + verify(request).setUserPrincipal(expectedPrincipal); + } + + @Test + public void both_ntoken_and_certificate_is_accepted() { + DiscFilterRequest request = createRequestMock(); + AthenzPrincipal principalWithToken = new AthenzPrincipal(IDENTITY, NTOKEN); + when(request.getHeader(ATHENZ_PRINCIPAL_HEADER)).thenReturn(NTOKEN.getRawToken()); + when(request.getClientCertificateChain()).thenReturn(singletonList(CERTIFICATE)); + when(validator.validate(NTOKEN)).thenReturn(principalWithToken); + + ResponseHandlerMock responseHandler = new ResponseHandlerMock(); + + AthenzPrincipalFilter filter = new AthenzPrincipalFilter(validator, ATHENZ_PRINCIPAL_HEADER, CORS_ALLOWED_URLS); + filter.filter(request, responseHandler); + + verify(request).setUserPrincipal(principalWithToken); + } + + @Test + public void conflicting_ntoken_and_certificate_is_unauthorized() { + DiscFilterRequest request = createRequestMock(); + AthenzUser conflictingIdentity = AthenzUser.fromUserId("mallory"); + when(request.getHeader(ATHENZ_PRINCIPAL_HEADER)).thenReturn(NTOKEN.getRawToken()); + when(request.getClientCertificateChain()) + .thenReturn(singletonList(createSelfSignedCertificate(conflictingIdentity))); + when(validator.validate(NTOKEN)).thenReturn(new AthenzPrincipal(IDENTITY)); + + ResponseHandlerMock responseHandler = new ResponseHandlerMock(); + + AthenzPrincipalFilter filter = new AthenzPrincipalFilter(validator, ATHENZ_PRINCIPAL_HEADER, CORS_ALLOWED_URLS); + filter.filter(request, responseHandler); + + assertUnauthorized(responseHandler, "Identity in principal token does not match x509 CN"); + } + + private static void assertUnauthorized(ResponseHandlerMock responseHandler, String expectedMessageSubstring) { + assertThat(responseHandler.response, notNullValue()); + assertThat(responseHandler.response.getStatus(), equalTo(UNAUTHORIZED)); + assertThat(responseHandler.getResponseContent(), containsString(expectedMessageSubstring)); + } + + private static class ResponseHandlerMock implements ResponseHandler { + + public Response response; + public ReadableContentChannel contentChannel; + + @Override + public ContentChannel handleResponse(Response r) { + response = Objects.requireNonNull(r); + contentChannel = new ReadableContentChannel(); + return contentChannel; + } + + public String getResponseContent() { + try (BufferedReader br = new BufferedReader(new InputStreamReader(contentChannel.toStream()))) { + return br.lines().collect(joining(System.lineSeparator())); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + } + + private static X509Certificate createSelfSignedCertificate(AthenzIdentity identity) { + KeyPair keyPair = KeyUtils.generateKeypair(KeyAlgorithm.RSA, 512); + X500Principal x500Name = new X500Principal("CN="+ identity.getFullName()); + Instant now = Instant.now(); + return X509CertificateBuilder + .fromKeypair(keyPair, x500Name, now, now.plus(Duration.ofDays(30)), SHA256_WITH_RSA, 1) + .build(); + } + +} -- cgit v1.2.3