This is an automated email from the ASF dual-hosted git repository.
chibenwa pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/james-project.git
The following commit(s) were added to refs/heads/master by this push:
new 2f53109f0d JAMES-4215 Allow customization of kerberos principal ->
username mapping
2f53109f0d is described below
commit 2f53109f0d925591c88bd0f61536a238cf767b40
Author: Benoit TELLIER <[email protected]>
AuthorDate: Wed Sep 2 11:37:28 2026 +0200
JAMES-4215 Allow customization of kerberos principal -> username mapping
---
.../servers/partials/configure/kerberos.adoc | 54 +++++-
protocols/sasl-kerberos/pom.xml | 4 +
.../sasl/kerberos/CanonicalKerberosPrincipal.java | 62 ++++++
.../kerberos/GssapiAuthorizeCallbackHandler.java | 103 ++++++----
.../sasl/kerberos/GssapiSaslConfiguration.java | 5 +-
.../sasl/kerberos/GssapiSaslExchange.java | 2 +-
.../protocols/sasl/kerberos/RealmMapping.java | 118 ++++++++++++
.../kerberos/CanonicalKerberosPrincipalTest.java | 102 ++++++++++
.../GssapiAuthorizeCallbackHandlerTest.java | 61 +++++-
.../sasl/kerberos/GssapiJdkInteropTest.java | 56 +++++-
.../sasl/kerberos/GssapiSaslConfigurationTest.java | 2 +-
.../sasl/kerberos/GssapiSaslMechanismTest.java | 27 ++-
.../sasl/kerberos/KeyTabPrincipalVerifierTest.java | 6 +-
.../protocols/sasl/kerberos/RealmMappingTest.java | 213 +++++++++++++++++++++
.../SaslGssapiRealmMappingIntegrationTest.java | 146 ++++++++++++++
.../resources/imapserver-gssapi-realm-mapping.xml | 48 +++++
16 files changed, 949 insertions(+), 60 deletions(-)
diff --git a/docs/modules/servers/partials/configure/kerberos.adoc
b/docs/modules/servers/partials/configure/kerberos.adoc
index 5509e3c138..e884b20d53 100644
--- a/docs/modules/servers/partials/configure/kerberos.adoc
+++ b/docs/modules/servers/partials/configure/kerberos.adoc
@@ -73,6 +73,9 @@ The `auth.gssapi` properties are:
| `keyTab`
| Absolute path or `file:` URI of a readable keytab containing the acceptor
principal.
+| `realmMapping`
+| Optional. Maps Kerberos realms onto mail domains, see <<_identity_mapping>>.
Absent, the mail domain is the Kerberos realm.
+
|===
GSSAPI uses the top-level `auth.requireSSL` transport policy and defaults to
requiring TLS when that property is absent.
@@ -80,14 +83,51 @@ SMTP also applies `auth.announce` and `auth.requireSSL`
when advertising authent
Kerberos tokens can exceed SMTP's default 8,192-byte line limit; set
`maxLineLength` to a measured operational value when needed.
The value applies globally to SMTP input and must remain bounded.
-== Identity And Security
+== Identity Mapping
+
+Because James usernames are case-insensitive while Kerberos principals are
case-sensitive, James accepts only canonical
+client principals: the identity must contain only ASCII characters, principal
components must be lower case, and the
+realm must be upper case, for example `[email protected]`.
+Noncanonical variants such as `[email protected]` and `[email protected]` are
rejected so that distinct Kerberos
+principals cannot map to the same James identity.
+
+By default the James username is the Kerberos principal itself, so the mail
domain is the Kerberos realm:
+`[email protected]` authenticates as `[email protected]`, and `example.com`
has to be one of the James domains.
+
+Deployments whose Kerberos realm is not their mail domain declare the
correspondence explicitly:
+
+[source,xml]
+....
+<gssapi>
+ <serviceName>imap</serviceName>
+ <serverName>mail.example.com</serverName>
+ <principal>imap/[email protected]</principal>
+ <keyTab>/run/secrets/james/imap.keytab</keyTab>
+ <realmMapping>
+ <realm name="CORP.EXAMPLE.COM" domain="example.com"/>
+ <realm name="SUB.EXAMPLE.COM" domain="sub.example.com"/>
+ </realmMapping>
+</gssapi>
+....
+
+`[email protected]` then authenticates as `[email protected]`.
+Realm names are written as Kerberos spells them, in upper case, and the
mapping is exhaustive: a principal whose realm
+is not listed has no James identity and is rejected.
+Two realms may not share a mail domain, as that would let the principals of
one realm authenticate as those of the
+other.
+
+Only the realm is rewritten: the principal name components become the local
part of the James username as they are.
+A deployment whose Kerberos account names differ from its mail local parts
provisions James accounts named after the
+Kerberos names and declares the mail addresses as aliases, as it would for any
other login.
+
+The resulting James username is lower case; provision that username and domain
when the protocol needs a local
+account, notably for IMAP mailbox access.
+
+When a client requests a SASL authorization identity, a canonically spelled
Kerberos principal goes through the realm
+mapping as well, and any other value is read as a James username.
+James then applies its normal authorization rules to the result.
-The authenticated Kerberos principal is mapped to a James username, including
its realm.
-Because James usernames are case-insensitive while Kerberos principals are
case-sensitive, James accepts only canonical client principals:
-the identity must contain only ASCII characters, principal components must be
lower case, and the realm must be upper case, for example `[email protected]`.
-Noncanonical variants such as `[email protected]` and `[email protected]` are
rejected so that distinct Kerberos principals cannot map to the same James
identity.
-The resulting James username is lower case; provision that username and domain
when the protocol needs a local account, notably for IMAP mailbox access.
-When a client requests a different SASL authorization identity, James applies
its normal authorization rules.
+== Security
The implementation accepts only the SASL `auth` quality of protection.
It does not negotiate a SASL integrity or confidentiality layer; use TLS to
protect protocol traffic.
diff --git a/protocols/sasl-kerberos/pom.xml b/protocols/sasl-kerberos/pom.xml
index 6217464232..d9e987a972 100644
--- a/protocols/sasl-kerberos/pom.xml
+++ b/protocols/sasl-kerberos/pom.xml
@@ -42,6 +42,10 @@
<groupId>${james.protocols.groupId}</groupId>
<artifactId>protocols-api</artifactId>
</dependency>
+ <dependency>
+ <groupId>com.google.guava</groupId>
+ <artifactId>guava</artifactId>
+ </dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-configuration2</artifactId>
diff --git
a/protocols/sasl-kerberos/src/main/java/org/apache/james/protocols/sasl/kerberos/CanonicalKerberosPrincipal.java
b/protocols/sasl-kerberos/src/main/java/org/apache/james/protocols/sasl/kerberos/CanonicalKerberosPrincipal.java
new file mode 100644
index 0000000000..973341746f
--- /dev/null
+++
b/protocols/sasl-kerberos/src/main/java/org/apache/james/protocols/sasl/kerberos/CanonicalKerberosPrincipal.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.Locale;
+
+import javax.security.auth.kerberos.KerberosPrincipal;
+
+record CanonicalKerberosPrincipal(String components, String realm) {
+ static CanonicalKerberosPrincipal parse(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 name = principal.getName();
+ CanonicalKerberosPrincipal parsed = new
CanonicalKerberosPrincipal(name.substring(0, name.length() - realm.length() -
1), realm);
+
+ // The principal is respelled by the parser: reject anything the
parser did not read back verbatim.
+ if (!authenticationId.equals(parsed.asString())) {
+ throw new IllegalArgumentException("GSSAPI authentication identity
is not canonical");
+ }
+ return parsed;
+ }
+
+ CanonicalKerberosPrincipal {
+ if (components == null || components.isEmpty() ||
components.indexOf('@') >= 0) {
+ throw new IllegalArgumentException("Kerberos principal components
must not be empty nor contain a realm separator");
+ }
+ if (realm == null || realm.isEmpty()) {
+ throw new IllegalArgumentException("Kerberos realm must not be
empty");
+ }
+ if (!components.equals(components.toLowerCase(Locale.ROOT))) {
+ throw new IllegalArgumentException("Kerberos principal components
must be lower case");
+ }
+ if (!realm.equals(realm.toUpperCase(Locale.ROOT))) {
+ throw new IllegalArgumentException("Kerberos realm must be upper
case");
+ }
+ }
+
+ String asString() {
+ return components + "@" + realm;
+ }
+}
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
index 92d0207760..48bb3cecdf 100644
---
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
@@ -20,13 +20,11 @@
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;
@@ -36,35 +34,28 @@ 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);
+ private static class UnresolvableIdentity extends RuntimeException {
+ private final SaslFailure failure;
- // 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");
+ UnresolvableIdentity(SaslFailure failure) {
+ super(failure.reason(), null, false, false);
+ this.failure = failure;
}
- 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");
+ SaslFailure failure() {
+ return failure;
}
- return username;
}
+ private static final SaslFailure MALFORMED =
SaslFailure.malformed("Malformed GSSAPI identity.");
+
private final SaslAuthenticator authenticator;
+ private final RealmMapping realmMapping;
private Optional<SaslAuthenticationResult> result;
- GssapiAuthorizeCallbackHandler(SaslAuthenticator authenticator) {
+ GssapiAuthorizeCallbackHandler(SaslAuthenticator authenticator,
RealmMapping realmMapping) {
this.authenticator = authenticator;
+ this.realmMapping = realmMapping;
this.result = Optional.empty();
}
@@ -83,24 +74,66 @@ class GssapiAuthorizeCallbackHandler implements
CallbackHandler {
}
private void authorize(AuthorizeCallback callback) {
+ SaslIdentity identity;
try {
- Username authenticationId =
canonicalAuthenticationId(callback.getAuthenticationID());
- Username authorizationId =
Optional.ofNullable(callback.getAuthorizationID())
+ Username authenticationId =
resolve(canonicalPrincipal(callback.getAuthenticationID()));
+ identity = new SaslIdentity(authenticationId,
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);
+ .map(this::authorizationId)
+ .orElse(authenticationId));
+ } catch (UnresolvableIdentity e) {
+ result = Optional.of(new
SaslAuthenticationResult.Failure(e.failure()));
+ callback.setAuthorized(false);
+ return;
+ }
+
+ result = Optional.of(authenticator.authorize(identity));
+ switch (result.orElseThrow()) {
+ case SaslAuthenticationResult.Success success -> {
+ callback.setAuthorized(true);
+
callback.setAuthorizedID(success.identity().authorizationId().asString());
}
+ case SaslAuthenticationResult.Failure ignored ->
callback.setAuthorized(false);
+ }
+ }
+
+ /**
+ * GSSAPI implementations echo the client principal when the client
requests no specific authorization identity, so
+ * a canonically spelled Kerberos principal goes through the realm mapping
like the authentication identity. Anything
+ * else is the James username the client asks to act as, and the
authenticator applies the usual delegation rules.
+ */
+ private Username authorizationId(String authorizationId) {
+ Optional<CanonicalKerberosPrincipal> principal =
principal(authorizationId);
+ if (principal.isPresent()) {
+ return resolve(principal.get());
+ }
+ try {
+ return Username.of(authorizationId);
} catch (IllegalArgumentException e) {
- result = Optional.of(new
SaslAuthenticationResult.Failure(SaslFailure.malformed("Malformed GSSAPI
identity.")));
- callback.setAuthorized(false);
+ throw new UnresolvableIdentity(MALFORMED);
+ }
+ }
+
+ private CanonicalKerberosPrincipal canonicalPrincipal(String
authenticationId) {
+ return principal(authenticationId).orElseThrow(() -> new
UnresolvableIdentity(MALFORMED));
+ }
+
+ private Optional<CanonicalKerberosPrincipal> principal(String value) {
+ try {
+ return Optional.of(CanonicalKerberosPrincipal.parse(value));
+ } catch (IllegalArgumentException e) {
+ return Optional.empty();
+ }
+ }
+
+ private Username resolve(CanonicalKerberosPrincipal principal) {
+ Optional<Username> username;
+ try {
+ username = realmMapping.resolve(principal);
+ } catch (IllegalArgumentException e) {
+ throw new UnresolvableIdentity(MALFORMED);
}
+ return username.orElseThrow(() -> new
UnresolvableIdentity(SaslFailure.authenticationFailed(
+ Optional.empty(), Optional.empty(), "GSSAPI principal is not
mapped to a James identity.")));
}
}
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
index aed55a8a73..f5ea97994e 100644
---
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
@@ -33,7 +33,8 @@ public record GssapiSaslConfiguration(String serviceName,
String serverName,
String principal,
Path keyTab,
- boolean requireSSL) {
+ boolean requireSSL,
+ RealmMapping realmMapping) {
private static final String CONFIGURATION_PATH = "auth.gssapi";
private static final boolean REQUIRE_SSL_DEFAULT = true;
@@ -50,7 +51,7 @@ public record GssapiSaslConfiguration(String serviceName,
validatePrincipal(serviceName, serverName, principal);
return new GssapiSaslConfiguration(serviceName, serverName, principal,
keyTab,
- serverConfiguration.getBoolean("auth.requireSSL",
REQUIRE_SSL_DEFAULT));
+ serverConfiguration.getBoolean("auth.requireSSL",
REQUIRE_SSL_DEFAULT), RealmMapping.from(configuration));
}
private static String required(HierarchicalConfiguration<ImmutableNode>
configuration, String property) throws ConfigurationException {
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
index 648b263c65..02cf8f2b8b 100644
---
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
@@ -63,7 +63,7 @@ class GssapiSaslExchange implements SaslExchange {
this.configuration = configuration;
this.loginContextFactory = loginContextFactory;
this.saslServerFactory = saslServerFactory;
- this.callbackHandler = new
GssapiAuthorizeCallbackHandler(authenticator);
+ this.callbackHandler = new
GssapiAuthorizeCallbackHandler(authenticator, configuration.realmMapping());
this.state = State.NEW;
}
diff --git
a/protocols/sasl-kerberos/src/main/java/org/apache/james/protocols/sasl/kerberos/RealmMapping.java
b/protocols/sasl-kerberos/src/main/java/org/apache/james/protocols/sasl/kerberos/RealmMapping.java
new file mode 100644
index 0000000000..a6d05b5c80
--- /dev/null
+++
b/protocols/sasl-kerberos/src/main/java/org/apache/james/protocols/sasl/kerberos/RealmMapping.java
@@ -0,0 +1,118 @@
+/****************************************************************
+ * 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.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Optional;
+
+import org.apache.commons.configuration2.HierarchicalConfiguration;
+import org.apache.commons.configuration2.ex.ConfigurationException;
+import org.apache.commons.configuration2.tree.ImmutableNode;
+import org.apache.james.core.Domain;
+import org.apache.james.core.Username;
+
+import com.google.common.collect.ImmutableMap;
+
+public record RealmMapping(Map<String, Domain> domainByRealm) {
+ public static final RealmMapping REALM_AS_DOMAIN = new
RealmMapping(Map.of());
+
+ private static final String CONFIGURATION_PATH = "realmMapping";
+
+ static RealmMapping from(HierarchicalConfiguration<ImmutableNode>
gssapiConfiguration) throws ConfigurationException {
+ if
(gssapiConfiguration.immutableConfigurationsAt(CONFIGURATION_PATH).isEmpty()) {
+ return REALM_AS_DOMAIN;
+ }
+
+ List<HierarchicalConfiguration<ImmutableNode>> realms =
gssapiConfiguration.configurationsAt(CONFIGURATION_PATH + ".realm");
+ if (realms.isEmpty()) {
+ throw new ConfigurationException("auth.gssapi.realmMapping must
declare at least one realm");
+ }
+
+ Map<String, Domain> domainByRealm = new LinkedHashMap<>();
+ for (HierarchicalConfiguration<ImmutableNode> realm : realms) {
+ String name = name(realm);
+ if (domainByRealm.put(name, domain(realm, name)) != null) {
+ throw new ConfigurationException("auth.gssapi.realmMapping
declares realm " + name + " twice");
+ }
+ }
+
+ try {
+ return new RealmMapping(domainByRealm);
+ } catch (IllegalArgumentException e) {
+ throw new ConfigurationException("auth.gssapi.realmMapping is
invalid", e);
+ }
+ }
+
+ private static String name(HierarchicalConfiguration<ImmutableNode> realm)
throws ConfigurationException {
+ String name = realm.getString("[@name]");
+ if (name == null || name.isBlank()) {
+ throw new ConfigurationException("auth.gssapi.realmMapping.realm
must carry a name attribute");
+ }
+ return name.trim();
+ }
+
+ private static Domain domain(HierarchicalConfiguration<ImmutableNode>
realm, String name) throws ConfigurationException {
+ String domain = realm.getString("[@domain]");
+ if (domain == null || domain.isBlank()) {
+ throw new ConfigurationException("auth.gssapi.realmMapping.realm "
+ name + " must carry a domain attribute");
+ }
+ try {
+ return Domain.of(domain.trim());
+ } catch (IllegalArgumentException e) {
+ throw new ConfigurationException("auth.gssapi.realmMapping.realm "
+ name + " carries an invalid domain", e);
+ }
+ }
+
+ public RealmMapping {
+ Map<Domain, String> realmByDomain = new HashMap<>();
+ domainByRealm.forEach((realm, domain) -> {
+ if (!realm.equals(realm.toUpperCase(Locale.ROOT))) {
+ throw new IllegalArgumentException("Kerberos realm " + realm +
" must be upper case");
+ }
+ String conflictingRealm = realmByDomain.put(domain, realm);
+ if (conflictingRealm != null) {
+ throw new IllegalArgumentException("Realms " +
conflictingRealm + " and " + realm
+ + " are both mapped onto domain " + domain.asString());
+ }
+ });
+ domainByRealm = ImmutableMap.copyOf(domainByRealm);
+ }
+
+ Optional<Username> resolve(CanonicalKerberosPrincipal principal) {
+ return domain(principal.realm())
+ .map(domain -> Username.of(principal.components() + "@" +
domain.asString()));
+ }
+
+ private Optional<Domain> domain(String realm) {
+ if (domainByRealm.isEmpty()) {
+ Domain domain = Domain.of(realm);
+ // Case folding is intentional; reject any additional
normalization that could collapse distinct realms.
+ if (!domain.asString().equals(realm.toLowerCase(Locale.US))) {
+ throw new IllegalArgumentException("Kerberos realm " + realm +
" cannot be used as a mail domain without normalization");
+ }
+ return Optional.of(domain);
+ }
+ return Optional.ofNullable(domainByRealm.get(realm));
+ }
+}
diff --git
a/protocols/sasl-kerberos/src/test/java/org/apache/james/protocols/sasl/kerberos/CanonicalKerberosPrincipalTest.java
b/protocols/sasl-kerberos/src/test/java/org/apache/james/protocols/sasl/kerberos/CanonicalKerberosPrincipalTest.java
new file mode 100644
index 0000000000..8476053d92
--- /dev/null
+++
b/protocols/sasl-kerberos/src/test/java/org/apache/james/protocols/sasl/kerberos/CanonicalKerberosPrincipalTest.java
@@ -0,0 +1,102 @@
+/****************************************************************
+ * 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.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+class CanonicalKerberosPrincipalTest {
+ @Test
+ void shouldParseCanonicalPrincipal() {
+ assertThat(CanonicalKerberosPrincipal.parse("[email protected]"))
+ .isEqualTo(new CanonicalKerberosPrincipal("alice", "EXAMPLE.COM"));
+ }
+
+ @Test
+ void shouldParseMultiComponentPrincipal() {
+ assertThat(CanonicalKerberosPrincipal.parse("alice/[email protected]"))
+ .isEqualTo(new CanonicalKerberosPrincipal("alice/admin",
"EXAMPLE.COM"));
+ }
+
+ @Test
+ void asStringShouldRoundTripTheParsedIdentity() {
+
assertThat(CanonicalKerberosPrincipal.parse("[email protected]").asString()).isEqualTo("[email protected]");
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {
+ "[email protected]",
+ "[email protected]",
+ "[email protected]",
+ "",
+ "@EXAMPLE.COM",
+ "alicé@EXAMPLE.COM",
+ // U+212A KELVIN SIGN lower cases to an ASCII 'k', colliding with the
[email protected] principal.
+ "alice@EXAMPLE.K"})
+ void shouldRejectNonCanonicalIdentities(String authenticationId) {
+ assertThatThrownBy(() ->
CanonicalKerberosPrincipal.parse(authenticationId))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ void shouldRejectNullIdentity() {
+ assertThatThrownBy(() -> CanonicalKerberosPrincipal.parse(null))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("GSSAPI authentication identity must contain only
ASCII characters");
+ }
+
+ @Test
+ void constructorShouldRejectUpperCaseComponents() {
+ assertThatThrownBy(() -> new CanonicalKerberosPrincipal("Alice",
"EXAMPLE.COM"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Kerberos principal components must be lower case");
+ }
+
+ @Test
+ void constructorShouldRejectLowerCaseRealm() {
+ assertThatThrownBy(() -> new CanonicalKerberosPrincipal("alice",
"example.com"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Kerberos realm must be upper case");
+ }
+
+ @Test
+ void constructorShouldRejectComponentsCarryingARealm() {
+ assertThatThrownBy(() -> new
CanonicalKerberosPrincipal("[email protected]", "EXAMPLE.COM"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Kerberos principal components must not be empty nor
contain a realm separator");
+ }
+
+ @Test
+ void constructorShouldRejectEmptyRealm() {
+ assertThatThrownBy(() -> new CanonicalKerberosPrincipal("alice", ""))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Kerberos realm must not be empty");
+ }
+
+ @Test
+ void constructorShouldAcceptCaselessComponents() {
+ assertThatCode(() -> new CanonicalKerberosPrincipal("alice.1",
"EXAMPLE.COM")).doesNotThrowAnyException();
+ }
+}
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
index a6fe3721c5..4ca6b763f0 100644
---
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
@@ -22,6 +22,7 @@ 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.Map;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Stream;
@@ -30,6 +31,7 @@ import javax.security.auth.callback.NameCallback;
import javax.security.auth.callback.UnsupportedCallbackException;
import javax.security.sasl.AuthorizeCallback;
+import org.apache.james.core.Domain;
import org.apache.james.core.Username;
import org.apache.james.protocols.api.sasl.SaslAuthenticationResult;
import org.apache.james.protocols.api.sasl.SaslAuthenticator;
@@ -50,7 +52,7 @@ class GssapiAuthorizeCallbackHandlerTest {
@Test
void shouldAuthorizeCanonicalSelfIdentity() throws Exception {
AtomicReference<SaslIdentity> identity = new AtomicReference<>();
- GssapiAuthorizeCallbackHandler testee = new
GssapiAuthorizeCallbackHandler(authorizing(identity));
+ GssapiAuthorizeCallbackHandler testee = new
GssapiAuthorizeCallbackHandler(authorizing(identity),
RealmMapping.REALM_AS_DOMAIN);
AuthorizeCallback callback = new
AuthorizeCallback("[email protected]", "[email protected]");
testee.handle(new AuthorizeCallback[] {callback});
@@ -64,7 +66,7 @@ class GssapiAuthorizeCallbackHandlerTest {
@Test
void shouldRejectNonCanonicalPrincipalComponentCase() throws Exception {
AtomicReference<SaslIdentity> identity = new AtomicReference<>();
- GssapiAuthorizeCallbackHandler testee = new
GssapiAuthorizeCallbackHandler(authorizing(identity));
+ GssapiAuthorizeCallbackHandler testee = new
GssapiAuthorizeCallbackHandler(authorizing(identity),
RealmMapping.REALM_AS_DOMAIN);
AuthorizeCallback callback = new
AuthorizeCallback("[email protected]", "[email protected]");
testee.handle(new AuthorizeCallback[] {callback});
@@ -79,7 +81,7 @@ class GssapiAuthorizeCallbackHandlerTest {
@Test
void shouldRejectNonCanonicalRealmCase() throws Exception {
AtomicReference<SaslIdentity> identity = new AtomicReference<>();
- GssapiAuthorizeCallbackHandler testee = new
GssapiAuthorizeCallbackHandler(authorizing(identity));
+ GssapiAuthorizeCallbackHandler testee = new
GssapiAuthorizeCallbackHandler(authorizing(identity),
RealmMapping.REALM_AS_DOMAIN);
AuthorizeCallback callback = new
AuthorizeCallback("[email protected]", "[email protected]");
testee.handle(new AuthorizeCallback[] {callback});
@@ -95,7 +97,7 @@ class GssapiAuthorizeCallbackHandlerTest {
@MethodSource("normalizationCollisions")
void shouldRejectAuthenticationIdentityNormalizationCollisions(String
authenticationId, String collidingCanonicalIdentity) throws Exception {
AtomicReference<SaslIdentity> identity = new AtomicReference<>();
- GssapiAuthorizeCallbackHandler testee = new
GssapiAuthorizeCallbackHandler(authorizing(identity));
+ GssapiAuthorizeCallbackHandler testee = new
GssapiAuthorizeCallbackHandler(authorizing(identity),
RealmMapping.REALM_AS_DOMAIN);
AuthorizeCallback callback = new AuthorizeCallback(authenticationId,
authenticationId);
assertThat(Username.of(authenticationId)).isEqualTo(Username.of(collidingCanonicalIdentity));
@@ -109,11 +111,56 @@ class GssapiAuthorizeCallbackHandlerTest {
actual ->
assertThat(actual.failure().type()).isEqualTo(SaslFailure.Type.MALFORMED)));
}
+ @Test
+ void shouldAuthorizeThroughTheConfiguredRealmMapping() throws Exception {
+ AtomicReference<SaslIdentity> identity = new AtomicReference<>();
+ GssapiAuthorizeCallbackHandler testee = new
GssapiAuthorizeCallbackHandler(authorizing(identity),
+ new RealmMapping(Map.of("CORP.EXAMPLE.COM",
Domain.of("example.com"))));
+ AuthorizeCallback callback = new
AuthorizeCallback("[email protected]", "[email protected]");
+
+ testee.handle(new AuthorizeCallback[] {callback});
+
+ assertThat(identity.get()).isEqualTo(new
SaslIdentity(Username.of("[email protected]"),
Username.of("[email protected]")));
+ assertThat(callback.isAuthorized()).isTrue();
+ assertThat(callback.getAuthorizedID()).isEqualTo("[email protected]");
+ }
+
+ @Test
+ void shouldRejectPrincipalOfAnUnmappedRealm() throws Exception {
+ AtomicReference<SaslIdentity> identity = new AtomicReference<>();
+ GssapiAuthorizeCallbackHandler testee = new
GssapiAuthorizeCallbackHandler(authorizing(identity),
+ new RealmMapping(Map.of("CORP.EXAMPLE.COM",
Domain.of("example.com"))));
+ 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.AUTHENTICATION_FAILED)));
+ }
+
+ @Test
+ void shouldRejectMalformedAuthorizationIdentity() throws Exception {
+ AtomicReference<SaslIdentity> identity = new AtomicReference<>();
+ GssapiAuthorizeCallbackHandler testee = new
GssapiAuthorizeCallbackHandler(authorizing(identity),
RealmMapping.REALM_AS_DOMAIN);
+ AuthorizeCallback callback = new
AuthorizeCallback("[email protected]", "bob@exa mple.com");
+
+ 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));
+ GssapiAuthorizeCallbackHandler testee = new
GssapiAuthorizeCallbackHandler(failing(failure), RealmMapping.REALM_AS_DOMAIN);
AuthorizeCallback callback = new
AuthorizeCallback("[email protected]", "[email protected]");
testee.handle(new AuthorizeCallback[] {callback});
@@ -125,7 +172,7 @@ class GssapiAuthorizeCallbackHandlerTest {
@Test
void shouldRejectMalformedIdentity() throws Exception {
GssapiAuthorizeCallbackHandler testee = new
GssapiAuthorizeCallbackHandler(failing(
- SaslFailure.authenticationFailed(Optional.empty(),
Optional.empty(), "unused")));
+ SaslFailure.authenticationFailed(Optional.empty(),
Optional.empty(), "unused")), RealmMapping.REALM_AS_DOMAIN);
AuthorizeCallback callback = new AuthorizeCallback("", "");
testee.handle(new AuthorizeCallback[] {callback});
@@ -139,7 +186,7 @@ class GssapiAuthorizeCallbackHandlerTest {
@Test
void shouldRejectUnexpectedCallback() {
GssapiAuthorizeCallbackHandler testee = new
GssapiAuthorizeCallbackHandler(failing(
- SaslFailure.authenticationFailed(Optional.empty(),
Optional.empty(), "unused")));
+ SaslFailure.authenticationFailed(Optional.empty(),
Optional.empty(), "unused")), RealmMapping.REALM_AS_DOMAIN);
assertThatThrownBy(() -> testee.handle(new NameCallback[] {new
NameCallback("name")}))
.isInstanceOf(UnsupportedCallbackException.class);
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
index 93c787a099..3033be37e1 100644
---
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
@@ -23,12 +23,15 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.fail;
import java.nio.file.Path;
+import java.util.Map;
import java.util.Optional;
+import org.apache.james.core.Domain;
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.SaslMechanism;
@@ -48,10 +51,7 @@ class GssapiJdkInteropTest {
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());
+ SaslMechanism mechanism = mechanism(service,
RealmMapping.REALM_AS_DOMAIN);
try (GssapiTestClient client = kerberos.client(service);
SaslExchange exchange = mechanism.start(
@@ -69,6 +69,54 @@ class GssapiJdkInteropTest {
}
}
+ @Test
+ @ResourceLock(KerberosTestFixture.KRB5_CONFIGURATION_RESOURCE)
+ void shouldMapTheRealmOntoTheConfiguredDomain() throws Exception {
+ try (KerberosTestFixture kerberos = new
KerberosTestFixture(temporaryDirectory)) {
+ KerberosTestFixture.Service service =
kerberos.provisionService("imap", SERVER_NAME);
+ SaslMechanism mechanism = mechanism(service, new RealmMapping(
+ Map.of(KerberosTestFixture.REALM, Domain.of("example.com"))));
+
+ 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("[email protected]"));
+
assertThat(success.identity().authorizationId()).isEqualTo(Username.of("[email protected]"));
+ });
+ }
+ }
+ }
+
+ @Test
+ @ResourceLock(KerberosTestFixture.KRB5_CONFIGURATION_RESOURCE)
+ void shouldRejectPrincipalsOfAnUnmappedRealm() throws Exception {
+ try (KerberosTestFixture kerberos = new
KerberosTestFixture(temporaryDirectory)) {
+ KerberosTestFixture.Service service =
kerberos.provisionService("imap", SERVER_NAME);
+ SaslMechanism mechanism = mechanism(service, new RealmMapping(
+ Map.of("OTHER.TEST", Domain.of("example.com"))));
+
+ 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.Failure.class,
+ failure ->
assertThat(failure.failure().type()).isEqualTo(SaslFailure.Type.AUTHENTICATION_FAILED));
+ }
+ }
+ }
+
+ private SaslMechanism mechanism(KerberosTestFixture.Service service,
RealmMapping realmMapping) {
+ GssapiSaslConfiguration configuration = new GssapiSaslConfiguration(
+ service.serviceName(), service.serverName(), service.principal(),
service.keyTab(), true, realmMapping);
+ return new GssapiSaslMechanism(configuration, new
KerberosLoginContextFactory(), new JdkSaslServerFactory());
+ }
+
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++) {
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
index b359ce1ba6..77bebbc0c1 100644
---
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
@@ -52,7 +52,7 @@ class GssapiSaslConfigurationTest {
GssapiSaslConfiguration configuration =
GssapiSaslConfiguration.from(configuration(keyTab.toString()));
assertThat(configuration).isEqualTo(new GssapiSaslConfiguration(
- SERVICE_NAME, SERVER_NAME, PRINCIPAL, keyTab.toAbsolutePath(),
true));
+ SERVICE_NAME, SERVER_NAME, PRINCIPAL, keyTab.toAbsolutePath(),
true, RealmMapping.REALM_AS_DOMAIN));
}
@Test
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
index b2438ac13a..d8fff685db 100644
---
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
@@ -249,6 +249,30 @@ class GssapiSaslMechanismTest {
verify(loginContextFactory, never()).login(any());
}
+ @Test
+ void factoryShouldResolveIdentitiesThroughTheConfiguredRealmMapping()
throws Exception {
+ Path keyTab =
Files.createFile(temporaryDirectory.resolve("imap.keytab"));
+ KerberosLoginContextFactory loginContextFactory =
mock(KerberosLoginContextFactory.class);
+ 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]");
+ return null;
+ });
+ when(saslServer.isComplete()).thenReturn(true);
+ when(saslServer.getNegotiatedProperty(Sasl.QOP)).thenReturn("auth");
+ GssapiSaslMechanismFactory testee = new
GssapiSaslMechanismFactory(mock(KeyTabPrincipalVerifier.class),
loginContextFactory,
+ capturingFactory(saslServer, callbackHandler));
+ BaseHierarchicalConfiguration configuration = configuration(keyTab);
+ configuration.addProperty("auth.gssapi.realmMapping.realm(-1)[@name]",
"CORP.EXAMPLE.COM");
+ configuration.addProperty("auth.gssapi.realmMapping.realm[@domain]",
"example.com");
+
+ try (SaslExchange exchange =
testee.create(configuration).start(request(Optional.of(INITIAL_TOKEN)),
authenticating())) {
+ assertThat(exchange.firstStep()).isEqualTo(new
SaslStep.Success(IDENTITY, Optional.empty()));
+ }
+ }
+
private TestExchange completedExchange(byte[] output, String qop,
SaslAuthenticator authenticator) throws Exception {
KerberosLoginContextFactory loginContextFactory =
mock(KerberosLoginContextFactory.class);
KerberosLoginContext loginContext = loginContext(loginContextFactory);
@@ -311,7 +335,8 @@ class GssapiSaslMechanismTest {
KerberosLoginContextFactory
loginContextFactory,
GssapiSaslServerFactory
saslServerFactory) {
GssapiSaslConfiguration configuration = new GssapiSaslConfiguration(
- "imap", "mail.example.com", "imap/[email protected]",
Path.of("imap.keytab"), requireSSL);
+ "imap", "mail.example.com", "imap/[email protected]",
Path.of("imap.keytab"), requireSSL,
+ RealmMapping.REALM_AS_DOMAIN);
return new GssapiSaslMechanism(configuration, loginContextFactory,
saslServerFactory);
}
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
index ca9a419018..b742a7eddf 100644
---
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
@@ -41,7 +41,8 @@ class KeyTabPrincipalVerifierTest {
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);
+ service.serviceName(), service.serverName(),
service.principal(), service.keyTab(), true,
+ RealmMapping.REALM_AS_DOMAIN);
assertThatCode(() -> new
KeyTabPrincipalVerifier().verify(configuration))
.doesNotThrowAnyException();
@@ -54,7 +55,8 @@ class KeyTabPrincipalVerifierTest {
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);
+ "imap", SERVER_NAME, "imap/" + SERVER_NAME + "@" +
KerberosTestFixture.REALM, service.keyTab(), true,
+ RealmMapping.REALM_AS_DOMAIN);
assertThatThrownBy(() -> new
KeyTabPrincipalVerifier().verify(configuration))
.isInstanceOf(ConfigurationException.class)
diff --git
a/protocols/sasl-kerberos/src/test/java/org/apache/james/protocols/sasl/kerberos/RealmMappingTest.java
b/protocols/sasl-kerberos/src/test/java/org/apache/james/protocols/sasl/kerberos/RealmMappingTest.java
new file mode 100644
index 0000000000..6695f0ccc7
--- /dev/null
+++
b/protocols/sasl-kerberos/src/test/java/org/apache/james/protocols/sasl/kerberos/RealmMappingTest.java
@@ -0,0 +1,213 @@
+/****************************************************************
+ * 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.io.ByteArrayInputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Map;
+
+import org.apache.commons.configuration2.BaseHierarchicalConfiguration;
+import org.apache.commons.configuration2.XMLConfiguration;
+import org.apache.commons.configuration2.ex.ConfigurationException;
+import org.apache.commons.configuration2.io.FileHandler;
+import org.apache.james.core.Domain;
+import org.apache.james.core.Username;
+import org.junit.jupiter.api.Test;
+
+class RealmMappingTest {
+ private static final CanonicalKerberosPrincipal ALICE = new
CanonicalKerberosPrincipal("alice", "CORP.EXAMPLE.COM");
+
+ @Test
+ void shouldDefaultToRealmAsDomain() throws Exception {
+ assertThat(RealmMapping.from(new
BaseHierarchicalConfiguration())).isEqualTo(RealmMapping.REALM_AS_DOMAIN);
+ }
+
+ @Test
+ void realmAsDomainShouldMakeTheRealmTheMailDomain() {
+ assertThat(RealmMapping.REALM_AS_DOMAIN.resolve(new
CanonicalKerberosPrincipal("alice", "EXAMPLE.COM")))
+ .contains(Username.of("[email protected]"));
+ }
+
+ @Test
+ void realmAsDomainShouldPreserveMultiComponentPrincipals() {
+ assertThat(RealmMapping.REALM_AS_DOMAIN.resolve(new
CanonicalKerberosPrincipal("alice/admin", "EXAMPLE.COM")))
+ .contains(Username.of("alice/[email protected]"));
+ }
+
+ @Test
+ void realmAsDomainShouldRejectRealmsCollapsingOntoAnotherPrincipal() {
+ // Domain.of strips the brackets, so this realm would silently
collapse onto the EXAMPLE.COM one.
+
assertThat(Username.of("alice@[EXAMPLE.COM]")).isEqualTo(Username.of("[email protected]"));
+
+ assertThatThrownBy(() -> RealmMapping.REALM_AS_DOMAIN.resolve(new
CanonicalKerberosPrincipal("alice", "[EXAMPLE.COM]")))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ void shouldMapRealmOntoConfiguredDomain() throws Exception {
+ RealmMapping testee =
RealmMapping.from(realmMapping("CORP.EXAMPLE.COM", "example.com"));
+
+
assertThat(testee.resolve(ALICE)).contains(Username.of("[email protected]"));
+ }
+
+ @Test
+ void shouldMapEachConfiguredRealm() throws Exception {
+ BaseHierarchicalConfiguration configuration =
realmMapping("CORP.EXAMPLE.COM", "example.com");
+ addRealm(configuration, "OTHER.EXAMPLE.COM", "other.example.com");
+ RealmMapping testee = RealmMapping.from(configuration);
+
+ assertThat(testee.resolve(new CanonicalKerberosPrincipal("bob",
"OTHER.EXAMPLE.COM")))
+ .contains(Username.of("[email protected]"));
+ }
+
+ @Test
+ void shouldNotResolveUnlistedRealm() throws Exception {
+ RealmMapping testee =
RealmMapping.from(realmMapping("CORP.EXAMPLE.COM", "example.com"));
+
+ assertThat(testee.resolve(new CanonicalKerberosPrincipal("alice",
"EXAMPLE.COM"))).isEmpty();
+ }
+
+ @Test
+ void shouldPreserveMultiComponentPrincipals() throws Exception {
+ RealmMapping testee =
RealmMapping.from(realmMapping("CORP.EXAMPLE.COM", "example.com"));
+
+ assertThat(testee.resolve(new
CanonicalKerberosPrincipal("alice/admin", "CORP.EXAMPLE.COM")))
+ .contains(Username.of("alice/[email protected]"));
+ }
+
+ @Test
+ void shouldReadRealmMappingFromXmlConfiguration() throws Exception {
+ XMLConfiguration configuration = new XMLConfiguration();
+ FileHandler fileHandler = new FileHandler(configuration);
+ fileHandler.load(new ByteArrayInputStream("""
+ <gssapi>
+ <realmMapping>
+ <realm name="CORP.EXAMPLE.COM" domain="example.com"/>
+ <realm name="OTHER.EXAMPLE.COM"
domain="other.example.com"/>
+ </realmMapping>
+ </gssapi>""".getBytes(StandardCharsets.UTF_8)));
+
+ RealmMapping testee = RealmMapping.from(configuration);
+
+
assertThat(testee.resolve(ALICE)).contains(Username.of("[email protected]"));
+ assertThat(testee.resolve(new CanonicalKerberosPrincipal("bob",
"OTHER.EXAMPLE.COM")))
+ .contains(Username.of("[email protected]"));
+ }
+
+ @Test
+ void shouldRejectEmptyRealmMapping() {
+ BaseHierarchicalConfiguration configuration = new
BaseHierarchicalConfiguration();
+ configuration.addProperty("realmMapping", "");
+
+ assertThatThrownBy(() -> RealmMapping.from(configuration))
+ .isInstanceOf(ConfigurationException.class)
+ .hasMessage("auth.gssapi.realmMapping must declare at least one
realm");
+ }
+
+ @Test
+ void shouldRejectRealmWithoutName() {
+ BaseHierarchicalConfiguration configuration = new
BaseHierarchicalConfiguration();
+ configuration.addProperty("realmMapping.realm(-1)[@domain]",
"example.com");
+
+ assertThatThrownBy(() -> RealmMapping.from(configuration))
+ .isInstanceOf(ConfigurationException.class)
+ .hasMessage("auth.gssapi.realmMapping.realm must carry a name
attribute");
+ }
+
+ @Test
+ void shouldRejectRealmWithoutDomain() {
+ BaseHierarchicalConfiguration configuration = new
BaseHierarchicalConfiguration();
+ configuration.addProperty("realmMapping.realm(-1)[@name]",
"CORP.EXAMPLE.COM");
+
+ assertThatThrownBy(() -> RealmMapping.from(configuration))
+ .isInstanceOf(ConfigurationException.class)
+ .hasMessage("auth.gssapi.realmMapping.realm CORP.EXAMPLE.COM must
carry a domain attribute");
+ }
+
+ @Test
+ void shouldRejectInvalidDomain() {
+ assertThatThrownBy(() ->
RealmMapping.from(realmMapping("CORP.EXAMPLE.COM", "exa mple.com")))
+ .isInstanceOf(ConfigurationException.class)
+ .hasMessage("auth.gssapi.realmMapping.realm CORP.EXAMPLE.COM
carries an invalid domain");
+ }
+
+ @Test
+ void shouldRejectDuplicatedRealm() {
+ BaseHierarchicalConfiguration configuration =
realmMapping("CORP.EXAMPLE.COM", "example.com");
+ addRealm(configuration, "CORP.EXAMPLE.COM", "other.example.com");
+
+ assertThatThrownBy(() -> RealmMapping.from(configuration))
+ .isInstanceOf(ConfigurationException.class)
+ .hasMessage("auth.gssapi.realmMapping declares realm
CORP.EXAMPLE.COM twice");
+ }
+
+ @Test
+ void shouldRejectRealmsSharingADomain() {
+ BaseHierarchicalConfiguration configuration =
realmMapping("CORP.EXAMPLE.COM", "example.com");
+ addRealm(configuration, "OTHER.EXAMPLE.COM", "example.com");
+
+ assertThatThrownBy(() -> RealmMapping.from(configuration))
+ .isInstanceOf(ConfigurationException.class)
+ .hasMessage("auth.gssapi.realmMapping is invalid")
+ .hasRootCauseMessage("Realms CORP.EXAMPLE.COM and
OTHER.EXAMPLE.COM are both mapped onto domain example.com");
+ }
+
+ @Test
+ void shouldRejectRealmsSharingADomainSpelledDifferently() {
+ BaseHierarchicalConfiguration configuration =
realmMapping("CORP.EXAMPLE.COM", "example.com");
+ addRealm(configuration, "OTHER.EXAMPLE.COM", "EXAMPLE.COM");
+
+ assertThatThrownBy(() -> RealmMapping.from(configuration))
+ .isInstanceOf(ConfigurationException.class)
+ .hasMessage("auth.gssapi.realmMapping is invalid");
+ }
+
+ @Test
+ void shouldRejectLowerCaseRealm() {
+ assertThatThrownBy(() ->
RealmMapping.from(realmMapping("corp.example.com", "example.com")))
+ .isInstanceOf(ConfigurationException.class)
+ .hasMessage("auth.gssapi.realmMapping is invalid")
+ .hasRootCauseMessage("Kerberos realm corp.example.com must be
upper case");
+ }
+
+ @Test
+ void constructorShouldRejectRealmsSharingADomain() {
+ Map<String, Domain> domainByRealm = Map.of(
+ "CORP.EXAMPLE.COM", Domain.of("example.com"),
+ "OTHER.EXAMPLE.COM", Domain.of("example.com"));
+
+ assertThatThrownBy(() -> new RealmMapping(domainByRealm))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ private BaseHierarchicalConfiguration realmMapping(String realm, String
domain) {
+ BaseHierarchicalConfiguration configuration = new
BaseHierarchicalConfiguration();
+ addRealm(configuration, realm, domain);
+ return configuration;
+ }
+
+ private void addRealm(BaseHierarchicalConfiguration configuration, String
realm, String domain) {
+ configuration.addProperty("realmMapping.realm(-1)[@name]", realm);
+ configuration.addProperty("realmMapping.realm[@domain]", domain);
+ }
+}
diff --git
a/server/apps/memory-app/src/test/java/org/apache/james/SaslGssapiRealmMappingIntegrationTest.java
b/server/apps/memory-app/src/test/java/org/apache/james/SaslGssapiRealmMappingIntegrationTest.java
new file mode 100644
index 0000000000..e06e6f8482
--- /dev/null
+++
b/server/apps/memory-app/src/test/java/org/apache/james/SaslGssapiRealmMappingIntegrationTest.java
@@ -0,0 +1,146 @@
+/****************************************************************
+ * 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;
+
+import static
org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT;
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.util.Base64;
+import java.util.Optional;
+
+import org.apache.commons.net.imap.AuthenticatingIMAPClient;
+import org.apache.commons.net.imap.IMAPReply;
+import org.apache.james.modules.protocols.ImapGuiceProbe;
+import org.apache.james.protocols.api.utils.BogusSslContextFactory;
+import org.apache.james.protocols.api.utils.BogusTrustManagerFactory;
+import org.apache.james.protocols.sasl.kerberos.GssapiTestClient;
+import org.apache.james.protocols.sasl.kerberos.KerberosTestExtension;
+import org.apache.james.protocols.sasl.kerberos.KerberosTestFixture;
+import org.apache.james.utils.DataProbeImpl;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Order;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import org.junit.jupiter.api.parallel.ResourceLock;
+
+// The embedded KDC temporarily replaces the JVM-wide Kerberos configuration.
+@ResourceLock(KerberosTestFixture.KRB5_CONFIGURATION_RESOURCE)
+class SaslGssapiRealmMappingIntegrationTest {
+ private static final String DOMAIN = "example.com";
+ private static final String USERNAME = "alice@" + DOMAIN;
+ private static final String HOST = "127.0.0.1";
+ private static final int MAX_SASL_ROUNDS = 10;
+
+ // Provision Kerberos credentials before James resolves them from its
server configuration.
+ @Order(1)
+ @RegisterExtension
+ static KerberosTestExtension kerberos = new KerberosTestExtension("imap");
+
+ @Order(2)
+ @RegisterExtension
+ static JamesServerExtension jamesServerExtension = new
JamesServerBuilder<MemoryJamesConfiguration>(tmpDir -> {
+ TemporaryJamesServer temporaryJamesServer = new
TemporaryJamesServer(tmpDir);
+
temporaryJamesServer.copyResources("imapserver-gssapi-realm-mapping.xml",
"imapserver.xml");
+ return MemoryJamesConfiguration.builder()
+ .workingDirectory(tmpDir)
+ .usersRepository(DEFAULT)
+ .build();
+ })
+ .server(MemoryJamesServerMain::createServer)
+ .lifeCycle(JamesServerExtension.Lifecycle.PER_ENCLOSING_CLASS)
+ .build();
+
+ // The Kerberos realm is JAMES.TEST while the mail domain is example.com.
+ @BeforeAll
+ static void provisionMappedUser(GuiceJamesServer server) throws Exception {
+ server.getProbe(DataProbeImpl.class).fluent()
+ .addDomain(DOMAIN)
+ .addUser(USERNAME, "unused-password");
+ }
+
+ @Test
+ void shouldAuthenticateThroughTheConfiguredRealmMapping(GuiceJamesServer
server) throws Exception {
+ // Connect to IMAP over TLS, as required by the configured GSSAPI
mechanism.
+ int port = server.getProbe(ImapGuiceProbe.class).getImapStartTLSPort();
+ AuthenticatingIMAPClient imapClient = imapClient(port);
+
+ try (GssapiTestClient gssapiClient = kerberos.client("imap")) {
+ // Authenticate as [email protected], whose realm the configuration
maps onto example.com.
+ int replyCode = completeImapExchange(imapClient, gssapiClient,
+ imapClient.sendCommand("AUTHENTICATE GSSAPI " +
encode(gssapiClient.initialResponse())));
+
+ assertThat(replyCode)
+ .withFailMessage("Unexpected IMAP authentication reply: %s",
imapClient.getReplyString())
+ .isEqualTo(IMAPReply.OK);
+ assertThat(gssapiClient.isComplete()).isTrue();
+
+ // Verify the session is bound to the mapped account rather than
to the realm as a domain.
+ assertThat(imapClient.select("INBOX")).isTrue();
+ } finally {
+ imapClient.disconnect();
+ }
+ }
+
+ @Test
+ void shouldMapAnExplicitPrincipalAuthorizationIdentity(GuiceJamesServer
server) throws Exception {
+ // Connect over TLS and create a client requesting its own Kerberos
principal as authorization identity.
+ int port = server.getProbe(ImapGuiceProbe.class).getImapStartTLSPort();
+ AuthenticatingIMAPClient imapClient = imapClient(port);
+
+ try (GssapiTestClient gssapiClient = kerberos.client("imap",
Optional.of(KerberosTestFixture.USER_PRINCIPAL))) {
+ int replyCode = completeImapExchange(imapClient, gssapiClient,
+ imapClient.sendCommand("AUTHENTICATE GSSAPI " +
encode(gssapiClient.initialResponse())));
+
+ // Verify the authorization identity went through the realm
mapping too.
+ assertThat(replyCode)
+ .withFailMessage("Unexpected IMAP authentication reply: %s",
imapClient.getReplyString())
+ .isEqualTo(IMAPReply.OK);
+ assertThat(gssapiClient.isComplete()).isTrue();
+ assertThat(imapClient.select("INBOX")).isTrue();
+ } finally {
+ imapClient.disconnect();
+ }
+ }
+
+ private static AuthenticatingIMAPClient imapClient(int port) throws
Exception {
+ AuthenticatingIMAPClient client = new AuthenticatingIMAPClient(false,
BogusSslContextFactory.getClientContext());
+ client.setTrustManager(BogusTrustManagerFactory.getTrustManagers()[0]);
+ client.connect(HOST, port);
+ assertThat(client.execTLS()).isTrue();
+ return client;
+ }
+
+ private static byte[] imapChallenge(String reply) {
+ String challenge = reply.trim().substring(1).trim();
+ return challenge.isEmpty() ? new byte[0] :
Base64.getDecoder().decode(challenge);
+ }
+
+ private static String encode(byte[] token) {
+ return Base64.getEncoder().encodeToString(token);
+ }
+
+ private static int completeImapExchange(AuthenticatingIMAPClient
imapClient, GssapiTestClient gssapiClient, int replyCode) throws Exception {
+ // Relay each server challenge through the JDK GSSAPI client until
SASL completes.
+ for (int round = 0; round < MAX_SASL_ROUNDS &&
IMAPReply.isContinuation(replyCode); round++) {
+ replyCode =
imapClient.sendData(encode(gssapiClient.evaluate(imapChallenge(imapClient.getReplyString()))));
+ }
+ return replyCode;
+ }
+}
diff --git
a/server/apps/memory-app/src/test/resources/imapserver-gssapi-realm-mapping.xml
b/server/apps/memory-app/src/test/resources/imapserver-gssapi-realm-mapping.xml
new file mode 100644
index 0000000000..8f67b67fed
--- /dev/null
+++
b/server/apps/memory-app/src/test/resources/imapserver-gssapi-realm-mapping.xml
@@ -0,0 +1,48 @@
+<?xml version="1.0"?>
+
+<!--
+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.
+-->
+
+<imapservers>
+ <imapserver enabled="true">
+ <jmxName>imapserver-gssapi-realm-mapping</jmxName>
+ <bind>127.0.0.1:0</bind>
+ <tls socketTLS="false" startTLS="true">
+ <keystore>classpath://keystore</keystore>
+ <secret>james72laBalle</secret>
+
<provider>org.bouncycastle.jce.provider.BouncyCastleProvider</provider>
+ </tls>
+ <auth>
+ <requireSSL>true</requireSSL>
+
<saslMechanisms>org.apache.james.protocols.sasl.kerberos.GssapiSaslMechanismFactory</saslMechanisms>
+ <gssapi>
+ <serviceName>imap</serviceName>
+ <serverName>localhost</serverName>
+
<principal>${sys:james.test.kerberos.imap.principal}</principal>
+ <keyTab>${sys:james.test.kerberos.imap.keytab}</keyTab>
+ <realmMapping>
+ <realm name="JAMES.TEST" domain="example.com"/>
+ </realmMapping>
+ </gssapi>
+ </auth>
+ <connectionLimit>0</connectionLimit>
+ <connectionLimitPerIP>0</connectionLimitPerIP>
+ <gracefulShutdown>false</gracefulShutdown>
+ </imapserver>
+</imapservers>
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]