|
@@ -16,8 +16,12 @@ import org.apache.logging.log4j.LogManager;
|
|
|
import org.apache.logging.log4j.Logger;
|
|
|
import org.elasticsearch.action.ActionListener;
|
|
|
import org.elasticsearch.common.Strings;
|
|
|
+import org.elasticsearch.common.cache.Cache;
|
|
|
+import org.elasticsearch.common.cache.CacheBuilder;
|
|
|
+import org.elasticsearch.common.hash.MessageDigests;
|
|
|
import org.elasticsearch.common.settings.SecureString;
|
|
|
import org.elasticsearch.common.settings.SettingsException;
|
|
|
+import org.elasticsearch.common.util.concurrent.ReleasableLock;
|
|
|
import org.elasticsearch.common.util.concurrent.ThreadContext;
|
|
|
import org.elasticsearch.core.Releasable;
|
|
|
import org.elasticsearch.core.TimeValue;
|
|
@@ -30,16 +34,20 @@ import org.elasticsearch.xpack.core.security.authc.RealmSettings;
|
|
|
import org.elasticsearch.xpack.core.security.authc.jwt.JwtRealmSettings;
|
|
|
import org.elasticsearch.xpack.core.security.authc.support.CachingRealm;
|
|
|
import org.elasticsearch.xpack.core.security.authc.support.UserRoleMapper;
|
|
|
+import org.elasticsearch.xpack.core.security.support.CacheIteratorHelper;
|
|
|
import org.elasticsearch.xpack.core.security.user.User;
|
|
|
import org.elasticsearch.xpack.core.ssl.SSLService;
|
|
|
+import org.elasticsearch.xpack.security.authc.BytesKey;
|
|
|
import org.elasticsearch.xpack.security.authc.support.ClaimParser;
|
|
|
import org.elasticsearch.xpack.security.authc.support.DelegatedAuthorizationSupport;
|
|
|
|
|
|
import java.io.IOException;
|
|
|
import java.net.URI;
|
|
|
import java.nio.charset.StandardCharsets;
|
|
|
+import java.security.MessageDigest;
|
|
|
import java.util.Arrays;
|
|
|
import java.util.Collections;
|
|
|
+import java.util.Date;
|
|
|
import java.util.List;
|
|
|
import java.util.Map;
|
|
|
|
|
@@ -50,6 +58,8 @@ import java.util.Map;
|
|
|
public class JwtRealm extends Realm implements CachingRealm, Releasable {
|
|
|
private static final Logger LOGGER = LogManager.getLogger(JwtRealm.class);
|
|
|
|
|
|
+ record ExpiringUser(User user, Date exp) {}
|
|
|
+
|
|
|
record JwksAlgs(List<JWK> jwks, List<String> algs) {
|
|
|
boolean isEmpty() {
|
|
|
return jwks.isEmpty() && algs.isEmpty();
|
|
@@ -77,6 +87,8 @@ public class JwtRealm extends Realm implements CachingRealm, Releasable {
|
|
|
final ClaimParser claimParserName;
|
|
|
final JwtRealmSettings.ClientAuthenticationType clientAuthenticationType;
|
|
|
final SecureString clientAuthenticationSharedSecret;
|
|
|
+ final Cache<BytesKey, ExpiringUser> jwtCache;
|
|
|
+ final CacheIteratorHelper<BytesKey, ExpiringUser> jwtCacheHelper;
|
|
|
DelegatedAuthorizationSupport delegatedAuthorizationSupport = null;
|
|
|
|
|
|
public JwtRealm(final RealmConfig realmConfig, final SSLService sslService, final UserRoleMapper userRoleMapper)
|
|
@@ -96,6 +108,8 @@ public class JwtRealm extends Realm implements CachingRealm, Releasable {
|
|
|
this.clientAuthenticationType = realmConfig.getSetting(JwtRealmSettings.CLIENT_AUTHENTICATION_TYPE);
|
|
|
final SecureString sharedSecret = realmConfig.getSetting(JwtRealmSettings.CLIENT_AUTHENTICATION_SHARED_SECRET);
|
|
|
this.clientAuthenticationSharedSecret = Strings.hasText(sharedSecret) ? sharedSecret : null; // convert "" to null
|
|
|
+ this.jwtCache = this.buildJwtCache();
|
|
|
+ this.jwtCacheHelper = (this.jwtCache == null) ? null : new CacheIteratorHelper<>(this.jwtCache);
|
|
|
|
|
|
// Validate Client Authentication settings. Throw SettingsException there was a problem.
|
|
|
JwtUtil.validateClientAuthenticationSettings(
|
|
@@ -143,6 +157,15 @@ public class JwtRealm extends Realm implements CachingRealm, Releasable {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ private Cache<BytesKey, ExpiringUser> buildJwtCache() {
|
|
|
+ final TimeValue jwtCacheTtl = super.config.getSetting(JwtRealmSettings.JWT_CACHE_TTL);
|
|
|
+ final int jwtCacheSize = super.config.getSetting(JwtRealmSettings.JWT_CACHE_SIZE);
|
|
|
+ if ((jwtCacheTtl.getNanos() > 0) && (jwtCacheSize > 0)) {
|
|
|
+ return CacheBuilder.<BytesKey, ExpiringUser>builder().setExpireAfterWrite(jwtCacheTtl).setMaximumWeight(jwtCacheSize).build();
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
// must call parseAlgsAndJwksHmac() before parseAlgsAndJwksPkc()
|
|
|
private JwtRealm.JwksAlgs parseJwksAlgsHmac() {
|
|
|
final JwtRealm.JwksAlgs jwksAlgsHmac;
|
|
@@ -252,8 +275,19 @@ public class JwtRealm extends Realm implements CachingRealm, Releasable {
|
|
|
this.delegatedAuthorizationSupport = new DelegatedAuthorizationSupport(allRealms, super.config, xpackLicenseState);
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * Clean up JWT cache (if enabled).
|
|
|
+ * Clean up HTTPS client cache (if enabled).
|
|
|
+ */
|
|
|
@Override
|
|
|
public void close() {
|
|
|
+ if (this.jwtCache != null) {
|
|
|
+ try {
|
|
|
+ this.jwtCache.invalidateAll();
|
|
|
+ } catch (Exception e) {
|
|
|
+ LOGGER.warn("Exception invalidating JWT cache for realm [" + super.name() + "]", e);
|
|
|
+ }
|
|
|
+ }
|
|
|
if (this.httpClient != null) {
|
|
|
try {
|
|
|
this.httpClient.close();
|
|
@@ -272,11 +306,21 @@ public class JwtRealm extends Realm implements CachingRealm, Releasable {
|
|
|
@Override
|
|
|
public void expire(final String username) {
|
|
|
this.ensureInitialized();
|
|
|
+ LOGGER.trace("Expiring JWT cache entries for realm [" + super.name() + "] principal=[" + username + "]");
|
|
|
+ if (this.jwtCacheHelper != null) {
|
|
|
+ this.jwtCacheHelper.removeValuesIf(expiringUser -> expiringUser.user.principal().equals(username));
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
@Override
|
|
|
public void expireAll() {
|
|
|
this.ensureInitialized();
|
|
|
+ if ((this.jwtCache != null) && (this.jwtCacheHelper != null)) {
|
|
|
+ LOGGER.trace("Invalidating JWT cache for realm [" + super.name() + "]");
|
|
|
+ try (ReleasableLock ignored = this.jwtCacheHelper.acquireUpdateLock()) {
|
|
|
+ this.jwtCache.invalidateAll();
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
@Override
|
|
@@ -321,22 +365,64 @@ public class JwtRealm extends Realm implements CachingRealm, Releasable {
|
|
|
return; // FAILED (secret is missing or mismatched)
|
|
|
}
|
|
|
|
|
|
- // Parse JWT: Extract claims for logs and role-mapping.
|
|
|
+ // JWT cache
|
|
|
final SecureString serializedJwt = jwtAuthenticationToken.getEndUserSignedJwt();
|
|
|
+ final BytesKey jwtCacheKey = (this.jwtCache == null) ? null : computeBytesKey(serializedJwt);
|
|
|
+ if (jwtCacheKey != null) {
|
|
|
+ final ExpiringUser expiringUser = this.jwtCache.get(jwtCacheKey);
|
|
|
+ if (expiringUser == null) {
|
|
|
+ LOGGER.trace("Realm [" + super.name() + "] JWT cache miss token=[" + tokenPrincipal + "] key=[" + jwtCacheKey + "].");
|
|
|
+ } else {
|
|
|
+ final User user = expiringUser.user;
|
|
|
+ final Date exp = expiringUser.exp; // claimsSet.getExpirationTime().getTime() + this.allowedClockSkew.getMillis()
|
|
|
+ final String principal = user.principal();
|
|
|
+ final Date now = new Date();
|
|
|
+ if (now.getTime() < exp.getTime()) {
|
|
|
+ LOGGER.trace(
|
|
|
+ "Realm ["
|
|
|
+ + super.name()
|
|
|
+ + "] JWT cache hit token=["
|
|
|
+ + tokenPrincipal
|
|
|
+ + "] key=["
|
|
|
+ + jwtCacheKey
|
|
|
+ + "] principal=["
|
|
|
+ + principal
|
|
|
+ + "] exp=["
|
|
|
+ + exp
|
|
|
+ + "] now=["
|
|
|
+ + now
|
|
|
+ + "]."
|
|
|
+ );
|
|
|
+ if (this.delegatedAuthorizationSupport.hasDelegation()) {
|
|
|
+ this.delegatedAuthorizationSupport.resolve(principal, listener);
|
|
|
+ } else {
|
|
|
+ listener.onResponse(AuthenticationResult.success(user));
|
|
|
+ }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ LOGGER.trace(
|
|
|
+ "Realm ["
|
|
|
+ + super.name()
|
|
|
+ + "] JWT cache exp token=["
|
|
|
+ + tokenPrincipal
|
|
|
+ + "] key=["
|
|
|
+ + jwtCacheKey
|
|
|
+ + "] principal=["
|
|
|
+ + principal
|
|
|
+ + "] exp=["
|
|
|
+ + exp
|
|
|
+ + "] now=["
|
|
|
+ + now
|
|
|
+ + "]."
|
|
|
+ );
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Validate JWT: Extract JWT and claims set, and validate JWT.
|
|
|
final SignedJWT jwt;
|
|
|
final JWTClaimsSet claimsSet;
|
|
|
try {
|
|
|
jwt = SignedJWT.parse(serializedJwt.toString());
|
|
|
- claimsSet = jwt.getJWTClaimsSet();
|
|
|
- } catch (Exception e) {
|
|
|
- final String msg = "Realm [" + super.name() + "] JWT parse failed for token=[" + tokenPrincipal + "].";
|
|
|
- LOGGER.debug(msg);
|
|
|
- listener.onResponse(AuthenticationResult.unsuccessful(msg, e));
|
|
|
- return; // FAILED (JWT parse fail or regex parse fail)
|
|
|
- }
|
|
|
-
|
|
|
- // Validate JWT
|
|
|
- try {
|
|
|
final String jwtAlg = jwt.getHeader().getAlgorithm().getName();
|
|
|
final boolean isJwtAlgHmac = JwtRealmSettings.SUPPORTED_SIGNATURE_ALGORITHMS_HMAC.contains(jwtAlg);
|
|
|
final JwtRealm.JwksAlgs jwksAndAlgs = isJwtAlgHmac ? this.jwksAlgsHmac : this.jwksAlgsPkc;
|
|
@@ -348,6 +434,7 @@ public class JwtRealm extends Realm implements CachingRealm, Releasable {
|
|
|
jwksAndAlgs.algs,
|
|
|
jwksAndAlgs.jwks
|
|
|
);
|
|
|
+ claimsSet = jwt.getJWTClaimsSet();
|
|
|
LOGGER.trace("Realm [" + super.name() + "] JWT validation succeeded for token=[" + tokenPrincipal + "].");
|
|
|
} catch (Exception e) {
|
|
|
final String msg = "Realm [" + super.name() + "] JWT validation failed for token=[" + tokenPrincipal + "].";
|
|
@@ -375,23 +462,25 @@ public class JwtRealm extends Realm implements CachingRealm, Releasable {
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
- // Delegated role lookup: If enabled, lookup in authz realms. Otherwise, fall through to JWT realm role mapping.
|
|
|
- if (this.delegatedAuthorizationSupport.hasDelegation()) {
|
|
|
- this.delegatedAuthorizationSupport.resolve(principal, ActionListener.wrap(result -> {
|
|
|
- if (result.isAuthenticated()) {
|
|
|
- // Intercept the delegated authorization listener response to log roles. Empty roles is OK.
|
|
|
- final User user = result.getValue();
|
|
|
- final String rolesString = Arrays.toString(user.roles());
|
|
|
- LOGGER.debug(
|
|
|
- "Realm [" + super.name() + "] delegated roles [" + rolesString + "] for principal=[" + principal + "]."
|
|
|
- );
|
|
|
+ // Roles listener: Log roles from delegated authz lookup or role mapping, and cache User if JWT cache is enabled.
|
|
|
+ final ActionListener<AuthenticationResult<User>> logAndCacheListener = ActionListener.wrap(result -> {
|
|
|
+ if (result.isAuthenticated()) {
|
|
|
+ final User user = result.getValue();
|
|
|
+ final String rolesString = Arrays.toString(user.roles());
|
|
|
+ LOGGER.debug("Realm [" + super.name() + "] roles [" + rolesString + "] for principal=[" + principal + "].");
|
|
|
+ if ((this.jwtCache != null) && (this.jwtCacheHelper != null)) {
|
|
|
+ try (ReleasableLock ignored = this.jwtCacheHelper.acquireUpdateLock()) {
|
|
|
+ final long expWallClockMillis = claimsSet.getExpirationTime().getTime() + this.allowedClockSkew.getMillis();
|
|
|
+ this.jwtCache.put(jwtCacheKey, new ExpiringUser(result.getValue(), new Date(expWallClockMillis)));
|
|
|
+ }
|
|
|
}
|
|
|
- listener.onResponse(result);
|
|
|
- }, e -> {
|
|
|
- final String msg = "Realm [" + super.name() + "] delegated roles failed for principal=[" + principal + "].";
|
|
|
- LOGGER.warn(msg, e);
|
|
|
- listener.onResponse(AuthenticationResult.unsuccessful(msg, e));
|
|
|
- }));
|
|
|
+ }
|
|
|
+ listener.onResponse(result);
|
|
|
+ }, listener::onFailure);
|
|
|
+
|
|
|
+ // Delegated role lookup or Role mapping: Use the above listener to log roles and cache User.
|
|
|
+ if (this.delegatedAuthorizationSupport.hasDelegation()) {
|
|
|
+ this.delegatedAuthorizationSupport.resolve(principal, logAndCacheListener);
|
|
|
return;
|
|
|
}
|
|
|
|
|
@@ -415,13 +504,8 @@ public class JwtRealm extends Realm implements CachingRealm, Releasable {
|
|
|
final UserRoleMapper.UserData userData = new UserRoleMapper.UserData(principal, dn, groups, userMetadata, super.config);
|
|
|
this.userRoleMapper.resolveRoles(userData, ActionListener.wrap(rolesSet -> {
|
|
|
final User user = new User(principal, rolesSet.toArray(Strings.EMPTY_ARRAY), name, mail, userData.getMetadata(), true);
|
|
|
- LOGGER.debug("Realm [" + super.name() + "] roles " + String.join(",", rolesSet) + " for principal=[" + principal + "].");
|
|
|
- listener.onResponse(AuthenticationResult.success(user));
|
|
|
- }, e -> {
|
|
|
- final String msg = "Realm [" + super.name() + "] roles failed for principal=[" + principal + "].";
|
|
|
- LOGGER.warn(msg, e);
|
|
|
- listener.onResponse(AuthenticationResult.unsuccessful(msg, e));
|
|
|
- }));
|
|
|
+ logAndCacheListener.onResponse(AuthenticationResult.success(user));
|
|
|
+ }, logAndCacheListener::onFailure));
|
|
|
} else {
|
|
|
final String className = (authenticationToken == null) ? "null" : authenticationToken.getClass().getCanonicalName();
|
|
|
final String msg = "Realm [" + super.name() + "] does not support AuthenticationToken [" + className + "].";
|
|
@@ -433,6 +517,15 @@ public class JwtRealm extends Realm implements CachingRealm, Releasable {
|
|
|
@Override
|
|
|
public void usageStats(final ActionListener<Map<String, Object>> listener) {
|
|
|
this.ensureInitialized();
|
|
|
- super.usageStats(ActionListener.wrap(listener::onResponse, listener::onFailure));
|
|
|
+ super.usageStats(ActionListener.wrap(stats -> {
|
|
|
+ stats.put("jwt.cache", Collections.singletonMap("size", this.jwtCache == null ? -1 : this.jwtCache.count()));
|
|
|
+ listener.onResponse(stats);
|
|
|
+ }, listener::onFailure));
|
|
|
+ }
|
|
|
+
|
|
|
+ static BytesKey computeBytesKey(final CharSequence charSequence) {
|
|
|
+ final MessageDigest messageDigest = MessageDigests.sha256();
|
|
|
+ messageDigest.update(charSequence.toString().getBytes(StandardCharsets.UTF_8));
|
|
|
+ return new BytesKey(messageDigest.digest());
|
|
|
}
|
|
|
}
|