This is an automated email from the ASF dual-hosted git repository. quantranhong1999 pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/james-project.git
commit ab27aba9324635f12b35ac5c74e511563ed4c574 Author: Quan Tran <[email protected]> AuthorDate: Fri Jul 24 16:49:13 2026 +0700 JAMES-4215 Add optional SASL GSSAPI Kerberos mechanism Introduce a dedicated optional SASL Kerberos module implementing GSSAPI authentication according to RFC 4752 through the JDK SASL provider. Validate the configured service principal and keytab at startup, accept only the auth QOP, inherit auth.requireSSL, and reject Kerberos identities that cannot be mapped injectively to James usernames. Cover configuration, authorization, resource cleanup, keytab validation, and JDK GSSAPI interoperability with an embedded Kerberos KDC. --- code-coverage-report/pom.xml | 5 + pom.xml | 17 + .../protocols/api/sasl/SaslMechanismNames.java | 1 + protocols/pom.xml | 1 + protocols/sasl-kerberos/pom.xml | 70 ++++ .../kerberos/GssapiAuthorizeCallbackHandler.java | 106 ++++++ .../sasl/kerberos/GssapiSaslConfiguration.java | 88 +++++ .../sasl/kerberos/GssapiSaslExchange.java | 207 ++++++++++++ .../sasl/kerberos/GssapiSaslMechanism.java | 57 ++++ .../sasl/kerberos/GssapiSaslMechanismFactory.java | 68 ++++ .../sasl/kerberos/GssapiSaslServerFactory.java} | 14 +- .../sasl/kerberos/JdkSaslServerFactory.java} | 27 +- .../sasl/kerberos/KerberosLoginContext.java} | 29 +- .../sasl/kerberos/KerberosLoginContextFactory.java | 57 ++++ .../sasl/kerberos/KeyTabPrincipalVerifier.java | 67 ++++ .../protocols/sasl/kerberos/SubjectSaslServer.java | 62 ++++ .../GssapiAuthorizeCallbackHandlerTest.java | 176 ++++++++++ .../sasl/kerberos/GssapiJdkInteropTest.java | 98 ++++++ .../sasl/kerberos/GssapiSaslConfigurationTest.java | 112 +++++++ .../sasl/kerberos/GssapiSaslMechanismTest.java | 354 +++++++++++++++++++++ .../protocols/sasl/kerberos/GssapiTestClient.java | 136 ++++++++ .../sasl/kerberos/KerberosTestFixture.java | 97 ++++++ .../sasl/kerberos/KeyTabPrincipalVerifierTest.java | 64 ++++ 23 files changed, 1891 insertions(+), 22 deletions(-) diff --git a/code-coverage-report/pom.xml b/code-coverage-report/pom.xml index 247ba42c67..1ff115d8c8 100644 --- a/code-coverage-report/pom.xml +++ b/code-coverage-report/pom.xml @@ -1144,6 +1144,11 @@ <artifactId>protocols-pop3</artifactId> <version>${project.version}</version> </dependency> + <dependency> + <groupId>org.apache.james.protocols</groupId> + <artifactId>protocols-sasl-kerberos</artifactId> + <version>${project.version}</version> + </dependency> <dependency> <groupId>org.apache.james.protocols</groupId> <artifactId>protocols-smtp</artifactId> diff --git a/pom.xml b/pom.xml index 5611e9c94c..fb65c64af3 100644 --- a/pom.xml +++ b/pom.xml @@ -661,6 +661,7 @@ <es-reporter.version>6.0.0-RC3</es-reporter.version> <guava.version>33.4.8-jre</guava.version> <jutf7.version>1.0.0</jutf7.version> + <kerby.version>2.1.1</kerby.version> <apache.httpcomponents.version>4.5.14</apache.httpcomponents.version> <!-- maven-mailetdocs-plugin artifacts --> @@ -2166,6 +2167,17 @@ <artifactId>protocols-sasl</artifactId> <version>${project.version}</version> </dependency> + <dependency> + <groupId>${james.protocols.groupId}</groupId> + <artifactId>protocols-sasl-kerberos</artifactId> + <version>${project.version}</version> + </dependency> + <dependency> + <groupId>${james.protocols.groupId}</groupId> + <artifactId>protocols-sasl-kerberos</artifactId> + <version>${project.version}</version> + <type>test-jar</type> + </dependency> <dependency> <groupId>${james.protocols.groupId}</groupId> <artifactId>protocols-smtp</artifactId> @@ -2778,6 +2790,11 @@ </exclusion> </exclusions> </dependency> + <dependency> + <groupId>org.apache.kerby</groupId> + <artifactId>kerb-simplekdc</artifactId> + <version>${kerby.version}</version> + </dependency> <dependency> <groupId>org.apache.logging.log4j</groupId> <artifactId>log4j-api</artifactId> diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismNames.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismNames.java index 20aad26648..0544641544 100644 --- a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismNames.java +++ b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismNames.java @@ -20,6 +20,7 @@ package org.apache.james.protocols.api.sasl; public final class SaslMechanismNames { + public static final String GSSAPI = "GSSAPI"; public static final String LOGIN = "LOGIN"; public static final String PLAIN = "PLAIN"; public static final String OAUTHBEARER = "OAUTHBEARER"; diff --git a/protocols/pom.xml b/protocols/pom.xml index a643b050a3..71d31cc179 100644 --- a/protocols/pom.xml +++ b/protocols/pom.xml @@ -43,6 +43,7 @@ <module>netty</module> <module>pop3</module> <module>sasl</module> + <module>sasl-kerberos</module> <module>smtp</module> </modules> diff --git a/protocols/sasl-kerberos/pom.xml b/protocols/sasl-kerberos/pom.xml new file mode 100644 index 0000000000..6217464232 --- /dev/null +++ b/protocols/sasl-kerberos/pom.xml @@ -0,0 +1,70 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + 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. +--> +<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd"> + <modelVersion>4.0.0</modelVersion> + + <parent> + <groupId>org.apache.james.protocols</groupId> + <artifactId>protocols</artifactId> + <version>3.10.0-SNAPSHOT</version> + <relativePath>../pom.xml</relativePath> + </parent> + + <artifactId>protocols-sasl-kerberos</artifactId> + <packaging>jar</packaging> + + <name>Apache James :: Protocols :: SASL Kerberos</name> + + <dependencies> + <dependency> + <groupId>${james.groupId}</groupId> + <artifactId>testing-base</artifactId> + <scope>test</scope> + </dependency> + <dependency> + <groupId>${james.protocols.groupId}</groupId> + <artifactId>protocols-api</artifactId> + </dependency> + <dependency> + <groupId>org.apache.commons</groupId> + <artifactId>commons-configuration2</artifactId> + </dependency> + <dependency> + <groupId>org.apache.kerby</groupId> + <artifactId>kerb-simplekdc</artifactId> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.mockito</groupId> + <artifactId>mockito-core</artifactId> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.slf4j</groupId> + <artifactId>jcl-over-slf4j</artifactId> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.slf4j</groupId> + <artifactId>slf4j-api</artifactId> + </dependency> + </dependencies> + +</project> diff --git a/protocols/sasl-kerberos/src/main/java/org/apache/james/protocols/sasl/kerberos/GssapiAuthorizeCallbackHandler.java b/protocols/sasl-kerberos/src/main/java/org/apache/james/protocols/sasl/kerberos/GssapiAuthorizeCallbackHandler.java new file mode 100644 index 0000000000..92d0207760 --- /dev/null +++ b/protocols/sasl-kerberos/src/main/java/org/apache/james/protocols/sasl/kerberos/GssapiAuthorizeCallbackHandler.java @@ -0,0 +1,106 @@ +/**************************************************************** + * 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.james.protocols.sasl.kerberos; + +import java.io.IOException; +import java.util.Locale; +import java.util.Optional; + +import javax.security.auth.callback.Callback; +import javax.security.auth.callback.CallbackHandler; +import javax.security.auth.callback.UnsupportedCallbackException; +import javax.security.auth.kerberos.KerberosPrincipal; +import javax.security.sasl.AuthorizeCallback; + +import org.apache.james.core.Username; +import org.apache.james.protocols.api.sasl.SaslAuthenticationResult; +import org.apache.james.protocols.api.sasl.SaslAuthenticator; +import org.apache.james.protocols.api.sasl.SaslFailure; +import org.apache.james.protocols.api.sasl.SaslIdentity; + +class GssapiAuthorizeCallbackHandler implements CallbackHandler { + private static Username canonicalAuthenticationId(String authenticationId) { + if (authenticationId == null || authenticationId.chars().anyMatch(character -> character > 0x7F)) { + throw new IllegalArgumentException("GSSAPI authentication identity must contain only ASCII characters"); + } + + KerberosPrincipal principal = new KerberosPrincipal(authenticationId); + String realm = principal.getRealm(); + String principalName = principal.getName(); + String principalComponents = principalName.substring(0, principalName.length() - realm.length() - 1); + String canonicalPrincipal = principalComponents.toLowerCase(Locale.ROOT) + "@" + realm.toUpperCase(Locale.ROOT); + + // James usernames are case-insensitive, so accept one Kerberos spelling to prevent case-distinct principals from collapsing. + if (!authenticationId.equals(canonicalPrincipal)) { + throw new IllegalArgumentException("GSSAPI authentication identity is not canonical"); + } + + Username username = Username.of(canonicalPrincipal); + // Case folding is intentional; reject any additional normalization that could collapse distinct Kerberos principals. + if (!username.asString().equals(canonicalPrincipal.toLowerCase(Locale.US))) { + throw new IllegalArgumentException("GSSAPI authentication identity cannot be mapped without normalization"); + } + return username; + } + + private final SaslAuthenticator authenticator; + private Optional<SaslAuthenticationResult> result; + + GssapiAuthorizeCallbackHandler(SaslAuthenticator authenticator) { + this.authenticator = authenticator; + this.result = Optional.empty(); + } + + @Override + public void handle(Callback[] callbacks) throws IOException, UnsupportedCallbackException { + for (Callback callback : callbacks) { + if (!(callback instanceof AuthorizeCallback authorizeCallback)) { + throw new UnsupportedCallbackException(callback); + } + authorize(authorizeCallback); + } + } + + Optional<SaslAuthenticationResult> result() { + return result; + } + + private void authorize(AuthorizeCallback callback) { + try { + Username authenticationId = canonicalAuthenticationId(callback.getAuthenticationID()); + Username authorizationId = Optional.ofNullable(callback.getAuthorizationID()) + .filter(value -> !value.isEmpty()) + .map(Username::of) + .orElse(authenticationId); + + result = Optional.of(authenticator.authorize(new SaslIdentity(authenticationId, authorizationId))); + switch (result.orElseThrow()) { + case SaslAuthenticationResult.Success success -> { + callback.setAuthorized(true); + callback.setAuthorizedID(success.identity().authorizationId().asString()); + } + case SaslAuthenticationResult.Failure ignored -> callback.setAuthorized(false); + } + } catch (IllegalArgumentException e) { + result = Optional.of(new SaslAuthenticationResult.Failure(SaslFailure.malformed("Malformed GSSAPI identity."))); + callback.setAuthorized(false); + } + } +} diff --git a/protocols/sasl-kerberos/src/main/java/org/apache/james/protocols/sasl/kerberos/GssapiSaslConfiguration.java b/protocols/sasl-kerberos/src/main/java/org/apache/james/protocols/sasl/kerberos/GssapiSaslConfiguration.java new file mode 100644 index 0000000000..aed55a8a73 --- /dev/null +++ b/protocols/sasl-kerberos/src/main/java/org/apache/james/protocols/sasl/kerberos/GssapiSaslConfiguration.java @@ -0,0 +1,88 @@ +/**************************************************************** + * 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.james.protocols.sasl.kerberos; + +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; + +import javax.security.auth.kerberos.KerberosPrincipal; + +import org.apache.commons.configuration2.HierarchicalConfiguration; +import org.apache.commons.configuration2.ex.ConfigurationException; +import org.apache.commons.configuration2.tree.ImmutableNode; + +public record GssapiSaslConfiguration(String serviceName, + String serverName, + String principal, + Path keyTab, + boolean requireSSL) { + private static final String CONFIGURATION_PATH = "auth.gssapi"; + private static final boolean REQUIRE_SSL_DEFAULT = true; + + public static GssapiSaslConfiguration from(HierarchicalConfiguration<ImmutableNode> serverConfiguration) throws ConfigurationException { + if (serverConfiguration.immutableConfigurationsAt(CONFIGURATION_PATH).isEmpty()) { + throw new ConfigurationException("GSSAPI SASL mechanism requires an auth.gssapi configuration"); + } + + HierarchicalConfiguration<ImmutableNode> configuration = serverConfiguration.configurationAt(CONFIGURATION_PATH); + String serviceName = required(configuration, "serviceName"); + String serverName = required(configuration, "serverName"); + String principal = required(configuration, "principal"); + Path keyTab = keyTab(required(configuration, "keyTab")); + validatePrincipal(serviceName, serverName, principal); + + return new GssapiSaslConfiguration(serviceName, serverName, principal, keyTab, + serverConfiguration.getBoolean("auth.requireSSL", REQUIRE_SSL_DEFAULT)); + } + + private static String required(HierarchicalConfiguration<ImmutableNode> configuration, String property) throws ConfigurationException { + String value = configuration.getString(property); + if (value == null || value.isBlank()) { + throw new ConfigurationException("auth.gssapi." + property + " must be specified"); + } + return value.trim(); + } + + private static Path keyTab(String value) throws ConfigurationException { + try { + Path path = value.startsWith("file:") ? Path.of(URI.create(value)) : Path.of(value); + Path normalizedPath = path.toAbsolutePath().normalize(); + if (!Files.isRegularFile(normalizedPath) || !Files.isReadable(normalizedPath)) { + throw new ConfigurationException("auth.gssapi.keyTab must reference a readable regular file"); + } + return normalizedPath; + } catch (RuntimeException e) { + throw new ConfigurationException("auth.gssapi.keyTab is invalid", e); + } + } + + private static void validatePrincipal(String serviceName, String serverName, String principal) throws ConfigurationException { + try { + KerberosPrincipal kerberosPrincipal = new KerberosPrincipal(principal); + String expectedPrincipal = serviceName + "/" + serverName + "@" + kerberosPrincipal.getRealm(); + if (!kerberosPrincipal.getName().equals(expectedPrincipal)) { + throw new ConfigurationException("auth.gssapi.principal must match the configured serviceName and serverName"); + } + } catch (IllegalArgumentException e) { + throw new ConfigurationException("auth.gssapi.principal is invalid", e); + } + } +} diff --git a/protocols/sasl-kerberos/src/main/java/org/apache/james/protocols/sasl/kerberos/GssapiSaslExchange.java b/protocols/sasl-kerberos/src/main/java/org/apache/james/protocols/sasl/kerberos/GssapiSaslExchange.java new file mode 100644 index 0000000000..648b263c65 --- /dev/null +++ b/protocols/sasl-kerberos/src/main/java/org/apache/james/protocols/sasl/kerberos/GssapiSaslExchange.java @@ -0,0 +1,207 @@ +/**************************************************************** + * 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.james.protocols.sasl.kerberos; + +import java.util.Optional; + +import javax.security.auth.login.LoginException; +import javax.security.sasl.Sasl; +import javax.security.sasl.SaslException; +import javax.security.sasl.SaslServer; + +import org.apache.james.protocols.api.sasl.SaslAuthenticationResult; +import org.apache.james.protocols.api.sasl.SaslAuthenticator; +import org.apache.james.protocols.api.sasl.SaslExchange; +import org.apache.james.protocols.api.sasl.SaslFailure; +import org.apache.james.protocols.api.sasl.SaslInitialRequest; +import org.apache.james.protocols.api.sasl.SaslStep; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +class GssapiSaslExchange implements SaslExchange { + private enum State { + NEW, + ACTIVE, + CLOSED + } + + private static final Logger LOGGER = LoggerFactory.getLogger(GssapiSaslExchange.class); + + private final SaslInitialRequest request; + private final GssapiSaslConfiguration configuration; + private final KerberosLoginContextFactory loginContextFactory; + private final GssapiSaslServerFactory saslServerFactory; + private final GssapiAuthorizeCallbackHandler callbackHandler; + + private State state; + private KerberosLoginContext loginContext; + private SaslServer saslServer; + + GssapiSaslExchange(SaslInitialRequest request, + SaslAuthenticator authenticator, + GssapiSaslConfiguration configuration, + KerberosLoginContextFactory loginContextFactory, + GssapiSaslServerFactory saslServerFactory) { + this.request = request; + this.configuration = configuration; + this.loginContextFactory = loginContextFactory; + this.saslServerFactory = saslServerFactory; + this.callbackHandler = new GssapiAuthorizeCallbackHandler(authenticator); + this.state = State.NEW; + } + + @Override + public synchronized SaslStep firstStep() { + if (state != State.NEW) { + return terminalFailure(SaslFailure.malformed("GSSAPI exchange was already started.")); + } + state = State.ACTIVE; + return request.initialResponse() + .map(this::evaluate) + .orElseGet(() -> new SaslStep.Challenge(Optional.empty())); + } + + @Override + public synchronized SaslStep onResponse(byte[] clientResponse) { + if (state != State.ACTIVE) { + return terminalFailure(SaslFailure.malformed("GSSAPI exchange is not active.")); + } + return evaluate(clientResponse); + } + + @Override + public synchronized void close() { + if (state != State.CLOSED) { + state = State.CLOSED; + cleanup(); + } + } + + private SaslStep evaluate(byte[] clientResponse) { + try { + initialize(); + } catch (LoginException | SaslException e) { + return terminalFailure(SaslFailure.serverError(Optional.empty(), Optional.empty(), "GSSAPI server credentials are unavailable.", e)); + } catch (RuntimeException e) { + return terminalFailure(SaslFailure.serverError(Optional.empty(), Optional.empty(), "GSSAPI authentication initialization failed.", e)); + } + + try { + byte[] output = SubjectSaslServer.evaluate(loginContext.subject(), saslServer, clientResponse); + if (!saslServer.isComplete()) { + return new SaslStep.Challenge(optional(output)); + } + return complete(output); + } catch (SaslException e) { + return callbackFailure() + .map(this::terminalFailure) + .orElseGet(() -> terminalFailure(SaslFailure.authenticationFailed(Optional.empty(), Optional.empty(), "GSSAPI authentication failed."))); + } catch (RuntimeException e) { + return terminalFailure(SaslFailure.serverError(Optional.empty(), Optional.empty(), "GSSAPI authentication failed.", e)); + } + } + + private void initialize() throws LoginException, SaslException { + if (saslServer == null) { + KerberosLoginContext newLoginContext = loginContextFactory.login(configuration); + try { + SaslServer newSaslServer = SubjectSaslServer.create(newLoginContext.subject(), saslServerFactory, configuration, callbackHandler); + loginContext = newLoginContext; + saslServer = newSaslServer; + } catch (SaslException | RuntimeException e) { + closeAfterInitializationFailure(newLoginContext, e); + throw e; + } + } + } + + private void closeAfterInitializationFailure(KerberosLoginContext newLoginContext, Exception failure) { + try { + newLoginContext.close(); + } catch (LoginException e) { + failure.addSuppressed(e); + } + } + + private SaslStep complete(byte[] output) { + if (output != null && output.length > 0) { + return terminalFailure(SaslFailure.serverError(Optional.empty(), Optional.empty(), "GSSAPI provider returned unexpected final server data.")); + } + if (!"auth".equals(saslServer.getNegotiatedProperty(Sasl.QOP))) { + return terminalFailure(SaslFailure.serverError(Optional.empty(), Optional.empty(), "GSSAPI negotiated an unsupported security layer.")); + } + + return callbackHandler.result() + .map(this::completeAuthorization) + .orElseGet(() -> terminalFailure(SaslFailure.serverError(Optional.empty(), Optional.empty(), "GSSAPI authorization did not complete."))); + } + + private SaslStep completeAuthorization(SaslAuthenticationResult result) { + return switch (result) { + case SaslAuthenticationResult.Success success -> terminalSuccess(new SaslStep.Success(success.identity(), Optional.empty())); + case SaslAuthenticationResult.Failure failure -> terminalFailure(failure.failure()); + }; + } + + private Optional<SaslFailure> callbackFailure() { + return callbackHandler.result() + .filter(SaslAuthenticationResult.Failure.class::isInstance) + .map(SaslAuthenticationResult.Failure.class::cast) + .map(SaslAuthenticationResult.Failure::failure); + } + + private SaslStep terminalSuccess(SaslStep.Success success) { + close(); + return success; + } + + private SaslStep terminalFailure(SaslFailure failure) { + close(); + return new SaslStep.Failure(failure); + } + + private Optional<byte[]> optional(byte[] value) { + if (value == null || value.length == 0) { + return Optional.empty(); + } + return Optional.of(value); + } + + private void cleanup() { + if (saslServer != null && loginContext != null) { + try { + SubjectSaslServer.dispose(loginContext.subject(), saslServer); + } catch (SaslException e) { + LOGGER.warn("Failed to dispose GSSAPI SASL server", e); + } finally { + saslServer = null; + } + } + if (loginContext != null) { + try { + loginContext.close(); + } catch (LoginException e) { + LOGGER.warn("Failed to logout GSSAPI acceptor", e); + } finally { + loginContext = null; + } + } + } +} diff --git a/protocols/sasl-kerberos/src/main/java/org/apache/james/protocols/sasl/kerberos/GssapiSaslMechanism.java b/protocols/sasl-kerberos/src/main/java/org/apache/james/protocols/sasl/kerberos/GssapiSaslMechanism.java new file mode 100644 index 0000000000..6a21e581f6 --- /dev/null +++ b/protocols/sasl-kerberos/src/main/java/org/apache/james/protocols/sasl/kerberos/GssapiSaslMechanism.java @@ -0,0 +1,57 @@ +/**************************************************************** + * 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.james.protocols.sasl.kerberos; + +import org.apache.james.protocols.api.sasl.SaslAuthenticator; +import org.apache.james.protocols.api.sasl.SaslExchange; +import org.apache.james.protocols.api.sasl.SaslInitialRequest; +import org.apache.james.protocols.api.sasl.SaslMechanism; +import org.apache.james.protocols.api.sasl.SaslMechanismNames; + +public class GssapiSaslMechanism implements SaslMechanism { + public static final String NAME = SaslMechanismNames.GSSAPI; + + private final GssapiSaslConfiguration configuration; + private final KerberosLoginContextFactory loginContextFactory; + private final GssapiSaslServerFactory saslServerFactory; + + GssapiSaslMechanism(GssapiSaslConfiguration configuration, + KerberosLoginContextFactory loginContextFactory, + GssapiSaslServerFactory saslServerFactory) { + this.configuration = configuration; + this.loginContextFactory = loginContextFactory; + this.saslServerFactory = saslServerFactory; + } + + @Override + public String name() { + return NAME; + } + + @Override + public boolean isAvailableOnTransport(boolean channelEncrypted) { + return !configuration.requireSSL() || channelEncrypted; + } + + @Override + public SaslExchange start(SaslInitialRequest request, SaslAuthenticator authenticator) { + return new GssapiSaslExchange(request, authenticator, configuration, loginContextFactory, saslServerFactory); + } +} diff --git a/protocols/sasl-kerberos/src/main/java/org/apache/james/protocols/sasl/kerberos/GssapiSaslMechanismFactory.java b/protocols/sasl-kerberos/src/main/java/org/apache/james/protocols/sasl/kerberos/GssapiSaslMechanismFactory.java new file mode 100644 index 0000000000..8a9bdc21c4 --- /dev/null +++ b/protocols/sasl-kerberos/src/main/java/org/apache/james/protocols/sasl/kerberos/GssapiSaslMechanismFactory.java @@ -0,0 +1,68 @@ +/**************************************************************** + * 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.james.protocols.sasl.kerberos; + +import javax.security.auth.callback.UnsupportedCallbackException; +import javax.security.sasl.SaslServer; + +import org.apache.commons.configuration2.HierarchicalConfiguration; +import org.apache.commons.configuration2.ex.ConfigurationException; +import org.apache.commons.configuration2.tree.ImmutableNode; +import org.apache.james.protocols.api.sasl.SaslMechanism; +import org.apache.james.protocols.api.sasl.SaslMechanismFactory; + +public class GssapiSaslMechanismFactory implements SaslMechanismFactory { + private final KeyTabPrincipalVerifier keyTabPrincipalVerifier; + private final KerberosLoginContextFactory loginContextFactory; + private final GssapiSaslServerFactory saslServerFactory; + + public GssapiSaslMechanismFactory() { + this(new KeyTabPrincipalVerifier(), new KerberosLoginContextFactory(), new JdkSaslServerFactory()); + } + + GssapiSaslMechanismFactory(KeyTabPrincipalVerifier keyTabPrincipalVerifier, + KerberosLoginContextFactory loginContextFactory, + GssapiSaslServerFactory saslServerFactory) { + this.keyTabPrincipalVerifier = keyTabPrincipalVerifier; + this.loginContextFactory = loginContextFactory; + this.saslServerFactory = saslServerFactory; + } + + @Override + public SaslMechanism create(HierarchicalConfiguration<ImmutableNode> serverConfiguration) throws ConfigurationException { + GssapiSaslConfiguration configuration = GssapiSaslConfiguration.from(serverConfiguration); + keyTabPrincipalVerifier.verify(configuration); + probeAcceptorCredentials(configuration); + return new GssapiSaslMechanism(configuration, loginContextFactory, saslServerFactory); + } + + private void probeAcceptorCredentials(GssapiSaslConfiguration configuration) throws ConfigurationException { + try (KerberosLoginContext loginContext = loginContextFactory.login(configuration)) { + SaslServer saslServer = SubjectSaslServer.create(loginContext.subject(), saslServerFactory, configuration, callbacks -> { + if (callbacks.length > 0) { + throw new UnsupportedCallbackException(callbacks[0]); + } + }); + SubjectSaslServer.dispose(loginContext.subject(), saslServer); + } catch (Exception e) { + throw new ConfigurationException("Unable to acquire configured GSSAPI acceptor credentials", e); + } + } +} diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismNames.java b/protocols/sasl-kerberos/src/main/java/org/apache/james/protocols/sasl/kerberos/GssapiSaslServerFactory.java similarity index 78% copy from protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismNames.java copy to protocols/sasl-kerberos/src/main/java/org/apache/james/protocols/sasl/kerberos/GssapiSaslServerFactory.java index 20aad26648..81ecbe1139 100644 --- a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismNames.java +++ b/protocols/sasl-kerberos/src/main/java/org/apache/james/protocols/sasl/kerberos/GssapiSaslServerFactory.java @@ -17,14 +17,12 @@ * under the License. * ****************************************************************/ -package org.apache.james.protocols.api.sasl; +package org.apache.james.protocols.sasl.kerberos; -public final class SaslMechanismNames { - public static final String LOGIN = "LOGIN"; - public static final String PLAIN = "PLAIN"; - public static final String OAUTHBEARER = "OAUTHBEARER"; - public static final String XOAUTH2 = "XOAUTH2"; +import javax.security.auth.callback.CallbackHandler; +import javax.security.sasl.SaslException; +import javax.security.sasl.SaslServer; - private SaslMechanismNames() { - } +interface GssapiSaslServerFactory { + SaslServer create(GssapiSaslConfiguration configuration, CallbackHandler callbackHandler) throws SaslException; } diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismNames.java b/protocols/sasl-kerberos/src/main/java/org/apache/james/protocols/sasl/kerberos/JdkSaslServerFactory.java similarity index 56% copy from protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismNames.java copy to protocols/sasl-kerberos/src/main/java/org/apache/james/protocols/sasl/kerberos/JdkSaslServerFactory.java index 20aad26648..00f5ad0185 100644 --- a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismNames.java +++ b/protocols/sasl-kerberos/src/main/java/org/apache/james/protocols/sasl/kerberos/JdkSaslServerFactory.java @@ -17,14 +17,27 @@ * under the License. * ****************************************************************/ -package org.apache.james.protocols.api.sasl; +package org.apache.james.protocols.sasl.kerberos; -public final class SaslMechanismNames { - public static final String LOGIN = "LOGIN"; - public static final String PLAIN = "PLAIN"; - public static final String OAUTHBEARER = "OAUTHBEARER"; - public static final String XOAUTH2 = "XOAUTH2"; +import java.util.Map; - private SaslMechanismNames() { +import javax.security.auth.callback.CallbackHandler; +import javax.security.sasl.Sasl; +import javax.security.sasl.SaslException; +import javax.security.sasl.SaslServer; + +class JdkSaslServerFactory implements GssapiSaslServerFactory { + private static final Map<String, String> PROPERTIES = Map.of( + Sasl.POLICY_NOANONYMOUS, "true", + Sasl.POLICY_NOPLAINTEXT, "true", + Sasl.QOP, "auth"); + + @Override + public SaslServer create(GssapiSaslConfiguration configuration, CallbackHandler callbackHandler) throws SaslException { + SaslServer saslServer = Sasl.createSaslServer("GSSAPI", configuration.serviceName(), configuration.serverName(), PROPERTIES, callbackHandler); + if (saslServer == null) { + throw new SaslException("No JDK GSSAPI SASL server provider is available"); + } + return saslServer; } } diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismNames.java b/protocols/sasl-kerberos/src/main/java/org/apache/james/protocols/sasl/kerberos/KerberosLoginContext.java similarity index 65% copy from protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismNames.java copy to protocols/sasl-kerberos/src/main/java/org/apache/james/protocols/sasl/kerberos/KerberosLoginContext.java index 20aad26648..dad0fcf1f2 100644 --- a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismNames.java +++ b/protocols/sasl-kerberos/src/main/java/org/apache/james/protocols/sasl/kerberos/KerberosLoginContext.java @@ -17,14 +17,29 @@ * under the License. * ****************************************************************/ -package org.apache.james.protocols.api.sasl; +package org.apache.james.protocols.sasl.kerberos; -public final class SaslMechanismNames { - public static final String LOGIN = "LOGIN"; - public static final String PLAIN = "PLAIN"; - public static final String OAUTHBEARER = "OAUTHBEARER"; - public static final String XOAUTH2 = "XOAUTH2"; +import javax.security.auth.Subject; +import javax.security.auth.login.LoginContext; +import javax.security.auth.login.LoginException; - private SaslMechanismNames() { +class KerberosLoginContext implements AutoCloseable { + private final LoginContext loginContext; + private boolean closed; + + KerberosLoginContext(LoginContext loginContext) { + this.loginContext = loginContext; + } + + Subject subject() { + return loginContext.getSubject(); + } + + @Override + public void close() throws LoginException { + if (!closed) { + closed = true; + loginContext.logout(); + } } } diff --git a/protocols/sasl-kerberos/src/main/java/org/apache/james/protocols/sasl/kerberos/KerberosLoginContextFactory.java b/protocols/sasl-kerberos/src/main/java/org/apache/james/protocols/sasl/kerberos/KerberosLoginContextFactory.java new file mode 100644 index 0000000000..e841328eca --- /dev/null +++ b/protocols/sasl-kerberos/src/main/java/org/apache/james/protocols/sasl/kerberos/KerberosLoginContextFactory.java @@ -0,0 +1,57 @@ +/**************************************************************** + * 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.james.protocols.sasl.kerberos; + +import java.util.Map; + +import javax.security.auth.login.AppConfigurationEntry; +import javax.security.auth.login.Configuration; +import javax.security.auth.login.LoginException; + +class KerberosLoginContextFactory { + private static final String LOGIN_CONTEXT_NAME = "JamesGssapiAcceptor"; + private static final String KRB5_LOGIN_MODULE = "com.sun.security.auth.module.Krb5LoginModule"; + + KerberosLoginContext login(GssapiSaslConfiguration configuration) throws LoginException { + javax.security.auth.login.LoginContext loginContext = new javax.security.auth.login.LoginContext(LOGIN_CONTEXT_NAME, null, null, jaasConfiguration(configuration)); + loginContext.login(); + return new KerberosLoginContext(loginContext); + } + + private Configuration jaasConfiguration(GssapiSaslConfiguration configuration) { + Map<String, String> options = Map.of( + "doNotPrompt", "true", + "isInitiator", "false", + "keyTab", configuration.keyTab().toString(), + "principal", configuration.principal(), + "storeKey", "true", + "useKeyTab", "true", + "useTicketCache", "false"); + + return new Configuration() { + @Override + public AppConfigurationEntry[] getAppConfigurationEntry(String name) { + return new AppConfigurationEntry[] { + new AppConfigurationEntry(KRB5_LOGIN_MODULE, AppConfigurationEntry.LoginModuleControlFlag.REQUIRED, options) + }; + } + }; + } +} diff --git a/protocols/sasl-kerberos/src/main/java/org/apache/james/protocols/sasl/kerberos/KeyTabPrincipalVerifier.java b/protocols/sasl-kerberos/src/main/java/org/apache/james/protocols/sasl/kerberos/KeyTabPrincipalVerifier.java new file mode 100644 index 0000000000..93ba110d5f --- /dev/null +++ b/protocols/sasl-kerberos/src/main/java/org/apache/james/protocols/sasl/kerberos/KeyTabPrincipalVerifier.java @@ -0,0 +1,67 @@ +/**************************************************************** + * 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.james.protocols.sasl.kerberos; + +import javax.security.auth.DestroyFailedException; +import javax.security.auth.kerberos.KerberosKey; +import javax.security.auth.kerberos.KerberosPrincipal; +import javax.security.auth.kerberos.KeyTab; + +import org.apache.commons.configuration2.ex.ConfigurationException; + +class KeyTabPrincipalVerifier { + private static KerberosKey[] keys(GssapiSaslConfiguration configuration) throws ConfigurationException { + try { + return KeyTab.getInstance(configuration.keyTab().toFile()) + .getKeys(new KerberosPrincipal(configuration.principal())); + } catch (RuntimeException e) { + throw new ConfigurationException("Unable to read the configured GSSAPI keytab", e); + } + } + + private static void destroy(KerberosKey[] keys) throws ConfigurationException { + DestroyFailedException failure = null; + for (KerberosKey key : keys) { + try { + key.destroy(); + } catch (DestroyFailedException e) { + if (failure == null) { + failure = e; + } else { + failure.addSuppressed(e); + } + } + } + if (failure != null) { + throw new ConfigurationException("Unable to release keys read from the configured GSSAPI keytab", failure); + } + } + + void verify(GssapiSaslConfiguration configuration) throws ConfigurationException { + KerberosKey[] keys = keys(configuration); + try { + if (keys.length == 0) { + throw new ConfigurationException("The configured GSSAPI keytab does not contain the configured principal"); + } + } finally { + destroy(keys); + } + } +} diff --git a/protocols/sasl-kerberos/src/main/java/org/apache/james/protocols/sasl/kerberos/SubjectSaslServer.java b/protocols/sasl-kerberos/src/main/java/org/apache/james/protocols/sasl/kerberos/SubjectSaslServer.java new file mode 100644 index 0000000000..8833410519 --- /dev/null +++ b/protocols/sasl-kerberos/src/main/java/org/apache/james/protocols/sasl/kerberos/SubjectSaslServer.java @@ -0,0 +1,62 @@ +/**************************************************************** + * 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.james.protocols.sasl.kerberos; + +import java.util.concurrent.Callable; +import java.util.concurrent.CompletionException; + +import javax.security.auth.Subject; +import javax.security.auth.callback.CallbackHandler; +import javax.security.sasl.SaslException; +import javax.security.sasl.SaslServer; + +final class SubjectSaslServer { + static SaslServer create(Subject subject, + GssapiSaslServerFactory saslServerFactory, + GssapiSaslConfiguration configuration, + CallbackHandler callbackHandler) throws SaslException { + return callAs(subject, () -> saslServerFactory.create(configuration, callbackHandler)); + } + + static byte[] evaluate(Subject subject, SaslServer saslServer, byte[] response) throws SaslException { + return callAs(subject, () -> saslServer.evaluateResponse(response)); + } + + static void dispose(Subject subject, SaslServer saslServer) throws SaslException { + callAs(subject, () -> { + saslServer.dispose(); + return null; + }); + } + + private static <T> T callAs(Subject subject, Callable<T> action) throws SaslException { + try { + return Subject.callAs(subject, action); + } catch (CompletionException e) { + if (e.getCause() instanceof SaslException saslException) { + throw saslException; + } + throw new SaslException("GSSAPI operation failed", e.getCause()); + } + } + + private SubjectSaslServer() { + } +} diff --git a/protocols/sasl-kerberos/src/test/java/org/apache/james/protocols/sasl/kerberos/GssapiAuthorizeCallbackHandlerTest.java b/protocols/sasl-kerberos/src/test/java/org/apache/james/protocols/sasl/kerberos/GssapiAuthorizeCallbackHandlerTest.java new file mode 100644 index 0000000000..a6fe3721c5 --- /dev/null +++ b/protocols/sasl-kerberos/src/test/java/org/apache/james/protocols/sasl/kerberos/GssapiAuthorizeCallbackHandlerTest.java @@ -0,0 +1,176 @@ +/**************************************************************** + * 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.james.protocols.sasl.kerberos; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Stream; + +import javax.security.auth.callback.NameCallback; +import javax.security.auth.callback.UnsupportedCallbackException; +import javax.security.sasl.AuthorizeCallback; + +import org.apache.james.core.Username; +import org.apache.james.protocols.api.sasl.SaslAuthenticationResult; +import org.apache.james.protocols.api.sasl.SaslAuthenticator; +import org.apache.james.protocols.api.sasl.SaslFailure; +import org.apache.james.protocols.api.sasl.SaslIdentity; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +class GssapiAuthorizeCallbackHandlerTest { + private static Stream<Arguments> normalizationCollisions() { + return Stream.of( + Arguments.of("alice@[EXAMPLE.COM]", "[email protected]"), + Arguments.of("alice@EXAMPLE.K", "[email protected]")); + } + + @Test + void shouldAuthorizeCanonicalSelfIdentity() throws Exception { + AtomicReference<SaslIdentity> identity = new AtomicReference<>(); + GssapiAuthorizeCallbackHandler testee = new GssapiAuthorizeCallbackHandler(authorizing(identity)); + AuthorizeCallback callback = new AuthorizeCallback("[email protected]", "[email protected]"); + + testee.handle(new AuthorizeCallback[] {callback}); + + SaslIdentity expectedIdentity = new SaslIdentity(Username.of("[email protected]"), Username.of("[email protected]")); + assertThat(identity.get()).isEqualTo(expectedIdentity); + assertThat(callback.isAuthorized()).isTrue(); + assertThat(callback.getAuthorizedID()).isEqualTo("[email protected]"); + } + + @Test + void shouldRejectNonCanonicalPrincipalComponentCase() throws Exception { + AtomicReference<SaslIdentity> identity = new AtomicReference<>(); + GssapiAuthorizeCallbackHandler testee = new GssapiAuthorizeCallbackHandler(authorizing(identity)); + AuthorizeCallback callback = new AuthorizeCallback("[email protected]", "[email protected]"); + + testee.handle(new AuthorizeCallback[] {callback}); + + assertThat(identity.get()).isNull(); + assertThat(callback.isAuthorized()).isFalse(); + assertThat(testee.result()).hasValueSatisfying(result -> assertThat(result) + .isInstanceOfSatisfying(SaslAuthenticationResult.Failure.class, + actual -> assertThat(actual.failure().type()).isEqualTo(SaslFailure.Type.MALFORMED))); + } + + @Test + void shouldRejectNonCanonicalRealmCase() throws Exception { + AtomicReference<SaslIdentity> identity = new AtomicReference<>(); + GssapiAuthorizeCallbackHandler testee = new GssapiAuthorizeCallbackHandler(authorizing(identity)); + AuthorizeCallback callback = new AuthorizeCallback("[email protected]", "[email protected]"); + + testee.handle(new AuthorizeCallback[] {callback}); + + assertThat(identity.get()).isNull(); + assertThat(callback.isAuthorized()).isFalse(); + assertThat(testee.result()).hasValueSatisfying(result -> assertThat(result) + .isInstanceOfSatisfying(SaslAuthenticationResult.Failure.class, + actual -> assertThat(actual.failure().type()).isEqualTo(SaslFailure.Type.MALFORMED))); + } + + @ParameterizedTest + @MethodSource("normalizationCollisions") + void shouldRejectAuthenticationIdentityNormalizationCollisions(String authenticationId, String collidingCanonicalIdentity) throws Exception { + AtomicReference<SaslIdentity> identity = new AtomicReference<>(); + GssapiAuthorizeCallbackHandler testee = new GssapiAuthorizeCallbackHandler(authorizing(identity)); + AuthorizeCallback callback = new AuthorizeCallback(authenticationId, authenticationId); + + assertThat(Username.of(authenticationId)).isEqualTo(Username.of(collidingCanonicalIdentity)); + + testee.handle(new AuthorizeCallback[] {callback}); + + assertThat(identity.get()).isNull(); + assertThat(callback.isAuthorized()).isFalse(); + assertThat(testee.result()).hasValueSatisfying(result -> assertThat(result) + .isInstanceOfSatisfying(SaslAuthenticationResult.Failure.class, + actual -> assertThat(actual.failure().type()).isEqualTo(SaslFailure.Type.MALFORMED))); + } + + @Test + void shouldPreserveTypedAuthorizationFailure() throws Exception { + SaslFailure failure = SaslFailure.delegationForbidden( + Username.of("[email protected]"), Username.of("[email protected]"), "Delegation is forbidden."); + GssapiAuthorizeCallbackHandler testee = new GssapiAuthorizeCallbackHandler(failing(failure)); + AuthorizeCallback callback = new AuthorizeCallback("[email protected]", "[email protected]"); + + testee.handle(new AuthorizeCallback[] {callback}); + + assertThat(callback.isAuthorized()).isFalse(); + assertThat(testee.result()).contains(new SaslAuthenticationResult.Failure(failure)); + } + + @Test + void shouldRejectMalformedIdentity() throws Exception { + GssapiAuthorizeCallbackHandler testee = new GssapiAuthorizeCallbackHandler(failing( + SaslFailure.authenticationFailed(Optional.empty(), Optional.empty(), "unused"))); + AuthorizeCallback callback = new AuthorizeCallback("", ""); + + testee.handle(new AuthorizeCallback[] {callback}); + + assertThat(callback.isAuthorized()).isFalse(); + assertThat(testee.result()).hasValueSatisfying(result -> assertThat(result) + .isInstanceOfSatisfying(SaslAuthenticationResult.Failure.class, + actual -> assertThat(actual.failure().type()).isEqualTo(SaslFailure.Type.MALFORMED))); + } + + @Test + void shouldRejectUnexpectedCallback() { + GssapiAuthorizeCallbackHandler testee = new GssapiAuthorizeCallbackHandler(failing( + SaslFailure.authenticationFailed(Optional.empty(), Optional.empty(), "unused"))); + + assertThatThrownBy(() -> testee.handle(new NameCallback[] {new NameCallback("name")})) + .isInstanceOf(UnsupportedCallbackException.class); + } + + private SaslAuthenticator authorizing(AtomicReference<SaslIdentity> identity) { + return new SaslAuthenticator() { + @Override + public SaslAuthenticationResult authenticatePassword(Username authenticationId, Optional<Username> authorizationId, String password) { + throw new UnsupportedOperationException(); + } + + @Override + public SaslAuthenticationResult authorize(SaslIdentity value) { + identity.set(value); + return new SaslAuthenticationResult.Success(value); + } + }; + } + + private SaslAuthenticator failing(SaslFailure failure) { + return new SaslAuthenticator() { + @Override + public SaslAuthenticationResult authenticatePassword(Username authenticationId, Optional<Username> authorizationId, String password) { + throw new UnsupportedOperationException(); + } + + @Override + public SaslAuthenticationResult authorize(SaslIdentity identity) { + return new SaslAuthenticationResult.Failure(failure); + } + }; + } +} diff --git a/protocols/sasl-kerberos/src/test/java/org/apache/james/protocols/sasl/kerberos/GssapiJdkInteropTest.java b/protocols/sasl-kerberos/src/test/java/org/apache/james/protocols/sasl/kerberos/GssapiJdkInteropTest.java new file mode 100644 index 0000000000..93c787a099 --- /dev/null +++ b/protocols/sasl-kerberos/src/test/java/org/apache/james/protocols/sasl/kerberos/GssapiJdkInteropTest.java @@ -0,0 +1,98 @@ +/**************************************************************** + * 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.james.protocols.sasl.kerberos; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; + +import java.nio.file.Path; +import java.util.Optional; + +import org.apache.james.core.Username; +import org.apache.james.protocols.api.sasl.SaslAuthenticationResult; +import org.apache.james.protocols.api.sasl.SaslAuthenticator; +import org.apache.james.protocols.api.sasl.SaslExchange; +import org.apache.james.protocols.api.sasl.SaslIdentity; +import org.apache.james.protocols.api.sasl.SaslInitialRequest; +import org.apache.james.protocols.api.sasl.SaslMechanism; +import org.apache.james.protocols.api.sasl.SaslStep; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.api.parallel.ResourceLock; + +class GssapiJdkInteropTest { + private static final String SERVER_NAME = "mail.example.test"; + + @TempDir + Path temporaryDirectory; + + @Test + @ResourceLock(KerberosTestFixture.KRB5_CONFIGURATION_RESOURCE) + void shouldInteroperateWithJdkGssapiClient() throws Exception { + try (KerberosTestFixture kerberos = new KerberosTestFixture(temporaryDirectory)) { + KerberosTestFixture.Service service = kerberos.provisionService("imap", SERVER_NAME); + GssapiSaslConfiguration configuration = new GssapiSaslConfiguration( + service.serviceName(), service.serverName(), service.principal(), service.keyTab(), true); + SaslMechanism mechanism = new GssapiSaslMechanism( + configuration, new KerberosLoginContextFactory(), new JdkSaslServerFactory()); + + try (GssapiTestClient client = kerberos.client(service); + SaslExchange exchange = mechanism.start( + new SaslInitialRequest("GSSAPI", Optional.of(client.initialResponse())), + allowingSelfAuthorization())) { + SaslStep result = completeExchange(exchange, client); + + assertThat(result).isInstanceOfSatisfying(SaslStep.Success.class, success -> { + assertThat(success.identity().authenticationId()).isEqualTo(Username.of(KerberosTestFixture.USER_PRINCIPAL)); + assertThat(success.identity().authorizationId()).isEqualTo(Username.of(KerberosTestFixture.USER_PRINCIPAL)); + assertThat(success.serverData()).isEmpty(); + }); + assertThat(client.isComplete()).isTrue(); + } + } + } + + private SaslStep completeExchange(SaslExchange exchange, GssapiTestClient client) throws Exception { + SaslStep step = exchange.firstStep(); + for (int round = 0; round < 10 && step instanceof SaslStep.Challenge challenge; round++) { + step = exchange.onResponse(client.evaluate(challenge.payload().orElseGet(() -> new byte[0]))); + } + if (step instanceof SaslStep.Challenge) { + fail("GSSAPI exchange did not complete within 10 rounds"); + } + return step; + } + + private SaslAuthenticator allowingSelfAuthorization() { + return new SaslAuthenticator() { + @Override + public SaslAuthenticationResult authenticatePassword(Username authenticationId, + Optional<Username> authorizationId, + String password) { + throw new UnsupportedOperationException("Password authentication is not used by GSSAPI"); + } + + @Override + public SaslAuthenticationResult authorize(SaslIdentity identity) { + return new SaslAuthenticationResult.Success(identity); + } + }; + } +} diff --git a/protocols/sasl-kerberos/src/test/java/org/apache/james/protocols/sasl/kerberos/GssapiSaslConfigurationTest.java b/protocols/sasl-kerberos/src/test/java/org/apache/james/protocols/sasl/kerberos/GssapiSaslConfigurationTest.java new file mode 100644 index 0000000000..b359ce1ba6 --- /dev/null +++ b/protocols/sasl-kerberos/src/test/java/org/apache/james/protocols/sasl/kerberos/GssapiSaslConfigurationTest.java @@ -0,0 +1,112 @@ +/**************************************************************** + * 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.james.protocols.sasl.kerberos; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.nio.file.Files; +import java.nio.file.Path; + +import org.apache.commons.configuration2.BaseHierarchicalConfiguration; +import org.apache.commons.configuration2.ex.ConfigurationException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class GssapiSaslConfigurationTest { + private static final String SERVICE_NAME = "imap"; + private static final String SERVER_NAME = "mail.example.com"; + private static final String PRINCIPAL = "imap/[email protected]"; + + @TempDir + Path temporaryDirectory; + + @Test + void shouldRequireGssapiConfiguration() { + assertThatThrownBy(() -> GssapiSaslConfiguration.from(new BaseHierarchicalConfiguration())) + .isInstanceOf(ConfigurationException.class) + .hasMessage("GSSAPI SASL mechanism requires an auth.gssapi configuration"); + } + + @Test + void shouldDefaultToRequiringSsl() throws Exception { + Path keyTab = Files.createFile(temporaryDirectory.resolve("imap.keytab")); + + GssapiSaslConfiguration configuration = GssapiSaslConfiguration.from(configuration(keyTab.toString())); + + assertThat(configuration).isEqualTo(new GssapiSaslConfiguration( + SERVICE_NAME, SERVER_NAME, PRINCIPAL, keyTab.toAbsolutePath(), true)); + } + + @Test + void shouldReadGlobalSslRequirement() throws Exception { + Path keyTab = Files.createFile(temporaryDirectory.resolve("imap.keytab")); + BaseHierarchicalConfiguration serverConfiguration = configuration(keyTab.toString()); + serverConfiguration.addProperty("auth.requireSSL", false); + + assertThat(GssapiSaslConfiguration.from(serverConfiguration).requireSSL()).isFalse(); + } + + @Test + void shouldSupportFileUri() throws Exception { + Path keyTab = Files.createFile(temporaryDirectory.resolve("imap.keytab")); + + assertThat(GssapiSaslConfiguration.from(configuration(keyTab.toUri().toString())).keyTab()) + .isEqualTo(keyTab.toAbsolutePath()); + } + + @Test + void shouldRejectMissingRequiredValue() throws Exception { + Path keyTab = Files.createFile(temporaryDirectory.resolve("imap.keytab")); + BaseHierarchicalConfiguration serverConfiguration = configuration(keyTab.toString()); + serverConfiguration.clearProperty("auth.gssapi.serverName"); + + assertThatThrownBy(() -> GssapiSaslConfiguration.from(serverConfiguration)) + .isInstanceOf(ConfigurationException.class) + .hasMessage("auth.gssapi.serverName must be specified"); + } + + @Test + void shouldRejectNonRegularKeyTab() { + assertThatThrownBy(() -> GssapiSaslConfiguration.from(configuration(temporaryDirectory.toString()))) + .isInstanceOf(ConfigurationException.class) + .hasMessage("auth.gssapi.keyTab must reference a readable regular file"); + } + + @Test + void shouldRejectContradictoryServicePrincipal() throws Exception { + Path keyTab = Files.createFile(temporaryDirectory.resolve("imap.keytab")); + BaseHierarchicalConfiguration serverConfiguration = configuration(keyTab.toString()); + serverConfiguration.setProperty("auth.gssapi.principal", "smtp/[email protected]"); + + assertThatThrownBy(() -> GssapiSaslConfiguration.from(serverConfiguration)) + .isInstanceOf(ConfigurationException.class) + .hasMessage("auth.gssapi.principal must match the configured serviceName and serverName"); + } + + private BaseHierarchicalConfiguration configuration(String keyTab) { + BaseHierarchicalConfiguration configuration = new BaseHierarchicalConfiguration(); + configuration.addProperty("auth.gssapi.serviceName", SERVICE_NAME); + configuration.addProperty("auth.gssapi.serverName", SERVER_NAME); + configuration.addProperty("auth.gssapi.principal", PRINCIPAL); + configuration.addProperty("auth.gssapi.keyTab", keyTab); + return configuration; + } +} diff --git a/protocols/sasl-kerberos/src/test/java/org/apache/james/protocols/sasl/kerberos/GssapiSaslMechanismTest.java b/protocols/sasl-kerberos/src/test/java/org/apache/james/protocols/sasl/kerberos/GssapiSaslMechanismTest.java new file mode 100644 index 0000000000..b2438ac13a --- /dev/null +++ b/protocols/sasl-kerberos/src/test/java/org/apache/james/protocols/sasl/kerberos/GssapiSaslMechanismTest.java @@ -0,0 +1,354 @@ +/**************************************************************** + * 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.james.protocols.sasl.kerberos; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import javax.security.auth.Subject; +import javax.security.auth.callback.CallbackHandler; +import javax.security.sasl.AuthorizeCallback; +import javax.security.sasl.Sasl; +import javax.security.sasl.SaslException; +import javax.security.sasl.SaslServer; + +import org.apache.commons.configuration2.BaseHierarchicalConfiguration; +import org.apache.commons.configuration2.ex.ConfigurationException; +import org.apache.james.core.Username; +import org.apache.james.protocols.api.sasl.SaslAuthenticationResult; +import org.apache.james.protocols.api.sasl.SaslAuthenticator; +import org.apache.james.protocols.api.sasl.SaslExchange; +import org.apache.james.protocols.api.sasl.SaslFailure; +import org.apache.james.protocols.api.sasl.SaslIdentity; +import org.apache.james.protocols.api.sasl.SaslInitialRequest; +import org.apache.james.protocols.api.sasl.SaslStep; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class GssapiSaslMechanismTest { + private record TestExchange(SaslExchange exchange, SaslServer saslServer, KerberosLoginContext loginContext) { + } + + private static final Username USERNAME = Username.of("[email protected]"); + private static final SaslIdentity IDENTITY = new SaslIdentity(USERNAME, USERNAME); + private static final byte[] INITIAL_TOKEN = {1, 2, 3}; + private static final byte[] SERVER_TOKEN = {4, 5, 6}; + + @TempDir + Path temporaryDirectory; + + @Test + void shouldRequireEncryptedTransportByDefault() { + GssapiSaslMechanism testee = mechanism(true, mock(KerberosLoginContextFactory.class), mock(GssapiSaslServerFactory.class)); + + assertThat(testee.isAvailableOnTransport(false)).isFalse(); + assertThat(testee.isAvailableOnTransport(true)).isTrue(); + } + + @Test + void shouldAllowClearTransportWhenConfigured() { + GssapiSaslMechanism testee = mechanism(false, mock(KerberosLoginContextFactory.class), mock(GssapiSaslServerFactory.class)); + + assertThat(testee.isAvailableOnTransport(false)).isTrue(); + } + + @Test + void shouldChallengeWithoutAcquiringCredentialsWhenInitialResponseIsOmitted() throws Exception { + KerberosLoginContextFactory loginContextFactory = mock(KerberosLoginContextFactory.class); + GssapiSaslMechanism testee = mechanism(true, loginContextFactory, mock(GssapiSaslServerFactory.class)); + + SaslStep step = testee.start(request(Optional.empty()), authenticating()).firstStep(); + + assertThat(step).isEqualTo(new SaslStep.Challenge(Optional.empty())); + verify(loginContextFactory, never()).login(any()); + } + + @Test + void shouldCompleteInitialResponseAndCleanup() throws Exception { + TestExchange testExchange = completedExchange(null, "auth", authenticating()); + + SaslStep step = testExchange.exchange().firstStep(); + + assertThat(step).isEqualTo(new SaslStep.Success(IDENTITY, Optional.empty())); + verify(testExchange.saslServer()).dispose(); + verify(testExchange.loginContext()).close(); + } + + @Test + void shouldPreserveMultipleChallengeRounds() throws Exception { + KerberosLoginContextFactory loginContextFactory = mock(KerberosLoginContextFactory.class); + KerberosLoginContext loginContext = loginContext(loginContextFactory); + SaslServer saslServer = mock(SaslServer.class); + AtomicReference<CallbackHandler> callbackHandler = new AtomicReference<>(); + AtomicInteger evaluation = new AtomicInteger(); + when(saslServer.evaluateResponse(any())).thenAnswer(invocation -> { + if (evaluation.getAndIncrement() == 0) { + return SERVER_TOKEN; + } + authorize(callbackHandler.get()); + return null; + }); + when(saslServer.isComplete()).thenReturn(false, true); + when(saslServer.getNegotiatedProperty(Sasl.QOP)).thenReturn("auth"); + GssapiSaslServerFactory saslServerFactory = capturingFactory(saslServer, callbackHandler); + SaslExchange exchange = mechanism(true, loginContextFactory, saslServerFactory) + .start(request(Optional.of(INITIAL_TOKEN)), authenticating()); + + assertThat(exchange.firstStep()).isInstanceOfSatisfying(SaslStep.Challenge.class, + challenge -> assertThat(challenge.payload()).hasValueSatisfying(payload -> assertThat(payload).containsExactly(SERVER_TOKEN))); + assertThat(exchange.onResponse(new byte[] {7, 8, 9})).isEqualTo(new SaslStep.Success(IDENTITY, Optional.empty())); + verify(saslServer).dispose(); + verify(loginContext).close(); + } + + @Test + void shouldPreserveTypedAuthorizationFailure() throws Exception { + SaslFailure failure = SaslFailure.delegationForbidden(USERNAME, Username.of("[email protected]"), "Delegation is forbidden."); + TestExchange testExchange = exchangeWhoseProviderRejectsAuthorization(failure); + + assertThat(testExchange.exchange().firstStep()).isEqualTo(new SaslStep.Failure(failure)); + } + + @Test + void shouldReleaseLoginContextWhenSaslServerCreationFails() throws Exception { + KerberosLoginContextFactory loginContextFactory = mock(KerberosLoginContextFactory.class); + KerberosLoginContext loginContext = loginContext(loginContextFactory); + GssapiSaslServerFactory saslServerFactory = (configuration, callbackHandler) -> { + throw new IllegalStateException("provider failure"); + }; + SaslExchange exchange = mechanism(true, loginContextFactory, saslServerFactory) + .start(request(Optional.of(INITIAL_TOKEN)), authenticating()); + + assertThat(exchange.firstStep()).isInstanceOfSatisfying(SaslStep.Failure.class, + failure -> assertThat(failure.failure().type()).isEqualTo(SaslFailure.Type.SERVER_ERROR)); + verify(loginContext).close(); + } + + @Test + void shouldCleanupWhenTokenEvaluationFails() throws Exception { + KerberosLoginContextFactory loginContextFactory = mock(KerberosLoginContextFactory.class); + KerberosLoginContext loginContext = loginContext(loginContextFactory); + SaslServer saslServer = mock(SaslServer.class); + when(saslServer.evaluateResponse(any())).thenThrow(new SaslException("invalid token")); + GssapiSaslServerFactory saslServerFactory = (configuration, callbackHandler) -> saslServer; + SaslExchange exchange = mechanism(true, loginContextFactory, saslServerFactory) + .start(request(Optional.of(INITIAL_TOKEN)), authenticating()); + + assertThat(exchange.firstStep()).isInstanceOfSatisfying(SaslStep.Failure.class, + failure -> assertThat(failure.failure().type()).isEqualTo(SaslFailure.Type.AUTHENTICATION_FAILED)); + verify(saslServer).dispose(); + verify(loginContext).close(); + } + + @Test + void shouldRejectFinalServerData() throws Exception { + TestExchange testExchange = completedExchange(SERVER_TOKEN, "auth", authenticating()); + + assertThat(testExchange.exchange().firstStep()).isInstanceOfSatisfying(SaslStep.Failure.class, + failure -> assertThat(failure.failure().type()).isEqualTo(SaslFailure.Type.SERVER_ERROR)); + } + + @Test + void shouldRejectUnsupportedQop() throws Exception { + TestExchange testExchange = completedExchange(null, "auth-int", authenticating()); + + assertThat(testExchange.exchange().firstStep()).isInstanceOfSatisfying(SaslStep.Failure.class, + failure -> assertThat(failure.failure().type()).isEqualTo(SaslFailure.Type.SERVER_ERROR)); + } + + @Test + void closeShouldBeIdempotent() throws Exception { + KerberosLoginContextFactory loginContextFactory = mock(KerberosLoginContextFactory.class); + KerberosLoginContext loginContext = loginContext(loginContextFactory); + SaslServer saslServer = mock(SaslServer.class); + GssapiSaslServerFactory saslServerFactory = (configuration, callbackHandler) -> saslServer; + SaslExchange exchange = mechanism(true, loginContextFactory, saslServerFactory) + .start(request(Optional.of(INITIAL_TOKEN)), authenticating()); + when(saslServer.evaluateResponse(any())).thenReturn(SERVER_TOKEN); + when(saslServer.isComplete()).thenReturn(false); + exchange.firstStep(); + + exchange.close(); + exchange.close(); + + verify(saslServer, times(1)).dispose(); + verify(loginContext, times(1)).close(); + } + + @Test + void factoryShouldProbeAndReleaseAcceptorCredentials() throws Exception { + Path keyTab = Files.createFile(temporaryDirectory.resolve("imap.keytab")); + KerberosLoginContextFactory loginContextFactory = mock(KerberosLoginContextFactory.class); + KerberosLoginContext loginContext = loginContext(loginContextFactory); + SaslServer saslServer = mock(SaslServer.class); + GssapiSaslMechanismFactory testee = new GssapiSaslMechanismFactory(mock(KeyTabPrincipalVerifier.class), loginContextFactory, + (configuration, callbackHandler) -> saslServer); + + assertThat(testee.create(configuration(keyTab))).isInstanceOf(GssapiSaslMechanism.class); + verify(saslServer).dispose(); + verify(loginContext).close(); + } + + @Test + void factoryShouldRejectUnavailableAcceptorCredentials() throws Exception { + Path keyTab = Files.createFile(temporaryDirectory.resolve("imap.keytab")); + KerberosLoginContextFactory loginContextFactory = mock(KerberosLoginContextFactory.class); + when(loginContextFactory.login(any())).thenThrow(new javax.security.auth.login.LoginException("unavailable")); + GssapiSaslMechanismFactory testee = new GssapiSaslMechanismFactory(mock(KeyTabPrincipalVerifier.class), loginContextFactory, + mock(GssapiSaslServerFactory.class)); + + assertThatThrownBy(() -> testee.create(configuration(keyTab))) + .isInstanceOf(ConfigurationException.class) + .hasMessage("Unable to acquire configured GSSAPI acceptor credentials"); + } + + @Test + void factoryShouldRejectKeyTabWithoutConfiguredPrincipalBeforeLogin() throws Exception { + Path keyTab = Files.createFile(temporaryDirectory.resolve("imap.keytab")); + KeyTabPrincipalVerifier keyTabPrincipalVerifier = mock(KeyTabPrincipalVerifier.class); + doThrow(new ConfigurationException("missing principal")) + .when(keyTabPrincipalVerifier) + .verify(any()); + KerberosLoginContextFactory loginContextFactory = mock(KerberosLoginContextFactory.class); + GssapiSaslMechanismFactory testee = new GssapiSaslMechanismFactory(keyTabPrincipalVerifier, loginContextFactory, + mock(GssapiSaslServerFactory.class)); + + assertThatThrownBy(() -> testee.create(configuration(keyTab))) + .isInstanceOf(ConfigurationException.class) + .hasMessage("missing principal"); + verify(loginContextFactory, never()).login(any()); + } + + private TestExchange completedExchange(byte[] output, String qop, SaslAuthenticator authenticator) throws Exception { + KerberosLoginContextFactory loginContextFactory = mock(KerberosLoginContextFactory.class); + KerberosLoginContext loginContext = loginContext(loginContextFactory); + SaslServer saslServer = mock(SaslServer.class); + AtomicReference<CallbackHandler> callbackHandler = new AtomicReference<>(); + when(saslServer.evaluateResponse(any())).thenAnswer(invocation -> { + authorize(callbackHandler.get()); + return output; + }); + when(saslServer.isComplete()).thenReturn(true); + when(saslServer.getNegotiatedProperty(Sasl.QOP)).thenReturn(qop); + GssapiSaslServerFactory saslServerFactory = capturingFactory(saslServer, callbackHandler); + SaslExchange exchange = mechanism(true, loginContextFactory, saslServerFactory) + .start(request(Optional.of(INITIAL_TOKEN)), authenticator); + return new TestExchange(exchange, saslServer, loginContext); + } + + private TestExchange exchangeWhoseProviderRejectsAuthorization(SaslFailure failure) throws Exception { + KerberosLoginContextFactory loginContextFactory = mock(KerberosLoginContextFactory.class); + KerberosLoginContext loginContext = loginContext(loginContextFactory); + SaslServer saslServer = mock(SaslServer.class); + AtomicReference<CallbackHandler> callbackHandler = new AtomicReference<>(); + when(saslServer.evaluateResponse(any())).thenAnswer(invocation -> { + authorize(callbackHandler.get(), "[email protected]", "[email protected]"); + throw new SaslException("not authorized"); + }); + GssapiSaslServerFactory saslServerFactory = capturingFactory(saslServer, callbackHandler); + SaslExchange exchange = mechanism(true, loginContextFactory, saslServerFactory) + .start(request(Optional.of(INITIAL_TOKEN)), failing(failure)); + return new TestExchange(exchange, saslServer, loginContext); + } + + private KerberosLoginContext loginContext(KerberosLoginContextFactory loginContextFactory) throws Exception { + KerberosLoginContext loginContext = mock(KerberosLoginContext.class); + when(loginContext.subject()).thenReturn(new Subject()); + when(loginContextFactory.login(any())).thenReturn(loginContext); + return loginContext; + } + + private GssapiSaslServerFactory capturingFactory(SaslServer saslServer, AtomicReference<CallbackHandler> callbackHandler) { + return (configuration, handler) -> { + callbackHandler.set(handler); + return saslServer; + }; + } + + private void authorize(CallbackHandler callbackHandler) throws Exception { + authorize(callbackHandler, "[email protected]", "[email protected]"); + } + + private void authorize(CallbackHandler callbackHandler, String authenticationId, String authorizationId) throws Exception { + callbackHandler.handle(new AuthorizeCallback[] {new AuthorizeCallback(authenticationId, authorizationId)}); + } + + private SaslInitialRequest request(Optional<byte[]> initialResponse) { + return new SaslInitialRequest(GssapiSaslMechanism.NAME, initialResponse); + } + + private GssapiSaslMechanism mechanism(boolean requireSSL, + KerberosLoginContextFactory loginContextFactory, + GssapiSaslServerFactory saslServerFactory) { + GssapiSaslConfiguration configuration = new GssapiSaslConfiguration( + "imap", "mail.example.com", "imap/[email protected]", Path.of("imap.keytab"), requireSSL); + return new GssapiSaslMechanism(configuration, loginContextFactory, saslServerFactory); + } + + private SaslAuthenticator authenticating() { + return new SaslAuthenticator() { + @Override + public SaslAuthenticationResult authenticatePassword(Username authenticationId, Optional<Username> authorizationId, String password) { + throw new UnsupportedOperationException(); + } + + @Override + public SaslAuthenticationResult authorize(SaslIdentity identity) { + return new SaslAuthenticationResult.Success(identity); + } + }; + } + + private SaslAuthenticator failing(SaslFailure failure) { + return new SaslAuthenticator() { + @Override + public SaslAuthenticationResult authenticatePassword(Username authenticationId, Optional<Username> authorizationId, String password) { + throw new UnsupportedOperationException(); + } + + @Override + public SaslAuthenticationResult authorize(SaslIdentity identity) { + return new SaslAuthenticationResult.Failure(failure); + } + }; + } + + private BaseHierarchicalConfiguration configuration(Path keyTab) { + BaseHierarchicalConfiguration configuration = new BaseHierarchicalConfiguration(); + configuration.addProperty("auth.gssapi.serviceName", "imap"); + configuration.addProperty("auth.gssapi.serverName", "mail.example.com"); + configuration.addProperty("auth.gssapi.principal", "imap/[email protected]"); + configuration.addProperty("auth.gssapi.keyTab", keyTab.toString()); + return configuration; + } +} diff --git a/protocols/sasl-kerberos/src/test/java/org/apache/james/protocols/sasl/kerberos/GssapiTestClient.java b/protocols/sasl-kerberos/src/test/java/org/apache/james/protocols/sasl/kerberos/GssapiTestClient.java new file mode 100644 index 0000000000..ba7719aa9b --- /dev/null +++ b/protocols/sasl-kerberos/src/test/java/org/apache/james/protocols/sasl/kerberos/GssapiTestClient.java @@ -0,0 +1,136 @@ +/**************************************************************** + * 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.james.protocols.sasl.kerberos; + +import java.nio.file.Path; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.Callable; +import java.util.concurrent.CompletionException; + +import javax.security.auth.Subject; +import javax.security.auth.login.AppConfigurationEntry; +import javax.security.auth.login.Configuration; +import javax.security.auth.login.LoginContext; +import javax.security.auth.login.LoginException; +import javax.security.sasl.Sasl; +import javax.security.sasl.SaslClient; +import javax.security.sasl.SaslException; + +public class GssapiTestClient implements AutoCloseable { + private static final String KRB5_LOGIN_MODULE = "com.sun.security.auth.module.Krb5LoginModule"; + private static final String LOGIN_CONTEXT_NAME = "JamesGssapiTestClient"; + + private final LoginContext loginContext; + private final Subject subject; + private final SaslClient saslClient; + + GssapiTestClient(String principal, + Path keyTab, + String serviceName, + String serverName, + Optional<String> authorizationId) throws LoginException, SaslException { + LoginContext context = new LoginContext(LOGIN_CONTEXT_NAME, null, null, jaasConfiguration(principal, keyTab)); + context.login(); + Subject authenticatedSubject = context.getSubject(); + try { + SaslClient client = call(authenticatedSubject, () -> Sasl.createSaslClient( + new String[] { "GSSAPI" }, + authorizationId.orElse(null), + serviceName, + serverName, + Map.of(Sasl.QOP, "auth", Sasl.SERVER_AUTH, "true"), + null)); + if (client == null) { + throw new SaslException("No JDK GSSAPI SASL client provider is available"); + } + loginContext = context; + subject = authenticatedSubject; + saslClient = client; + } catch (RuntimeException | SaslException e) { + context.logout(); + throw e; + } + } + + public byte[] initialResponse() throws SaslException { + if (!saslClient.hasInitialResponse()) { + throw new SaslException("JDK GSSAPI SASL client did not provide an initial response"); + } + return evaluate(new byte[0]); + } + + public byte[] evaluate(byte[] challenge) throws SaslException { + byte[] response = call(() -> saslClient.evaluateChallenge(challenge)); + return response == null ? new byte[0] : response; + } + + public boolean isComplete() { + return saslClient.isComplete(); + } + + @Override + public void close() throws Exception { + try { + call(() -> { + saslClient.dispose(); + return null; + }); + } finally { + loginContext.logout(); + } + } + + private static Configuration jaasConfiguration(String principal, Path keyTab) { + Map<String, String> options = Map.of( + "doNotPrompt", "true", + "isInitiator", "true", + "keyTab", keyTab.toAbsolutePath().toString(), + "principal", principal, + "refreshKrb5Config", "true", + "storeKey", "true", + "useKeyTab", "true", + "useTicketCache", "false"); + + return new Configuration() { + @Override + public AppConfigurationEntry[] getAppConfigurationEntry(String name) { + return new AppConfigurationEntry[] { + new AppConfigurationEntry(KRB5_LOGIN_MODULE, AppConfigurationEntry.LoginModuleControlFlag.REQUIRED, options) + }; + } + }; + } + + private <T> T call(Callable<T> action) throws SaslException { + return call(subject, action); + } + + private static <T> T call(Subject subject, Callable<T> action) throws SaslException { + try { + return Subject.callAs(subject, action); + } catch (CompletionException e) { + if (e.getCause() instanceof SaslException saslException) { + throw saslException; + } + throw e; + } + } +} diff --git a/protocols/sasl-kerberos/src/test/java/org/apache/james/protocols/sasl/kerberos/KerberosTestFixture.java b/protocols/sasl-kerberos/src/test/java/org/apache/james/protocols/sasl/kerberos/KerberosTestFixture.java new file mode 100644 index 0000000000..96584be309 --- /dev/null +++ b/protocols/sasl-kerberos/src/test/java/org/apache/james/protocols/sasl/kerberos/KerberosTestFixture.java @@ -0,0 +1,97 @@ +/**************************************************************** + * 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.james.protocols.sasl.kerberos; + +import java.io.IOException; +import java.net.ServerSocket; +import java.nio.file.Path; +import java.util.Optional; + +import org.apache.kerby.kerberos.kerb.server.SimpleKdcServer; + +public class KerberosTestFixture implements AutoCloseable { + public record Service(String serviceName, String serverName, String principal, Path keyTab) { + } + + public static final String REALM = "JAMES.TEST"; + public static final String USER_PRINCIPAL = "alice@" + REALM; + public static final String KRB5_CONFIGURATION_RESOURCE = "java.security.krb5.conf"; + + private final Path workDirectory; + private final SimpleKdcServer kdcServer; + private final Path userKeyTab; + private final String previousKrb5Configuration; + + public KerberosTestFixture(Path workDirectory) throws Exception { + this.workDirectory = workDirectory; + this.previousKrb5Configuration = System.getProperty(KRB5_CONFIGURATION_RESOURCE); + this.kdcServer = startKdc(workDirectory); + this.userKeyTab = workDirectory.resolve("alice.keytab"); + kdcServer.createPrincipal(USER_PRINCIPAL); + kdcServer.exportPrincipal(USER_PRINCIPAL, userKeyTab.toFile()); + } + + public Service provisionService(String serviceName, String serverName) throws Exception { + String principal = serviceName + "/" + serverName + "@" + REALM; + Path keyTab = workDirectory.resolve(serviceName + ".keytab"); + kdcServer.createPrincipal(principal); + kdcServer.exportPrincipal(principal, keyTab.toFile()); + return new Service(serviceName, serverName, principal, keyTab); + } + + public GssapiTestClient client(Service service) throws Exception { + return client(service, Optional.empty()); + } + + public GssapiTestClient client(Service service, Optional<String> authorizationId) throws Exception { + return new GssapiTestClient(USER_PRINCIPAL, userKeyTab, service.serviceName(), service.serverName(), authorizationId); + } + + @Override + public void close() throws Exception { + try { + kdcServer.stop(); + } finally { + if (previousKrb5Configuration == null) { + System.clearProperty(KRB5_CONFIGURATION_RESOURCE); + } else { + System.setProperty(KRB5_CONFIGURATION_RESOURCE, previousKrb5Configuration); + } + } + } + + private static SimpleKdcServer startKdc(Path workDirectory) throws Exception { + SimpleKdcServer kdcServer = new SimpleKdcServer(); + kdcServer.setWorkDir(workDirectory.toFile()); + kdcServer.setKdcRealm(REALM); + kdcServer.setKdcHost("127.0.0.1"); + kdcServer.setAllowUdp(false); + kdcServer.setKdcTcpPort(availablePort()); + kdcServer.init(); + kdcServer.start(); + return kdcServer; + } + + private static int availablePort() throws IOException { + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + } +} diff --git a/protocols/sasl-kerberos/src/test/java/org/apache/james/protocols/sasl/kerberos/KeyTabPrincipalVerifierTest.java b/protocols/sasl-kerberos/src/test/java/org/apache/james/protocols/sasl/kerberos/KeyTabPrincipalVerifierTest.java new file mode 100644 index 0000000000..ca9a419018 --- /dev/null +++ b/protocols/sasl-kerberos/src/test/java/org/apache/james/protocols/sasl/kerberos/KeyTabPrincipalVerifierTest.java @@ -0,0 +1,64 @@ +/**************************************************************** + * 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.james.protocols.sasl.kerberos; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.nio.file.Path; + +import org.apache.commons.configuration2.ex.ConfigurationException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.api.parallel.ResourceLock; + +class KeyTabPrincipalVerifierTest { + private static final String SERVER_NAME = "mail.example.test"; + + @TempDir + Path temporaryDirectory; + + @Test + @ResourceLock(KerberosTestFixture.KRB5_CONFIGURATION_RESOURCE) + void shouldFindConfiguredPrincipalInKeyTab() throws Exception { + try (KerberosTestFixture kerberos = new KerberosTestFixture(temporaryDirectory)) { + KerberosTestFixture.Service service = kerberos.provisionService("imap", SERVER_NAME); + GssapiSaslConfiguration configuration = new GssapiSaslConfiguration( + service.serviceName(), service.serverName(), service.principal(), service.keyTab(), true); + + assertThatCode(() -> new KeyTabPrincipalVerifier().verify(configuration)) + .doesNotThrowAnyException(); + } + } + + @Test + @ResourceLock(KerberosTestFixture.KRB5_CONFIGURATION_RESOURCE) + void shouldRejectKeyTabWithoutConfiguredPrincipal() throws Exception { + try (KerberosTestFixture kerberos = new KerberosTestFixture(temporaryDirectory)) { + KerberosTestFixture.Service service = kerberos.provisionService("smtp", SERVER_NAME); + GssapiSaslConfiguration configuration = new GssapiSaslConfiguration( + "imap", SERVER_NAME, "imap/" + SERVER_NAME + "@" + KerberosTestFixture.REALM, service.keyTab(), true); + + assertThatThrownBy(() -> new KeyTabPrincipalVerifier().verify(configuration)) + .isInstanceOf(ConfigurationException.class) + .hasMessage("The configured GSSAPI keytab does not contain the configured principal"); + } + } +} --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
