KEYCLOAK-18368 Invalidate client session after refresh token re-use

This commit is contained in:
mposolda 2021-06-02 07:53:09 +02:00 committed by Marek Posolda
parent ead667aaac
commit 91865fa93e
9 changed files with 194 additions and 31 deletions

View file

@ -148,6 +148,15 @@ public class JsonWebToken implements Serializable, Token {
return !isExpired() && isNotBefore(allowedTimeSkew);
}
/**
* @param sessionStarted Time in seconds
* @return true if the particular token was issued before the given session start time. Which means that token cannot be issued by the particular session
*/
@JsonIgnore
public boolean isIssuedBeforeSessionStart(long sessionStarted) {
return getIat() + 1 < sessionStarted;
}
public Long getIat() {
return iat;
}

View file

@ -188,6 +188,7 @@ public class InfinispanUserSessionProvider implements UserSessionProvider {
AuthenticatedClientSessionEntity entity = new AuthenticatedClientSessionEntity(clientSessionId);
entity.setRealmId(realm.getId());
entity.setTimestamp(Time.currentTime());
entity.getNotes().put(AuthenticatedClientSessionModel.STARTED_AT_NOTE, String.valueOf(entity.getTimestamp()));
InfinispanChangelogBasedTransaction<String, UserSessionEntity> userSessionUpdateTx = getTransaction(false);
InfinispanChangelogBasedTransaction<UUID, AuthenticatedClientSessionEntity> clientSessionUpdateTx = getClientSessionTransaction(false);
@ -773,6 +774,7 @@ public class InfinispanUserSessionProvider implements UserSessionProvider {
// update timestamp to current time
offlineClientSession.setTimestamp(Time.currentTime());
offlineClientSession.getNotes().put(AuthenticatedClientSessionModel.STARTED_AT_NOTE, String.valueOf(offlineClientSession.getTimestamp()));
session.getProvider(UserSessionPersisterProvider.class).createClientSession(clientSession, true);

View file

@ -157,6 +157,7 @@ public class MapUserSessionProvider<UK, CK> implements UserSessionProvider {
public AuthenticatedClientSessionModel createClientSession(RealmModel realm, ClientModel client, UserSessionModel userSession) {
MapAuthenticatedClientSessionEntity<CK> entity =
new MapAuthenticatedClientSessionEntity<>(clientSessionStore.getKeyConvertor().yieldNewUniqueKey(), userSession.getId(), realm.getId(), client.getId(), false);
entity.getNotes().put(AuthenticatedClientSessionModel.STARTED_AT_NOTE, String.valueOf(entity.getTimestamp()));
setClientSessionExpiration(entity, realm, client);
LOG.tracef("createClientSession(%s, %s, %s)%s", realm, client, userSession, getShortStackTrace());
@ -472,7 +473,9 @@ public class MapUserSessionProvider<UK, CK> implements UserSessionProvider {
LOG.tracef("createOfflineClientSession(%s, %s)%s", clientSession, offlineUserSession, getShortStackTrace());
MapAuthenticatedClientSessionEntity<CK> clientSessionEntity = createAuthenticatedClientSessionInstance(clientSession, offlineUserSession, true);
clientSessionEntity.setTimestamp(Time.currentTime());
int currentTime = Time.currentTime();
clientSessionEntity.getNotes().put(AuthenticatedClientSessionModel.STARTED_AT_NOTE, String.valueOf(currentTime));
clientSessionEntity.setTimestamp(currentTime);
setClientSessionExpiration(clientSessionEntity, clientSession.getRealm(), clientSession.getClient());
Optional<MapUserSessionEntity<UK>> userSessionEntity = getOfflineUserSessionEntityStream(clientSession.getRealm(), offlineUserSession.getId()).findFirst();

View file

@ -37,8 +37,16 @@ public interface AuthenticatedClientSessionModel extends CommonClientSessionMode
public static final SearchableModelField<AuthenticatedClientSessionModel> TIMESTAMP = new SearchableModelField<>("timestamp", Integer.class);
}
String STARTED_AT_NOTE = "startedAt";
String getId();
default int getStarted() {
String started = getNote(STARTED_AT_NOTE);
// Fallback to 0 if "started" note is not available. This can happen for the offline sessions migrated from old version where "startedAt" note was not yet available
return started == null ? 0 : Integer.parseInt(started);
}
int getTimestamp();
void setTimestamp(int timestamp);

View file

@ -67,6 +67,7 @@ import org.keycloak.representations.RefreshToken;
import org.keycloak.services.ErrorResponseException;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.managers.AuthenticationSessionManager;
import org.keycloak.services.managers.ResourceAdminManager;
import org.keycloak.services.managers.UserSessionCrossDCManager;
import org.keycloak.services.managers.UserSessionManager;
import org.keycloak.services.resources.IdentityBrokerService;
@ -155,7 +156,7 @@ public class TokenManager {
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "User disabled", "User disabled");
}
if (oldToken.getIssuedAt() + 1 < userSession.getStarted()) {
if (oldToken.isIssuedBeforeSessionStart(userSession.getStarted())) {
logger.debug("Refresh toked issued before the user session started");
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Refresh toked issued before the user session started");
}
@ -174,6 +175,11 @@ public class TokenManager {
}
}
if (oldToken.isIssuedBeforeSessionStart(clientSession.getStarted())) {
logger.debug("Refresh toked issued before the client session started");
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Refresh toked issued before the client session started");
}
if (!client.getClientId().equals(oldToken.getIssuedFor())) {
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Unmatching clients", "Unmatching clients");
}
@ -259,10 +265,17 @@ public class TokenManager {
}
}
if (valid && (token.getIssuedAt() + 1 < userSession.getStarted())) {
if (valid && (token.isIssuedBeforeSessionStart(userSession.getStarted()))) {
valid = false;
}
AuthenticatedClientSessionModel clientSession = userSession == null ? null : userSession.getAuthenticatedClientSessionByClient(client.getId());
if (clientSession != null) {
if (valid && (token.isIssuedBeforeSessionStart(clientSession.getStarted()))) {
valid = false;
}
}
String tokenType = token.getType();
if (realm.isRevokeRefreshToken()
&& (tokenType.equals(TokenUtil.TOKEN_TYPE_REFRESH) || tokenType.equals(TokenUtil.TOKEN_TYPE_OFFLINE))
@ -273,7 +286,6 @@ public class TokenManager {
if (valid) {
int currentTime = Time.currentTime();
userSession.setLastSessionRefresh(currentTime);
AuthenticatedClientSessionModel clientSession = userSession.getAuthenticatedClientSessionByClient(client.getId());
if (clientSession != null) {
clientSession.setTimestamp(currentTime);
}
@ -296,7 +308,8 @@ public class TokenManager {
AuthenticatedClientSessionModel clientSession = userSession.getAuthenticatedClientSessionByClient(client.getId());
try {
return validateTokenReuse(session, realm, token, clientSession, false);
validateTokenReuse(session, realm, token, clientSession, false);
return true;
} catch (OAuthErrorException e) {
return false;
}
@ -403,14 +416,23 @@ public class TokenManager {
TokenValidation validation) throws OAuthErrorException {
if (realm.isRevokeRefreshToken()) {
AuthenticatedClientSessionModel clientSession = validation.clientSessionCtx.getClientSession();
if (validateTokenReuse(session, realm, refreshToken, clientSession, true)) {
try {
validateTokenReuse(session, realm, refreshToken, clientSession, true);
int currentCount = clientSession.getCurrentRefreshTokenUseCount();
clientSession.setCurrentRefreshTokenUseCount(currentCount + 1);
} catch (OAuthErrorException oee) {
if (logger.isDebugEnabled()) {
logger.debugf("Failed validation of refresh token %s due it was used before. Realm: %s, client: %s, user: %s, user session: %s. Will detach client session from user session",
refreshToken.getId(), realm.getName(), clientSession.getClient().getClientId(), clientSession.getUserSession().getUser().getUsername(), clientSession.getUserSession().getId());
}
clientSession.detachFromUserSession();
throw oee;
}
}
}
private boolean validateTokenReuse(KeycloakSession session, RealmModel realm, AccessToken refreshToken,
// Will throw OAuthErrorException if validation fails
private void validateTokenReuse(KeycloakSession session, RealmModel realm, AccessToken refreshToken,
AuthenticatedClientSessionModel clientSession, boolean refreshFlag) throws OAuthErrorException {
int clusterStartupTime = session.getProvider(ClusterProvider.class).getClusterStartupTime();
@ -426,7 +448,7 @@ public class TokenManager {
clientSession.setCurrentRefreshToken(refreshToken.getId());
clientSession.setCurrentRefreshTokenUseCount(0);
} else {
return true;
return;
}
}
@ -435,7 +457,7 @@ public class TokenManager {
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Maximum allowed refresh token reuse exceeded",
"Maximum allowed refresh token reuse exceeded");
}
return true;
return;
}
public RefreshToken verifyRefreshToken(KeycloakSession session, RealmModel realm, ClientModel client, HttpRequest request, String encodedRefreshToken, boolean checkExpiration) throws OAuthErrorException {

View file

@ -287,13 +287,13 @@ public class UserInfoEndpoint {
UserSessionModel userSession = new UserSessionCrossDCManager(session).getUserSessionWithClient(realm, token.getSessionState(), false, client.getId());
UserSessionModel offlineUserSession = null;
if (AuthenticationManager.isSessionValid(realm, userSession)) {
checkTokenIssuedAt(token, userSession, event);
checkTokenIssuedAt(token, userSession, event, client);
event.session(userSession);
return userSession;
} else {
offlineUserSession = new UserSessionCrossDCManager(session).getUserSessionWithClient(realm, token.getSessionState(), true, client.getId());
if (AuthenticationManager.isOfflineSessionValid(realm, offlineUserSession)) {
checkTokenIssuedAt(token, offlineUserSession, event);
checkTokenIssuedAt(token, offlineUserSession, event, client);
event.session(offlineUserSession);
return offlineUserSession;
}
@ -314,8 +314,14 @@ public class UserInfoEndpoint {
throw newUnauthorizedErrorResponseException(OAuthErrorException.INVALID_TOKEN, "Session expired");
}
private void checkTokenIssuedAt(AccessToken token, UserSessionModel userSession, EventBuilder event) throws CorsErrorResponseException {
if (token.getIssuedAt() + 1 < userSession.getStarted()) {
private void checkTokenIssuedAt(AccessToken token, UserSessionModel userSession, EventBuilder event, ClientModel client) throws CorsErrorResponseException {
if (token.isIssuedBeforeSessionStart(userSession.getStarted())) {
event.error(Errors.INVALID_TOKEN);
throw newUnauthorizedErrorResponseException(OAuthErrorException.INVALID_TOKEN, "Stale token");
}
AuthenticatedClientSessionModel clientSession = userSession.getAuthenticatedClientSessionByClient(client.getId());
if (token.isIssuedBeforeSessionStart(clientSession.getStarted())) {
event.error(Errors.INVALID_TOKEN);
throw newUnauthorizedErrorResponseException(OAuthErrorException.INVALID_TOKEN, "Stale token");
}

View file

@ -105,10 +105,10 @@ public class LastSessionRefreshCrossDCTest extends AbstractAdminCrossDCTest {
Assert.assertNull("Expecting no access token present", tokenResponse.getAccessToken());
Assert.assertNotNull(tokenResponse.getError());
// try refresh with new token on DC2. It should pass.
// try refresh with new token on DC2. It should fail because client session not valid anymore
tokenResponse = oauth.doRefreshTokenRequest(refreshToken2, "password");
Assert.assertNotNull(tokenResponse.getAccessToken());
Assert.assertNull(tokenResponse.getError());
Assert.assertNull("Expecting no access token present", tokenResponse.getAccessToken());
Assert.assertNotNull(tokenResponse.getError());
// Revert
realmRep = testRealm().toRepresentation();

View file

@ -392,8 +392,15 @@ public class OfflineTokenTest extends AbstractKeycloakTest {
.clearDetails()
.assertEvent();
// Refresh with new refreshToken is successful now
testRefreshWithOfflineToken(token, offlineToken2, offlineTokenString2, offlineToken2.getSessionState(), userId);
// Refresh with new refreshToken fails as well (client session was invalidated because of attempt to refresh with revoked refresh token)
OAuthClient.AccessTokenResponse response2 = oauth.doRefreshTokenRequest(offlineTokenString2, "secret1");
Assert.assertEquals(400, response2.getStatusCode());
events.expectRefresh(offlineToken2.getId(), offlineToken2.getSessionState())
.client("offline-client")
.error(Errors.INVALID_TOKEN)
.user(userId)
.clearDetails()
.assertEvent();
RealmManager.realm(adminClient.realm("test")).revokeRefreshToken(false);
}

View file

@ -16,6 +16,7 @@
*/
package org.keycloak.testsuite.oauth;
import com.fasterxml.jackson.databind.JsonNode;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.jboss.arquillian.graphene.page.Page;
import org.junit.Assert;
@ -24,6 +25,7 @@ import org.junit.BeforeClass;
import org.junit.Rule;
import org.junit.Test;
import org.keycloak.OAuth2Constants;
import org.keycloak.OAuthErrorException;
import org.keycloak.admin.client.resource.ClientResource;
import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.admin.client.resource.UserResource;
@ -31,6 +33,7 @@ import org.keycloak.common.enums.SslRequired;
import org.keycloak.crypto.Algorithm;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventType;
import org.keycloak.jose.jws.JWSHeader;
import org.keycloak.jose.jws.JWSInput;
import org.keycloak.models.RealmModel;
@ -42,9 +45,11 @@ import org.keycloak.protocol.oidc.OIDCLoginProtocolService;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.IDToken;
import org.keycloak.representations.RefreshToken;
import org.keycloak.representations.UserInfo;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.EventRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.UserSessionRepresentation;
import org.keycloak.testsuite.AbstractKeycloakTest;
import org.keycloak.testsuite.AssertEvents;
import org.keycloak.testsuite.admin.ApiUtil;
@ -61,6 +66,7 @@ import org.keycloak.testsuite.util.TokenSignatureUtil;
import org.keycloak.testsuite.util.UserManager;
import org.keycloak.testsuite.util.WaitUtils;
import org.keycloak.util.BasicAuthHelper;
import org.keycloak.util.JsonSerialization;
import javax.ws.rs.client.Client;
import javax.ws.rs.client.Entity;
@ -83,6 +89,7 @@ import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import static org.keycloak.protocol.oidc.OIDCConfigAttributes.CLIENT_SESSION_IDLE_TIMEOUT;
import static org.keycloak.protocol.oidc.OIDCConfigAttributes.CLIENT_SESSION_MAX_LIFESPAN;
import static org.keycloak.testsuite.Assert.assertExpiration;
@ -388,10 +395,11 @@ public class RefreshTokenTest extends AbstractKeycloakTest {
events.expectRefresh(refreshToken1.getId(), sessionId).removeDetail(Details.TOKEN_ID).removeDetail(Details.UPDATED_REFRESH_TOKEN_ID).error("invalid_token").assertEvent();
// Client session invalidated hence old refresh token not valid anymore
setTimeOffset(6);
oauth.doRefreshTokenRequest(response2.getRefreshToken(), "password");
events.expectRefresh(refreshToken2.getId(), sessionId).assertEvent();
OAuthClient.AccessTokenResponse response4 = oauth.doRefreshTokenRequest(response2.getRefreshToken(), "password");
assertEquals(400, response4.getStatusCode());
events.expectRefresh(refreshToken2.getId(), sessionId).removeDetail(Details.TOKEN_ID).removeDetail(Details.UPDATED_REFRESH_TOKEN_ID).error("invalid_token").assertEvent();
} finally {
setTimeOffset(0);
RealmManager.realm(adminClient.realm("test")).revokeRefreshToken(false);
@ -459,13 +467,14 @@ public class RefreshTokenTest extends AbstractKeycloakTest {
.removeDetail(Details.UPDATED_REFRESH_TOKEN_ID).error("invalid_token").assertEvent();
setTimeOffset(10);
// Refresh token from reuse is still valid.
// Refresh token from reuse is not valid. Client session was invalidated
OAuthClient.AccessTokenResponse responseUseOfValidRefreshToken =
oauth.doRefreshTokenRequest(responseFirstReuse.getRefreshToken(), "password");
assertEquals(200, responseUseOfValidRefreshToken.getStatusCode());
assertEquals(400, responseUseOfValidRefreshToken.getStatusCode());
events.expectRefresh(newTokenFirstReuse.getId(), sessionId).assertEvent();
events.expectRefresh(newTokenFirstReuse.getId(), sessionId).removeDetail(Details.TOKEN_ID)
.removeDetail(Details.UPDATED_REFRESH_TOKEN_ID).error("invalid_token").assertEvent();
} finally {
setTimeOffset(0);
RealmManager.realm(adminClient.realm("test"))
@ -551,10 +560,11 @@ public class RefreshTokenTest extends AbstractKeycloakTest {
RealmManager.realm(adminClient.realm("test")).revokeRefreshToken(false);
// Config changed, token can be reused again
processExpectedValidRefresh(sessionId, initialRefreshToken, initialResponse.getRefreshToken());
processExpectedValidRefresh(sessionId, initialRefreshToken, initialResponse.getRefreshToken());
processExpectedValidRefresh(sessionId, initialRefreshToken, initialResponse.getRefreshToken());
// Config changed, token cannot be used again at this point due the client session invalidated
OAuthClient.AccessTokenResponse responseReuseExceeded2 = oauth.doRefreshTokenRequest(initialResponse.getRefreshToken(), "password");
assertEquals(400, responseReuseExceeded2.getStatusCode());
events.expectRefresh(initialRefreshToken.getId(), sessionId).removeDetail(Details.TOKEN_ID)
.removeDetail(Details.UPDATED_REFRESH_TOKEN_ID).error("invalid_token").assertEvent();
} finally {
setTimeOffset(0);
RealmManager.realm(adminClient.realm("test"))
@ -563,6 +573,105 @@ public class RefreshTokenTest extends AbstractKeycloakTest {
}
}
// Doublecheck that with "revokeRefreshToken" and revoked tokens, the SSO re-authentication won't cause old tokens to be valid again
@Test
public void refreshTokenReuseTokenWithRefreshTokensRevokedAndSSOReauthentication() throws Exception {
try {
// Initial login
RealmManager.realm(adminClient.realm("test")).revokeRefreshToken(true);
oauth.doLogin("test-user@localhost", "password");
EventRepresentation loginEvent = events.expectLogin().assertEvent();
String sessionId = loginEvent.getSessionId();
String codeId = loginEvent.getDetails().get(Details.CODE_ID);
String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
OAuthClient.AccessTokenResponse response1 = oauth.doAccessTokenRequest(code, "password");
RefreshToken refreshToken1 = oauth.parseRefreshToken(response1.getRefreshToken());
events.expectCodeToToken(codeId, sessionId).assertEvent();
// Refresh token for the first time - should pass
setTimeOffset(2);
OAuthClient.AccessTokenResponse response2 = oauth.doRefreshTokenRequest(response1.getRefreshToken(), "password");
RefreshToken refreshToken2 = oauth.parseRefreshToken(response2.getRefreshToken());
assertEquals(200, response2.getStatusCode());
events.expectRefresh(refreshToken1.getId(), sessionId).assertEvent();
// Client sessions is available now
Assert.assertTrue(hasClientSessionForTestApp());
// Refresh token for the second time - should fail and invalidate client session
setTimeOffset(4);
OAuthClient.AccessTokenResponse response3 = oauth.doRefreshTokenRequest(response1.getRefreshToken(), "password");
assertEquals(400, response3.getStatusCode());
events.expectRefresh(refreshToken1.getId(), sessionId).removeDetail(Details.TOKEN_ID).removeDetail(Details.UPDATED_REFRESH_TOKEN_ID).error("invalid_token").assertEvent();
// No client sessions available after revoke
Assert.assertFalse(hasClientSessionForTestApp());
// Introspection with the accessToken from the first authentication. This should fail
String introspectionResponse = oauth.introspectAccessTokenWithClientCredential("test-app", "password", response1.getAccessToken());
JsonNode jsonNode = JsonSerialization.mapper.readTree(introspectionResponse);
Assert.assertFalse(jsonNode.get("active").asBoolean());
events.clear();
// SSO re-authentication
setTimeOffset(6);
oauth.openLoginForm();
loginEvent = events.expectLogin().assertEvent();
sessionId = loginEvent.getSessionId();
codeId = loginEvent.getDetails().get(Details.CODE_ID);
code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
OAuthClient.AccessTokenResponse response4 = oauth.doAccessTokenRequest(code, "password");
RefreshToken refreshToken4 = oauth.parseRefreshToken(response4.getRefreshToken());
events.expectCodeToToken(codeId, sessionId).assertEvent();
// Client sessions should be available again now after re-authentication
Assert.assertTrue(hasClientSessionForTestApp());
// Introspection again with the accessToken from the very first authentication. This should fail as the access token was obtained for the old client session before SSO re-authentication
introspectionResponse = oauth.introspectAccessTokenWithClientCredential("test-app", "password", response1.getAccessToken());
jsonNode = JsonSerialization.mapper.readTree(introspectionResponse);
Assert.assertFalse(jsonNode.get("active").asBoolean());
// Try userInfo with the same old access token. Should fail as well
UserInfo userInfo = oauth.doUserInfoRequest(response1.getAccessToken());
Assert.assertNull(userInfo.getSubject());
Assert.assertEquals(userInfo.getOtherClaims().get(OAuth2Constants.ERROR), OAuthErrorException.INVALID_TOKEN);
events.clear();
// Try to refresh with one of the old refresh tokens before SSO re-authentication - should fail
setTimeOffset(8);
OAuthClient.AccessTokenResponse response5 = oauth.doRefreshTokenRequest(response2.getRefreshToken(), "password");
assertEquals(400, response5.getStatusCode());
events.expectRefresh(refreshToken2.getId(), sessionId).removeDetail(Details.TOKEN_ID).removeDetail(Details.UPDATED_REFRESH_TOKEN_ID).error("invalid_token").assertEvent();
} finally {
setTimeOffset(0);
RealmManager.realm(adminClient.realm("test")).revokeRefreshToken(false);
}
}
// Returns true if "test-user@localhost" has any user session with client session for "test-app"
private boolean hasClientSessionForTestApp() {
List<UserSessionRepresentation> userSessions = ApiUtil.findUserByUsernameId(adminClient.realm("test"), "test-user@localhost").getUserSessions();
return userSessions.stream()
.anyMatch(userSession -> userSession.getClients().containsValue("test-app"));
}
private void processExpectedValidRefresh(String sessionId, RefreshToken requestToken, String refreshToken) {
OAuthClient.AccessTokenResponse response2 = oauth.doRefreshTokenRequest(refreshToken, "password");
@ -572,9 +681,6 @@ public class RefreshTokenTest extends AbstractKeycloakTest {
}
String privateKey;
String publicKey;
@Test
public void refreshTokenClientDisabled() throws Exception {
oauth.doLogin("test-user@localhost", "password");