KEYCLOAK-4627 Changes in TokenVerifier to include token in exceptions. Reset credentials uses checks to validate individual token aspects

This commit is contained in:
Hynek Mlnarik 2017-03-30 14:05:41 +02:00 committed by mposolda
parent a9ec69e424
commit b55b089355
12 changed files with 599 additions and 310 deletions

View file

@ -32,7 +32,7 @@ public class RSATokenVerifier {
private final TokenVerifier<AccessToken> tokenVerifier; private final TokenVerifier<AccessToken> tokenVerifier;
private RSATokenVerifier(String tokenString) { private RSATokenVerifier(String tokenString) {
this.tokenVerifier = TokenVerifier.create(tokenString, AccessToken.class); this.tokenVerifier = TokenVerifier.create(tokenString, AccessToken.class).withDefaultChecks();
} }
public static RSATokenVerifier create(String tokenString) { public static RSATokenVerifier create(String tokenString) {

View file

@ -33,6 +33,8 @@ import org.keycloak.util.TokenUtil;
import javax.crypto.SecretKey; import javax.crypto.SecretKey;
import java.security.PublicKey; import java.security.PublicKey;
import java.util.*; import java.util.*;
import java.util.logging.Level;
import java.util.logging.Logger;
/** /**
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a> * @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
@ -40,9 +42,15 @@ import java.util.*;
*/ */
public class TokenVerifier<T extends JsonWebToken> { public class TokenVerifier<T extends JsonWebToken> {
private static final Logger LOG = Logger.getLogger(TokenVerifier.class.getName());
// This interface is here as JDK 7 is a requirement for this project. // This interface is here as JDK 7 is a requirement for this project.
// Once JDK 8 would become mandatory, java.util.function.Predicate would be used instead. // Once JDK 8 would become mandatory, java.util.function.Predicate would be used instead.
/**
* Functional interface of checks that verify some part of a JWT.
* @param <T> Type of the token handled by this predicate.
*/
// @FunctionalInterface // @FunctionalInterface
public static interface Predicate<T extends JsonWebToken> { public static interface Predicate<T extends JsonWebToken> {
/** /**
@ -66,11 +74,15 @@ public class TokenVerifier<T extends JsonWebToken> {
} }
}; };
/**
* Check for token being neither expired nor used before it gets valid.
* @see JsonWebToken#isActive()
*/
public static final Predicate<JsonWebToken> IS_ACTIVE = new Predicate<JsonWebToken>() { public static final Predicate<JsonWebToken> IS_ACTIVE = new Predicate<JsonWebToken>() {
@Override @Override
public boolean test(JsonWebToken t) throws VerificationException { public boolean test(JsonWebToken t) throws VerificationException {
if (! t.isActive()) { if (! t.isActive()) {
throw new TokenNotActiveException("Token is not active"); throw new TokenNotActiveException(t, "Token is not active");
} }
return true; return true;
@ -143,29 +155,45 @@ public class TokenVerifier<T extends JsonWebToken> {
} }
/** /**
* Creates a {@code TokenVerifier<AccessToken> instance. The method is here for backwards compatibility. * Creates an instance of {@code TokenVerifier} from the given string on a JWT of the given class.
* @param tokenString * The token verifier has no checks defined. Note that the checks are only tested when
* {@link #verify()} method is invoked.
* @param <T> Type of the token
* @param tokenString String representation of JWT
* @param clazz Class of the token
* @return * @return
* @deprecated use {@link #create(java.lang.String, java.lang.Class) } instead
*/ */
public static TokenVerifier<AccessToken> create(String tokenString) {
return create(tokenString, AccessToken.class);
}
public static <T extends JsonWebToken> TokenVerifier<T> create(String tokenString, Class<T> clazz) { public static <T extends JsonWebToken> TokenVerifier<T> create(String tokenString, Class<T> clazz) {
return new TokenVerifier(tokenString, clazz) return new TokenVerifier(tokenString, clazz);
.check(RealmUrlCheck.NULL_INSTANCE)
.check(SUBJECT_EXISTS_CHECK)
.check(TokenTypeCheck.INSTANCE_BEARER)
.check(IS_ACTIVE);
} }
public static <T extends JsonWebToken> TokenVerifier<T> from(T token) { /**
return new TokenVerifier(token) * Creates an instance of {@code TokenVerifier} from the given string on a JWT of the given class.
.check(RealmUrlCheck.NULL_INSTANCE) * The token verifier has no checks defined. Note that the checks are only tested when
.check(SUBJECT_EXISTS_CHECK) * {@link #verify()} method is invoked.
.check(TokenTypeCheck.INSTANCE_BEARER) * @return
.check(IS_ACTIVE); */
public static <T extends JsonWebToken> TokenVerifier<T> create(T token) {
return new TokenVerifier(token);
}
/**
* Adds default checks to the token verification:
* <ul>
* <li>Realm URL (JWT issuer field: {@code iss}) has to be defined and match realm set via {@link #realmUrl(java.lang.String)} method</li>
* <li>Subject (JWT subject field: {@code sub}) has to be defined</li>
* <li>Token type (JWT type field: {@code typ}) has to be {@code Bearer}. The type can be set via {@link #tokenType(java.lang.String)} method</li>
* <li>Token has to be active, ie. both not expired and not used before its validity (JWT issuer fields: {@code exp} and {@code nbf})</li>
* </ul>
* @return This token verifier.
*/
public TokenVerifier<T> withDefaultChecks() {
return withChecks(
RealmUrlCheck.NULL_INSTANCE,
SUBJECT_EXISTS_CHECK,
TokenTypeCheck.INSTANCE_BEARER,
IS_ACTIVE
);
} }
private void removeCheck(Class<? extends Predicate<?>> checkClass) { private void removeCheck(Class<? extends Predicate<?>> checkClass) {
@ -197,12 +225,11 @@ public class TokenVerifier<T extends JsonWebToken> {
} }
/** /**
* Resets all preset checks and will test the given checks in {@link #verify()} method. * Will test the given checks in {@link #verify()} method in addition to already set checks.
* @param checks * @param checks
* @return * @return
*/ */
public TokenVerifier<T> checkOnly(Predicate<? super T>... checks) { public TokenVerifier<T> withChecks(Predicate<? super T>... checks) {
this.checks.clear();
if (checks != null) { if (checks != null) {
this.checks.addAll(Arrays.asList(checks)); this.checks.addAll(Arrays.asList(checks));
} }
@ -210,46 +237,64 @@ public class TokenVerifier<T extends JsonWebToken> {
} }
/** /**
* Will test the given checks in {@link #verify()} method in addition to already set checks. * Sets the key for verification of RSA-based signature.
* @param checks * @param publicKey
* @return * @return
*/ */
public TokenVerifier<T> check(Predicate<? super T>... checks) {
if (checks != null) {
this.checks.addAll(Arrays.asList(checks));
}
return this;
}
public TokenVerifier<T> publicKey(PublicKey publicKey) { public TokenVerifier<T> publicKey(PublicKey publicKey) {
this.publicKey = publicKey; this.publicKey = publicKey;
return this; return this;
} }
/**
* Sets the key for verification of HMAC-based signature.
* @param secretKey
* @return
*/
public TokenVerifier<T> secretKey(SecretKey secretKey) { public TokenVerifier<T> secretKey(SecretKey secretKey) {
this.secretKey = secretKey; this.secretKey = secretKey;
return this; return this;
} }
/**
* @deprecated This method is here only for backward compatibility with previous version of {@code TokenVerifier}.
* @return This token verifier
*/
public TokenVerifier<T> realmUrl(String realmUrl) { public TokenVerifier<T> realmUrl(String realmUrl) {
this.realmUrl = realmUrl; this.realmUrl = realmUrl;
return replaceCheck(RealmUrlCheck.class, checkRealmUrl, new RealmUrlCheck(realmUrl)); return replaceCheck(RealmUrlCheck.class, checkRealmUrl, new RealmUrlCheck(realmUrl));
} }
/**
* @deprecated This method is here only for backward compatibility with previous version of {@code TokenVerifier}.
* @return This token verifier
*/
public TokenVerifier<T> checkTokenType(boolean checkTokenType) { public TokenVerifier<T> checkTokenType(boolean checkTokenType) {
this.checkTokenType = checkTokenType; this.checkTokenType = checkTokenType;
return replaceCheck(TokenTypeCheck.class, this.checkTokenType, new TokenTypeCheck(expectedTokenType)); return replaceCheck(TokenTypeCheck.class, this.checkTokenType, new TokenTypeCheck(expectedTokenType));
} }
/**
* @deprecated This method is here only for backward compatibility with previous version of {@code TokenVerifier}.
* @return This token verifier
*/
public TokenVerifier<T> tokenType(String tokenType) { public TokenVerifier<T> tokenType(String tokenType) {
this.expectedTokenType = tokenType; this.expectedTokenType = tokenType;
return replaceCheck(TokenTypeCheck.class, this.checkTokenType, new TokenTypeCheck(expectedTokenType)); return replaceCheck(TokenTypeCheck.class, this.checkTokenType, new TokenTypeCheck(expectedTokenType));
} }
/**
* @deprecated This method is here only for backward compatibility with previous version of {@code TokenVerifier}.
* @return This token verifier
*/
public TokenVerifier<T> checkActive(boolean checkActive) { public TokenVerifier<T> checkActive(boolean checkActive) {
return replaceCheck(IS_ACTIVE, checkActive, IS_ACTIVE); return replaceCheck(IS_ACTIVE, checkActive, IS_ACTIVE);
} }
/**
* @deprecated This method is here only for backward compatibility with previous version of {@code TokenVerifier}.
* @return This token verifier
*/
public TokenVerifier<T> checkRealmUrl(boolean checkRealmUrl) { public TokenVerifier<T> checkRealmUrl(boolean checkRealmUrl) {
this.checkRealmUrl = checkRealmUrl; this.checkRealmUrl = checkRealmUrl;
return replaceCheck(RealmUrlCheck.class, this.checkRealmUrl, new RealmUrlCheck(realmUrl)); return replaceCheck(RealmUrlCheck.class, this.checkRealmUrl, new RealmUrlCheck(realmUrl));
@ -300,14 +345,14 @@ public class TokenVerifier<T extends JsonWebToken> {
throw new VerificationException("Public key not set"); throw new VerificationException("Public key not set");
} }
if (!RSAProvider.verify(jws, publicKey)) { if (!RSAProvider.verify(jws, publicKey)) {
throw new TokenSignatureInvalidException("Invalid token signature"); throw new TokenSignatureInvalidException(token, "Invalid token signature");
} break; } break;
case HMAC: case HMAC:
if (secretKey == null) { if (secretKey == null) {
throw new VerificationException("Secret key not set"); throw new VerificationException("Secret key not set");
} }
if (!HMACProvider.verify(jws, secretKey)) { if (!HMACProvider.verify(jws, secretKey)) {
throw new TokenSignatureInvalidException("Invalid token signature"); throw new TokenSignatureInvalidException(token, "Invalid token signature");
} break; } break;
default: default:
throw new VerificationException("Unknown or unsupported token algorithm"); throw new VerificationException("Unknown or unsupported token algorithm");
@ -331,4 +376,55 @@ public class TokenVerifier<T extends JsonWebToken> {
return this; return this;
} }
/**
* Creates an optional predicate from a predicate that will proceed with check but always pass.
* @param <T>
* @param mandatoryPredicate
* @return
*/
public static <T extends JsonWebToken> Predicate<T> optional(final Predicate<T> mandatoryPredicate) {
return new Predicate<T>() {
@Override
public boolean test(T t) throws VerificationException {
try {
if (! mandatoryPredicate.test(t)) {
LOG.finer("[optional] predicate failed: " + mandatoryPredicate);
}
return true;
} catch (VerificationException ex) {
LOG.log(Level.FINER, "[optional] predicate " + mandatoryPredicate + " failed.", ex);
return true;
}
}
};
}
/**
* Creates a predicate that will proceed with checks of the given predicates
* and will pass if and only if at least one of the given predicates passes.
* @param <T>
* @param predicates
* @return
*/
public static <T extends JsonWebToken> Predicate<T> alternative(final Predicate<? super T>... predicates) {
return new Predicate<T>() {
@Override
public boolean test(T t) throws VerificationException {
for (Predicate<? super T> predicate : predicates) {
try {
if (predicate.test(t)) {
return true;
}
LOG.finer("[alternative] predicate failed: " + predicate);
} catch (VerificationException ex) {
LOG.log(Level.FINER, "[alternative] predicate " + predicate + " failed.", ex);
}
}
return false;
}
};
}
} }

View file

@ -16,28 +16,29 @@
*/ */
package org.keycloak.exceptions; package org.keycloak.exceptions;
import org.keycloak.common.VerificationException; import org.keycloak.representations.JsonWebToken;
/** /**
* Exception thrown for cases when token is invalid due to time constraints (expired, or not yet valid). * Exception thrown for cases when token is invalid due to time constraints (expired, or not yet valid).
* Cf. {@link JsonWebToken#isActive()}. * Cf. {@link JsonWebToken#isActive()}.
* @author hmlnarik * @author hmlnarik
*/ */
public class TokenNotActiveException extends VerificationException { public class TokenNotActiveException extends TokenVerificationException {
public TokenNotActiveException() { public TokenNotActiveException(JsonWebToken token) {
super(token);
} }
public TokenNotActiveException(String message) { public TokenNotActiveException(JsonWebToken token, String message) {
super(message); super(token, message);
} }
public TokenNotActiveException(String message, Throwable cause) { public TokenNotActiveException(JsonWebToken token, String message, Throwable cause) {
super(message, cause); super(token, message, cause);
} }
public TokenNotActiveException(Throwable cause) { public TokenNotActiveException(JsonWebToken token, Throwable cause) {
super(cause); super(token, cause);
} }
} }

View file

@ -16,27 +16,28 @@
*/ */
package org.keycloak.exceptions; package org.keycloak.exceptions;
import org.keycloak.common.VerificationException; import org.keycloak.representations.JsonWebToken;
/** /**
* Thrown when token signature is invalid. * Thrown when token signature is invalid.
* @author hmlnarik * @author hmlnarik
*/ */
public class TokenSignatureInvalidException extends VerificationException { public class TokenSignatureInvalidException extends TokenVerificationException {
public TokenSignatureInvalidException() { public TokenSignatureInvalidException(JsonWebToken token) {
super(token);
} }
public TokenSignatureInvalidException(String message) { public TokenSignatureInvalidException(JsonWebToken token, String message) {
super(message); super(token, message);
} }
public TokenSignatureInvalidException(String message, Throwable cause) { public TokenSignatureInvalidException(JsonWebToken token, String message, Throwable cause) {
super(message, cause); super(token, message, cause);
} }
public TokenSignatureInvalidException(Throwable cause) { public TokenSignatureInvalidException(JsonWebToken token, Throwable cause) {
super(cause); super(token, cause);
} }
} }

View file

@ -0,0 +1,54 @@
/*
* Copyright 2017 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed 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.keycloak.exceptions;
import org.keycloak.common.VerificationException;
import org.keycloak.representations.JsonWebToken;
/**
* Exception thrown on failed verification of a token.
*
* @author hmlnarik
*/
public class TokenVerificationException extends VerificationException {
private final JsonWebToken token;
public TokenVerificationException(JsonWebToken token) {
this.token = token;
}
public TokenVerificationException(JsonWebToken token, String message) {
super(message);
this.token = token;
}
public TokenVerificationException(JsonWebToken token, String message, Throwable cause) {
super(message, cause);
this.token = token;
}
public TokenVerificationException(JsonWebToken token, Throwable cause) {
super(cause);
this.token = token;
}
public JsonWebToken getToken() {
return token;
}
}

View file

@ -42,7 +42,7 @@ public class ResetCredentialsActionToken extends DefaultActionToken {
private static final Logger LOG = Logger.getLogger(ResetCredentialsActionToken.class); private static final Logger LOG = Logger.getLogger(ResetCredentialsActionToken.class);
private static final String RESET_CREDENTIALS_ACTION = "reset-credentials"; public static final String RESET_CREDENTIALS_TYPE = "reset-credentials";
public static final String NOTE_AUTHENTICATION_SESSION_ID = "clientSessionId"; public static final String NOTE_AUTHENTICATION_SESSION_ID = "clientSessionId";
private static final String JSON_FIELD_AUTHENTICATION_SESSION_ID = "asid"; private static final String JSON_FIELD_AUTHENTICATION_SESSION_ID = "asid";
private static final String JSON_FIELD_LAST_CHANGE_PASSWORD_TIMESTAMP = "lcpt"; private static final String JSON_FIELD_LAST_CHANGE_PASSWORD_TIMESTAMP = "lcpt";
@ -54,7 +54,7 @@ public class ResetCredentialsActionToken extends DefaultActionToken {
private Long lastChangedPasswordTimestamp; private Long lastChangedPasswordTimestamp;
public ResetCredentialsActionToken(String userId, int absoluteExpirationInSecs, UUID actionVerificationNonce, Long lastChangedPasswordTimestamp, String authenticationSessionId) { public ResetCredentialsActionToken(String userId, int absoluteExpirationInSecs, UUID actionVerificationNonce, Long lastChangedPasswordTimestamp, String authenticationSessionId) {
super(userId, RESET_CREDENTIALS_ACTION, absoluteExpirationInSecs, actionVerificationNonce); super(userId, RESET_CREDENTIALS_TYPE, absoluteExpirationInSecs, actionVerificationNonce);
setNote(NOTE_AUTHENTICATION_SESSION_ID, authenticationSessionId); setNote(NOTE_AUTHENTICATION_SESSION_ID, authenticationSessionId);
this.lastChangedPasswordTimestamp = lastChangedPasswordTimestamp; this.lastChangedPasswordTimestamp = lastChangedPasswordTimestamp;
} }
@ -131,19 +131,7 @@ public class ResetCredentialsActionToken extends DefaultActionToken {
* @param actionTokenString * @param actionTokenString
* @return * @return
*/ */
public static ResetCredentialsActionToken deserialize(KeycloakSession session, RealmModel realm, UriInfo uri, String token, public static ResetCredentialsActionToken deserialize(String token) throws VerificationException {
Predicate<? super ResetCredentialsActionToken>... checks) throws VerificationException { return TokenVerifier.create(token, ResetCredentialsActionToken.class).getToken();
return TokenVerifier.create(token, ResetCredentialsActionToken.class)
.secretKey(session.keys().getActiveHmacKey(realm).getSecretKey())
.realmUrl(getIssuer(realm, uri))
.tokenType(RESET_CREDENTIALS_ACTION)
.checkActive(false) // TODO: If this line is omitted, the following tests in ResetPasswordTest fail: resetPasswordExpiredCodeShort, resetPasswordExpiredCode
.check(ACTION_TOKEN_BASIC_CHECKS)
.check(checks)
.verify()
.getToken()
;
} }
} }

View file

@ -0,0 +1,74 @@
/*
* Copyright 2017 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed 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.keycloak.authentication;
import org.keycloak.TokenVerifier.Predicate;
import org.keycloak.authentication.authenticators.resetcred.ResetCredentialEmail;
import org.keycloak.common.VerificationException;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventBuilder;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.services.ErrorPage;
import org.keycloak.services.messages.Messages;
import org.keycloak.services.resources.LoginActionsServiceException;
import java.util.Objects;
/**
* Additional checks for {@link ResetCredentialsActionToken}.
*
* @author hmlnarik
*/
public class ResetCredentialsActionTokenChecks implements Predicate<ResetCredentialsActionToken> {
private final KeycloakSession session;
private final RealmModel realm;
private final EventBuilder event;
public ResetCredentialsActionTokenChecks(KeycloakSession session, RealmModel realm, EventBuilder event) {
this.session = session;
this.realm = realm;
this.event = event;
}
public boolean lastChangedTimestampMatches(ResetCredentialsActionToken t) throws VerificationException {
// TODO:hmlnarik Update to use single-use cache
UserModel m = session.users().getUserById(t.getSubject(), realm);
Long lastChanged = m == null ? null : ResetCredentialEmail.getLastChangedTimestamp(session, realm, m);
if (! Objects.equals(lastChanged, t.getLastChangedPasswordTimestamp())) {
if (m != null) {
event.detail(Details.USERNAME, m.getUsername());
}
event.user(t.getSubject()).error(Errors.EXPIRED_CODE);
throw new LoginActionsServiceException(ErrorPage.error(session, Messages.INVALID_CODE));
}
return true;
}
@Override
public boolean test(ResetCredentialsActionToken t) throws VerificationException {
return lastChangedTimestampMatches(t);
}
}

View file

@ -17,7 +17,6 @@
package org.keycloak.authentication.authenticators.resetcred; package org.keycloak.authentication.authenticators.resetcred;
import org.jboss.logging.Logger;
import org.keycloak.Config; import org.keycloak.Config;
import org.keycloak.authentication.*; import org.keycloak.authentication.*;
import org.keycloak.authentication.authenticators.browser.AbstractUsernameFormAuthenticator; import org.keycloak.authentication.authenticators.browser.AbstractUsernameFormAuthenticator;
@ -35,7 +34,6 @@ import org.keycloak.models.utils.FormMessage;
import org.keycloak.provider.ProviderConfigProperty; import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.services.ServicesLogger; import org.keycloak.services.ServicesLogger;
import org.keycloak.services.messages.Messages; import org.keycloak.services.messages.Messages;
import org.keycloak.services.resources.LoginActionsService;
import org.keycloak.sessions.AuthenticationSessionModel; import org.keycloak.sessions.AuthenticationSessionModel;
import java.util.*; import java.util.*;
@ -78,13 +76,11 @@ public class ResetCredentialEmail implements Authenticator, AuthenticatorFactory
int validityInSecs = context.getRealm().getAccessCodeLifespanUserAction(); int validityInSecs = context.getRealm().getAccessCodeLifespanUserAction();
int absoluteExpirationInSecs = Time.currentTime() + validityInSecs; int absoluteExpirationInSecs = Time.currentTime() + validityInSecs;
PasswordCredentialProvider passwordProvider = (PasswordCredentialProvider) context.getSession().getProvider(CredentialProvider.class, PasswordCredentialProviderFactory.PROVIDER_ID); KeycloakSession keycloakSession = context.getSession();
CredentialModel password = passwordProvider.getPassword(context.getRealm(), user); Long lastCreatedPassword = getLastChangedTimestamp(keycloakSession, context.getRealm(), user);
Long lastCreatedPassword = password == null ? null : password.getCreatedDate();
// We send the secret in the email in a link as a query param. // We send the secret in the email in a link as a query param.
ResetCredentialsActionToken token = new ResetCredentialsActionToken(user.getId(), absoluteExpirationInSecs, null, lastCreatedPassword, context.getAuthenticationSession()); ResetCredentialsActionToken token = new ResetCredentialsActionToken(user.getId(), absoluteExpirationInSecs, null, lastCreatedPassword, context.getAuthenticationSession());
KeycloakSession keycloakSession = context.getSession();
String link = UriBuilder String link = UriBuilder
.fromUri(context.getRefreshExecutionUrl()) .fromUri(context.getRefreshExecutionUrl())
.queryParam(Constants.KEY, token.serialize(keycloakSession, context.getRealm(), context.getUriInfo())) .queryParam(Constants.KEY, token.serialize(keycloakSession, context.getRealm(), context.getUriInfo()))
@ -112,22 +108,26 @@ public class ResetCredentialEmail implements Authenticator, AuthenticatorFactory
} }
} }
public static Long getLastChangedTimestamp(KeycloakSession session, RealmModel realm, UserModel user) {
// TODO(hmlnarik): Make this more generic to support non-password credential types
PasswordCredentialProvider passwordProvider = (PasswordCredentialProvider) session.getProvider(CredentialProvider.class, PasswordCredentialProviderFactory.PROVIDER_ID);
CredentialModel password = passwordProvider.getPassword(realm, user);
return password == null ? null : password.getCreatedDate();
}
@Override @Override
public void action(AuthenticationFlowContext context) { public void action(AuthenticationFlowContext context) {
KeycloakSession keycloakSession = context.getSession(); KeycloakSession keycloakSession = context.getSession();
String actionTokenString = context.getUriInfo().getQueryParameters().getFirst(Constants.KEY); String actionTokenString = context.getAuthenticationSession().getAuthNote(ResetCredentialsActionToken.class.getName());
ResetCredentialsActionToken tokenFromMail = null; ResetCredentialsActionToken tokenFromMail = null;
try {
tokenFromMail = ResetCredentialsActionToken.deserialize(keycloakSession, context.getRealm(), context.getUriInfo(), actionTokenString);
} catch (VerificationException ex) {
context.getEvent().detail(Details.REASON, ex.getMessage()).error(Errors.INVALID_CODE);
Response challenge = context.form()
.setError(Messages.INVALID_CODE)
.createErrorPage();
context.failure(AuthenticationFlowError.INTERNAL_ERROR, challenge);
}
String userId = tokenFromMail == null ? null : tokenFromMail.getUserId(); try {
tokenFromMail = ResetCredentialsActionToken.deserialize(actionTokenString);
} catch (VerificationException ex) {
context.getEvent().detail(Details.REASON, ex.getMessage());
// flow returns in the next condition so no "return" statmenent here
}
if (tokenFromMail == null) { if (tokenFromMail == null) {
context.getEvent() context.getEvent()
@ -139,14 +139,15 @@ public class ResetCredentialEmail implements Authenticator, AuthenticatorFactory
return; return;
} }
PasswordCredentialProvider passwordProvider = (PasswordCredentialProvider) context.getSession().getProvider(CredentialProvider.class, PasswordCredentialProviderFactory.PROVIDER_ID); String userId = tokenFromMail.getUserId();
CredentialModel password = passwordProvider.getPassword(context.getRealm(), context.getUser());
Long lastCreatedPasswordMail = tokenFromMail.getLastChangedPasswordTimestamp(); Long lastCreatedPasswordMail = tokenFromMail.getLastChangedPasswordTimestamp();
Long lastCreatedPasswordFromStore = password == null ? null : password.getCreatedDate(); Long lastCreatedPasswordFromStore = getLastChangedTimestamp(keycloakSession, context.getRealm(), context.getUser());
String authenticationSessionId = tokenFromMail.getAuthenticationSessionId(); String authenticationSessionId = tokenFromMail.getAuthenticationSessionId();
AuthenticationSessionModel authenticationSession = authenticationSessionId == null ? null : keycloakSession.authenticationSessions().getAuthenticationSession(context.getRealm(), authenticationSessionId); AuthenticationSessionModel authenticationSession = authenticationSessionId == null
? null
: keycloakSession.authenticationSessions().getAuthenticationSession(context.getRealm(), authenticationSessionId);
if (authenticationSession == null if (authenticationSession == null
|| ! Objects.equals(lastCreatedPasswordMail, lastCreatedPasswordFromStore) || ! Objects.equals(lastCreatedPasswordMail, lastCreatedPasswordFromStore)
@ -157,7 +158,7 @@ public class ResetCredentialEmail implements Authenticator, AuthenticatorFactory
.detail(Details.TOKEN_ID, tokenFromMail.getId()) .detail(Details.TOKEN_ID, tokenFromMail.getId())
.error(Errors.EXPIRED_CODE); .error(Errors.EXPIRED_CODE);
Response challenge = context.form() Response challenge = context.form()
.setError(Messages.INVALID_CODE) .setError(Messages.EXPIRED_CODE)
.createErrorPage(); .createErrorPage();
context.failure(AuthenticationFlowError.INTERNAL_ERROR, challenge); context.failure(AuthenticationFlowError.INTERNAL_ERROR, challenge);
return; return;

View file

@ -750,7 +750,11 @@ public class AuthenticationManager {
public static AuthResult verifyIdentityToken(KeycloakSession session, RealmModel realm, UriInfo uriInfo, ClientConnection connection, boolean checkActive, boolean checkTokenType, public static AuthResult verifyIdentityToken(KeycloakSession session, RealmModel realm, UriInfo uriInfo, ClientConnection connection, boolean checkActive, boolean checkTokenType,
boolean isCookie, String tokenString, HttpHeaders headers) { boolean isCookie, String tokenString, HttpHeaders headers) {
try { try {
TokenVerifier<AccessToken> verifier = TokenVerifier.create(tokenString).realmUrl(Urls.realmIssuer(uriInfo.getBaseUri(), realm.getName())).checkActive(checkActive).checkTokenType(checkTokenType); TokenVerifier<AccessToken> verifier = TokenVerifier.create(tokenString, AccessToken.class)
.withDefaultChecks()
.realmUrl(Urls.realmIssuer(uriInfo.getBaseUri(), realm.getName()))
.checkActive(checkActive)
.checkTokenType(checkTokenType);
String kid = verifier.getHeader().getKeyId(); String kid = verifier.getHeader().getKeyId();
AlgorithmType algorithmType = verifier.getHeader().getAlgorithm().getType(); AlgorithmType algorithmType = verifier.getHeader().getAlgorithm().getType();

View file

@ -26,17 +26,18 @@ import org.keycloak.authentication.RequiredActionFactory;
import org.keycloak.authentication.RequiredActionProvider; import org.keycloak.authentication.RequiredActionProvider;
import org.keycloak.TokenVerifier; import org.keycloak.TokenVerifier;
import org.keycloak.TokenVerifier.Predicate; import org.keycloak.TokenVerifier.Predicate;
import org.keycloak.authentication.ResetCredentialsActionToken; import org.keycloak.TokenVerifier.TokenTypeCheck;
import org.keycloak.authentication.*;
import org.keycloak.authentication.authenticators.broker.AbstractIdpAuthenticator; import org.keycloak.authentication.authenticators.broker.AbstractIdpAuthenticator;
import org.keycloak.authentication.authenticators.browser.AbstractUsernameFormAuthenticator; import org.keycloak.authentication.authenticators.browser.AbstractUsernameFormAuthenticator;
import org.keycloak.common.ClientConnection; import org.keycloak.common.ClientConnection;
import org.keycloak.common.VerificationException; import org.keycloak.common.VerificationException;
import org.keycloak.common.util.ObjectUtil; import org.keycloak.common.util.ObjectUtil;
import org.keycloak.common.util.Time;
import org.keycloak.events.Details; import org.keycloak.events.Details;
import org.keycloak.events.Errors; import org.keycloak.events.Errors;
import org.keycloak.events.EventBuilder; import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventType; import org.keycloak.events.EventType;
import org.keycloak.exceptions.TokenNotActiveException;
import org.keycloak.forms.login.LoginFormsProvider; import org.keycloak.forms.login.LoginFormsProvider;
import org.keycloak.models.AuthenticationFlowModel; import org.keycloak.models.AuthenticationFlowModel;
import org.keycloak.models.AuthenticatedClientSessionModel; import org.keycloak.models.AuthenticatedClientSessionModel;
@ -62,12 +63,12 @@ import org.keycloak.services.ServicesLogger;
import org.keycloak.services.Urls; import org.keycloak.services.Urls;
import org.keycloak.services.managers.AuthenticationManager; import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.managers.ClientSessionCode; import org.keycloak.services.managers.ClientSessionCode;
import org.keycloak.services.managers.ClientSessionCode.ActionType;
import org.keycloak.services.messages.Messages; import org.keycloak.services.messages.Messages;
import org.keycloak.services.util.CacheControlUtil; import org.keycloak.services.util.CacheControlUtil;
import org.keycloak.services.util.CookieHelper; import org.keycloak.services.util.CookieHelper;
import org.keycloak.sessions.AuthenticationSessionModel; import org.keycloak.sessions.AuthenticationSessionModel;
import org.keycloak.sessions.CommonClientSessionModel.Action;
import javax.ws.rs.Consumes; import javax.ws.rs.Consumes;
import javax.ws.rs.GET; import javax.ws.rs.GET;
import javax.ws.rs.POST; import javax.ws.rs.POST;
@ -85,6 +86,11 @@ import javax.ws.rs.core.UriInfo;
import javax.ws.rs.ext.Providers; import javax.ws.rs.ext.Providers;
import java.net.URI; import java.net.URI;
import java.util.Objects; import java.util.Objects;
import java.util.function.*;
import javax.ws.rs.core.*;
import static org.keycloak.TokenVerifier.optional;
import static org.keycloak.authentication.DefaultActionToken.ACTION_TOKEN_BASIC_CHECKS;
import static org.keycloak.authentication.ResetCredentialsActionToken.RESET_CREDENTIALS_TYPE;
/** /**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a> * @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
@ -518,171 +524,192 @@ public class LoginActionsService {
return resetCredentials(code, execution); return resetCredentials(code, execution);
} }
private boolean isSslUsed(JsonWebToken t) throws VerificationException { private Predicate<JsonWebToken> checkThat(BooleanSupplier function, String errorEvent, String errorMessage) {
if (! checkSsl()) { return t -> {
event.error(Errors.SSL_REQUIRED); if (! function.getAsBoolean()) {
throw new LoginActionsServiceException(ErrorPage.error(session, Messages.HTTPS_REQUIRED)); event.error(errorEvent);
throw new LoginActionsServiceException(ErrorPage.error(session, errorMessage));
} }
return true; return true;
};
} }
private boolean isRealmEnabled(JsonWebToken t) throws VerificationException { /**
if (! realm.isEnabled()) { * Verifies that the authentication session has not yet been converted to user session, in other words
event.error(Errors.REALM_DISABLED); * that the user has not yet completed authentication and logged in.
throw new LoginActionsServiceException(ErrorPage.error(session, Messages.REALM_NOT_ENABLED)); * @param t token
} */
return true; private class IsAuthenticationSessionNotConvertedToUserSession<T extends JsonWebToken> implements Predicate<T> {
private final Function<T, String> getAuthenticationSessionIdFromToken;
public IsAuthenticationSessionNotConvertedToUserSession(Function<T, String> getAuthenticationSessionIdFromToken) {
this.getAuthenticationSessionIdFromToken = getAuthenticationSessionIdFromToken;
} }
private boolean isResetCredentialsAllowed(ResetCredentialsActionToken t) throws VerificationException { @Override
if (!realm.isResetPasswordAllowed()) { public boolean test(T t) throws VerificationException {
event.client(t.getAuthenticationSession().getClient()); String authSessionId = t == null ? null : getAuthenticationSessionIdFromToken.apply(t);
event.error(Errors.NOT_ALLOWED); if (authSessionId == null) {
throw new LoginActionsServiceException(ErrorPage.error(session, Messages.RESET_CREDENTIAL_NOT_ALLOWED)); return false;
}
return true;
} }
private boolean canResolveClientSession(ResetCredentialsActionToken t) throws VerificationException { if (session.sessions().getUserSession(realm, authSessionId) != null) {
String authSessionId = t == null ? null : t.getAuthenticationSessionId(); throw new LoginActionsServiceException(
session.getProvider(LoginFormsProvider.class)
if (t == null || authSessionId == null) { .setSuccess(Messages.ALREADY_LOGGED_IN)
event.error(Errors.INVALID_CODE); .createInfoPage());
throw new LoginActionsServiceException(ErrorPage.error(session, Messages.INVALID_CODE));
} }
AuthenticationSessionModel authSession = session.authenticationSessions().getAuthenticationSession(realm, authSessionId);
t.setAuthenticationSession(authSession);
if (authSession == null) { // timeout or logged-already
try {
// Check if we are logged-already (it means userSession with same ID already exists). If yes, just showing the INFO or ERROR that user is already authenticated
// TODO:mposolda
// If not, try to restart authSession from the cookie
AuthenticationSessionModel restartedAuthSession = RestartLoginCookie.restartSession(session, realm);
// IDs must match with the ID from cookie
if (restartedAuthSession!=null && restartedAuthSession.getId().equals(authSessionId)) {
authSession = restartedAuthSession;
}
} catch (Exception e) {
ServicesLogger.LOGGER.failedToParseRestartLoginCookie(e);
}
if (authSession != null) {
event.clone().detail(Details.RESTART_AFTER_TIMEOUT, "true").error(Errors.EXPIRED_CODE);
throw new LoginActionsServiceException(processFlow(false, null, authSession, AUTHENTICATE_PATH, realm.getBrowserFlow(), Messages.LOGIN_TIMEOUT, new AuthenticationProcessor()));
}
}
if (authSession == null) {
event.error(Errors.INVALID_CODE);
throw new LoginActionsServiceException(ErrorPage.error(session, Messages.INVALID_CODE));
}
event.detail(Details.CODE_ID, authSession.getId());
return true; return true;
} }
}
/**
* Verifies whether client stored in the authentication session both exists and is enabled. If yes, it also sets the client
* into session context.
* @param <T>
*/
private class IsClientValid<T extends JsonWebToken> implements Predicate<T> {
private final Function<T, AuthenticationSessionModel> getAuthenticationSessionFromToken;
public IsClientValid(Function<T, AuthenticationSessionModel> getAuthenticationSessionFromToken) {
this.getAuthenticationSessionFromToken = getAuthenticationSessionFromToken;
}
@Override
public boolean test(T t) throws VerificationException {
AuthenticationSessionModel authenticationSession = getAuthenticationSessionFromToken.apply(t);
ClientModel client = authenticationSession == null ? null : authenticationSession.getClient();
private boolean canResolveClient(ResetCredentialsActionToken t) throws VerificationException {
ClientModel client = t.getAuthenticationSession().getClient();
if (client == null) { if (client == null) {
event.error(Errors.CLIENT_NOT_FOUND); event.error(Errors.CLIENT_NOT_FOUND);
session.authenticationSessions().removeAuthenticationSession(realm, t.getAuthenticationSession()); session.authenticationSessions().removeAuthenticationSession(realm, authenticationSession);
throw new LoginActionsServiceException(ErrorPage.error(session, Messages.UNKNOWN_LOGIN_REQUESTER)); throw new LoginActionsServiceException(ErrorPage.error(session, Messages.UNKNOWN_LOGIN_REQUESTER));
} }
if (! client.isEnabled()) { if (! client.isEnabled()) {
event.error(Errors.CLIENT_NOT_FOUND); event.error(Errors.CLIENT_NOT_FOUND);
session.authenticationSessions().removeAuthenticationSession(realm, t.getAuthenticationSession()); session.authenticationSessions().removeAuthenticationSession(realm, authenticationSession);
throw new LoginActionsServiceException(ErrorPage.error(session, Messages.LOGIN_REQUESTER_NOT_ENABLED)); throw new LoginActionsServiceException(ErrorPage.error(session, Messages.LOGIN_REQUESTER_NOT_ENABLED));
} }
session.getContext().setClient(client); session.getContext().setClient(client);
return true; return true;
} }
}
private class IsValidAction implements Predicate<ResetCredentialsActionToken> { /**
* This check verifies that:
* <ul>
* <li>If authentication session ID is not set in the token, passes.</li>
* <li>If auth session ID is set in the token, then the corresponding authentication session exists.
* Then it is set into the token.</li>
* </ul>
*
* @param <T>
*/
private class CanResolveAuthenticationSession<T extends JsonWebToken> implements Predicate<T> {
private final String requiredAction; private final Function<T, String> getAuthenticationSessionIdFromToken;
public IsValidAction(String requiredAction) { private final BiConsumer<T, AuthenticationSessionModel> setAuthenticationSessionToToken;
this.requiredAction = requiredAction;
public CanResolveAuthenticationSession(Function<T, String> getAuthenticationSessionIdFromToken,
BiConsumer<T, AuthenticationSessionModel> setAuthenticationSessionToToken) {
this.getAuthenticationSessionIdFromToken = getAuthenticationSessionIdFromToken;
this.setAuthenticationSessionToToken = setAuthenticationSessionToToken;
}
@Override
public boolean test(T t) throws VerificationException {
String authSessionId = t == null ? null : getAuthenticationSessionIdFromToken.apply(t);
AuthenticationSessionModel authSession;
if (authSessionId == null) {
return true;
} else {
authSession = session.authenticationSessions().getAuthenticationSession(realm, authSessionId);
}
if (authSession == null) { // timeout or logged-already (NOPE - this is handled by IsAuthenticationSessionNotConvertedToUserSession)
throw new LoginActionsServiceException(restartAuthenticationSession(false));
}
event
.detail(Details.CODE_ID, authSession.getId())
.client(authSession.getClient());
setAuthenticationSessionToToken.accept(t, authSession);
return true;
}
}
/**
* This check verifies that if the token has not authentication session set, a new authentication session is introduced
* for the given client and reset-credentials flow is started with this new session.
* @param <T>
*/
private class ResetCredsIntroduceAuthenticationSessionIfNotSet implements Predicate<ResetCredentialsActionToken> {
private final String defaultClientId;
public ResetCredsIntroduceAuthenticationSessionIfNotSet(String defaultClientId) {
this.defaultClientId = defaultClientId;
} }
@Override @Override
public boolean test(ResetCredentialsActionToken t) throws VerificationException { public boolean test(ResetCredentialsActionToken t) throws VerificationException {
AuthenticationSessionModel authSession = t.getAuthenticationSession(); AuthenticationSessionModel authSession = t.getAuthenticationSession();
if (! Objects.equals(authSession.getAction(), this.requiredAction)) {
if (authSession == null) {
authSession = createAuthenticationSessionForClient(this.defaultClientId);
throw new LoginActionsServiceException(processResetCredentials(false, null, authSession, null));
}
return true;
}
}
/**
* Verifies that if authentication session exists and any action is required according to it, then it is
* the expected one.
*
* If there is an action required in the session, furthermore it is not the expected one, and the required
* action is redirection to "required actions", it throws with response performing the redirect to required
* actions.
* @param <T>
*/
private class IsActionRequired<T extends JsonWebToken> implements Predicate<T> {
private final ClientSessionModel.Action expectedAction;
private final Function<T, AuthenticationSessionModel> getAuthenticationSessionFromToken;
public IsActionRequired(Action expectedAction, Function<T, AuthenticationSessionModel> getAuthenticationSessionFromToken) {
this.expectedAction = expectedAction;
this.getAuthenticationSessionFromToken = getAuthenticationSessionFromToken;
}
@Override
public boolean test(T t) throws VerificationException {
AuthenticationSessionModel authSession = getAuthenticationSessionFromToken.apply(t);
if (authSession != null && ! Objects.equals(authSession.getAction(), this.expectedAction.name())) {
if (ClientSessionModel.Action.REQUIRED_ACTIONS.name().equals(authSession.getAction())) { if (ClientSessionModel.Action.REQUIRED_ACTIONS.name().equals(authSession.getAction())) {
// TODO: Once login tokens would be implemented, this would have to be rewritten
// String code = clientSession.getNote(ClientSessionCode.ACTIVE_CODE) + "." + clientSession.getId();
//String code = clientSession.getNote("active_code") + "." + clientSession.getId();
throw new LoginActionsServiceException(redirectToRequiredActions(null, authSession)); throw new LoginActionsServiceException(redirectToRequiredActions(null, authSession));
} }
// TODO:mposolda Similar stuff is in SessionCodeChecks as well. The case when authSession is already logged should be handled similarly
/*else if (clientSession.getUserSession() != null && clientSession.getUserSession().getState() == UserSessionModel.State.LOGGED_IN) {
throw new LoginActionsServiceException(
session.getProvider(LoginFormsProvider.class)
.setSuccess(Messages.ALREADY_LOGGED_IN)
.createInfoPage());
}*/
} }
return true; return true;
} }
} }
private class IsActiveAction implements Predicate<ResetCredentialsActionToken> {
private final ClientSessionCode.ActionType actionType;
public IsActiveAction(ActionType actionType) {
this.actionType = actionType;
}
@Override
public boolean test(ResetCredentialsActionToken t) throws VerificationException {
int timestamp = t.getAuthenticationSession().getTimestamp();
if (! isActionActive(actionType, timestamp)) {
event.client(t.getAuthenticationSession().getClient());
event.clone().error(Errors.EXPIRED_CODE);
if (t.getAuthenticationSession().getAction().equals(ClientSessionModel.Action.AUTHENTICATE.name())) {
AuthenticationSessionModel authSession = t.getAuthenticationSession();
AuthenticationProcessor.resetFlow(authSession);
throw new LoginActionsServiceException(processAuthentication(false, null, authSession, Messages.LOGIN_TIMEOUT));
}
throw new LoginActionsServiceException(ErrorPage.error(session, Messages.EXPIRED_CODE));
}
return true;
}
public boolean isActionActive(ActionType actionType, int timestamp) {
int lifespan;
switch (actionType) {
case CLIENT:
lifespan = realm.getAccessCodeLifespan();
break;
case LOGIN:
lifespan = realm.getAccessCodeLifespanLogin() > 0 ? realm.getAccessCodeLifespanLogin() : realm.getAccessCodeLifespanUserAction();
break;
case USER:
lifespan = realm.getAccessCodeLifespanUserAction();
break;
default:
throw new IllegalArgumentException();
}
return timestamp + lifespan > Time.currentTime();
}
}
/** /**
* Endpoint for executing reset credentials flow. If code is null, a client session is created with the account * Endpoint for executing reset credentials flow. If code is null, a client session is created with the account
* service as the client. Successful reset sends you to the account page. Note, account service must be enabled. * service as the client. Successful reset sends you to the account page. Note, account service must be enabled.
@ -695,7 +722,7 @@ public class LoginActionsService {
@GET @GET
public Response resetCredentialsGET(@QueryParam("code") String code, public Response resetCredentialsGET(@QueryParam("code") String code,
@QueryParam("execution") String execution, @QueryParam("execution") String execution,
@QueryParam("key") String key) { @QueryParam(Constants.KEY) String key) {
if (code != null && key != null) { if (code != null && key != null) {
// TODO:mposolda better handling of error // TODO:mposolda better handling of error
throw new IllegalStateException("Illegal state"); throw new IllegalStateException("Illegal state");
@ -704,48 +731,45 @@ public class LoginActionsService {
AuthenticationSessionModel authSession = session.authenticationSessions().getCurrentAuthenticationSession(realm); AuthenticationSessionModel authSession = session.authenticationSessions().getCurrentAuthenticationSession(realm);
// we allow applications to link to reset credentials without going through OAuth or SAML handshakes // we allow applications to link to reset credentials without going through OAuth or SAML handshakes
if (authSession == null && key == null) { if (authSession == null && key == null && code == null) {
if (!realm.isResetPasswordAllowed()) { if (!realm.isResetPasswordAllowed()) {
event.event(EventType.RESET_PASSWORD); event.event(EventType.RESET_PASSWORD);
event.error(Errors.NOT_ALLOWED); event.error(Errors.NOT_ALLOWED);
return ErrorPage.error(session, Messages.RESET_CREDENTIAL_NOT_ALLOWED); return ErrorPage.error(session, Messages.RESET_CREDENTIAL_NOT_ALLOWED);
} }
authSession = createAuthenticationSessionForClient(Constants.ACCOUNT_MANAGEMENT_CLIENT_ID);
return processResetCredentials(false, null, authSession, null);
}
if (key != null) {
return resetCredentialsByToken(key, execution);
}
return resetCredentials(code, execution);
}
private AuthenticationSessionModel createAuthenticationSessionForClient(String clientId)
throws UriBuilderException, IllegalArgumentException {
AuthenticationSessionModel authSession;
// set up the account service as the endpoint to call. // set up the account service as the endpoint to call.
ClientModel client = realm.getClientByClientId(Constants.ACCOUNT_MANAGEMENT_CLIENT_ID); ClientModel client = realm.getClientByClientId(clientId);
authSession = session.authenticationSessions().createAuthenticationSession(realm, client, true); authSession = session.authenticationSessions().createAuthenticationSession(realm, client, true);
authSession.setAction(ClientSessionModel.Action.AUTHENTICATE.name()); authSession.setAction(ClientSessionModel.Action.AUTHENTICATE.name());
//authSession.setNote(AuthenticationManager.END_AFTER_REQUIRED_ACTIONS, "true"); //authSession.setNote(AuthenticationManager.END_AFTER_REQUIRED_ACTIONS, "true");
authSession.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); authSession.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
String redirectUri = Urls.accountBase(uriInfo.getBaseUri()).path("/").build(realm.getName()).toString(); String redirectUri = Urls.accountBase(uriInfo.getBaseUri()).path("/").build(realm.getName()).toString();
authSession.setRedirectUri(redirectUri); authSession.setRedirectUri(redirectUri);
authSession.setAction(ClientSessionModel.Action.AUTHENTICATE.name());
authSession.setNote(OIDCLoginProtocol.RESPONSE_TYPE_PARAM, OAuth2Constants.CODE); authSession.setNote(OIDCLoginProtocol.RESPONSE_TYPE_PARAM, OAuth2Constants.CODE);
authSession.setNote(OIDCLoginProtocol.REDIRECT_URI_PARAM, redirectUri); authSession.setNote(OIDCLoginProtocol.REDIRECT_URI_PARAM, redirectUri);
authSession.setNote(OIDCLoginProtocol.ISSUER, Urls.realmIssuer(uriInfo.getBaseUri(), realm.getName())); authSession.setNote(OIDCLoginProtocol.ISSUER, Urls.realmIssuer(uriInfo.getBaseUri(), realm.getName()));
return processResetCredentials(false, null, authSession, null);
}
if (key != null) { return authSession;
try {
ResetCredentialsActionToken token = ResetCredentialsActionToken.deserialize(
session, realm, session.getContext().getUri(), key);
return resetCredentials(token, execution);
} catch (VerificationException ex) {
event.event(EventType.RESET_PASSWORD)
.detail(Details.REASON, ex.getMessage())
.error(Errors.NOT_ALLOWED);
return ErrorPage.error(session, Messages.RESET_CREDENTIAL_NOT_ALLOWED);
} }
}
return resetCredentials(code, execution);
}
/** /**
* @deprecated In favor of {@link #resetCredentials(org.keycloak.authentication.ResetCredentialsActionToken, java.lang.String)} * @deprecated In favor of {@link #resetCredentialsByToken(String, String)}
* @param code * @param code
* @param execution * @param execution
* @return * @return
@ -768,49 +792,79 @@ public class LoginActionsService {
return processResetCredentials(checks.actionRequest, execution, authSession, null); return processResetCredentials(checks.actionRequest, execution, authSession, null);
} }
protected Response resetCredentials(ResetCredentialsActionToken token, String execution) { protected Response resetCredentialsByToken(String tokenString, String execution) {
event.event(EventType.RESET_PASSWORD); event.event(EventType.RESET_PASSWORD);
if (token == null) { ResetCredentialsActionToken token;
// TODO: Use more appropriate code ResetCredentialsActionTokenChecks singleUseCheck = new ResetCredentialsActionTokenChecks(session, realm, event);
event.error(Errors.NOT_ALLOWED); try {
return ErrorPage.error(session, Messages.RESET_CREDENTIAL_NOT_ALLOWED); token = TokenVerifier.createHollow(tokenString, ResetCredentialsActionToken.class)
.secretKey(session.keys().getActiveHmacKey(realm).getSecretKey())
.withChecks(
new TokenTypeCheck(RESET_CREDENTIALS_TYPE),
checkThat(realm::isEnabled, Errors.REALM_DISABLED, Messages.REALM_NOT_ENABLED),
checkThat(realm::isResetPasswordAllowed, Errors.NOT_ALLOWED, Messages.RESET_CREDENTIAL_NOT_ALLOWED),
checkThat(this::checkSsl, Errors.SSL_REQUIRED, Messages.HTTPS_REQUIRED),
new IsAuthenticationSessionNotConvertedToUserSession<>(ResetCredentialsActionToken::getAuthenticationSessionId),
// Authentication session might not be part of the token, hence the following check is optional
optional(new CanResolveAuthenticationSession<>(ResetCredentialsActionToken::getAuthenticationSessionId, ResetCredentialsActionToken::setAuthenticationSession)),
// Check for being active has to be after authentication session is resolved so that it can be used in error handling
TokenVerifier.IS_ACTIVE,
singleUseCheck, // TODO:hmlnarik make it use a check via generic single-use cache
new ResetCredsIntroduceAuthenticationSessionIfNotSet(Constants.ACCOUNT_MANAGEMENT_CLIENT_ID),
new IsActionRequired<>(Action.AUTHENTICATE, ResetCredentialsActionToken::getAuthenticationSession),
new IsClientValid<>(ResetCredentialsActionToken::getAuthenticationSession)
)
.withChecks(ACTION_TOKEN_BASIC_CHECKS)
.verify()
.getToken();
} catch (TokenNotActiveException ex) {
token = (ResetCredentialsActionToken) ex.getToken();
if (token != null && token.getAuthenticationSession() != null) {
event.clone()
.client(token.getAuthenticationSession().getClient())
.error(Errors.EXPIRED_CODE);
AuthenticationSessionModel authSession = token.getAuthenticationSession();
AuthenticationProcessor.resetFlow(authSession);
return processAuthentication(false, null, authSession, Messages.LOGIN_TIMEOUT);
} }
try { event
TokenVerifier.from(token).checkOnly( .detail(Details.REASON, ex.getMessage())
// Start basic checks .error(Errors.NOT_ALLOWED);
this::isRealmEnabled, return ErrorPage.error(session, Messages.RESET_CREDENTIAL_NOT_ALLOWED);
this::isSslUsed,
this::isResetCredentialsAllowed,
this::canResolveClientSession,
this::canResolveClient,
// End basic checks
new IsValidAction(ClientSessionModel.Action.AUTHENTICATE.name()),
new IsActiveAction(ActionType.USER)
).verify();
} catch (LoginActionsServiceException ex) { } catch (LoginActionsServiceException ex) {
if (ex.getResponse() == null) { if (ex.getResponse() == null) {
event.event(EventType.RESET_PASSWORD) event
.detail(Details.REASON, ex.getMessage()) .detail(Details.REASON, ex.getMessage())
.error(Errors.INVALID_REQUEST); .error(Errors.NOT_ALLOWED);
return ErrorPage.error(session, Messages.RESET_CREDENTIAL_NOT_ALLOWED); return ErrorPage.error(session, Messages.RESET_CREDENTIAL_NOT_ALLOWED);
} else { } else {
return ex.getResponse(); return ex.getResponse();
} }
} catch (VerificationException ex) { } catch (VerificationException ex) {
event.event(EventType.RESET_PASSWORD) event
.detail(Details.REASON, ex.getMessage()) .detail(Details.REASON, ex.getMessage())
.error(Errors.NOT_ALLOWED); .error(Errors.NOT_ALLOWED);
return ErrorPage.error(session, Messages.RESET_CREDENTIAL_NOT_ALLOWED); return ErrorPage.error(session, Messages.RESET_CREDENTIAL_NOT_ALLOWED);
} }
final AuthenticationSessionModel authSession = token.getAuthenticationSession(); final AuthenticationSessionModel authSession = token.getAuthenticationSession();
authSession.setAuthNote(ResetCredentialsActionToken.class.getName(), tokenString);
// Verify if action is processed in same browser. // Verify if action is processed in same browser.
if (!isSameBrowser(authSession)) { if (!isSameBrowser(authSession)) {
logger.infof("Action request processed in different browser!"); logger.debug("Action request processed in different browser.");
// TODO:mposolda improve this. The code should be merged with the InfinispanLoginSessionProvider code and rather extracted from the infinispan provider // TODO:mposolda improve this. The code should be merged with the InfinispanLoginSessionProvider code and rather extracted from the infinispan provider
setAuthSessionCookie(authSession.getId()); setAuthSessionCookie(authSession.getId());
@ -846,7 +900,7 @@ public class LoginActionsService {
} }
if (actionTokenSession.getId().equals(parentSessionId)) { if (actionTokenSession.getId().equals(parentSessionId)) {
// It's the the correct browser. Let's remove forked session as we won't continue from the login form (browser flow) but from the resetCredentials flow // It's the correct browser. Let's remove forked session as we won't continue from the login form (browser flow) but from the resetCredentialsByToken flow
session.authenticationSessions().removeAuthenticationSession(realm, forkedSession); session.authenticationSessions().removeAuthenticationSession(realm, forkedSession);
logger.infof("Removed forked session: %s", forkedSession.getId()); logger.infof("Removed forked session: %s", forkedSession.getId());

View file

@ -40,6 +40,7 @@ import javax.ws.rs.core.Response;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import static org.hamcrest.Matchers.is;
/** /**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a> * @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
@ -274,13 +275,13 @@ public class AssertEvents implements TestRule {
if (expected.getError() != null && ! expected.getType().toString().endsWith("_ERROR")) { if (expected.getError() != null && ! expected.getType().toString().endsWith("_ERROR")) {
expected.setType(expected.getType() + "_ERROR"); expected.setType(expected.getType() + "_ERROR");
} }
Assert.assertEquals(expected.getType(), actual.getType()); Assert.assertThat("type", actual.getType(), is(expected.getType()));
Assert.assertThat(actual.getRealmId(), realmId); Assert.assertThat("realm ID", actual.getRealmId(), is(realmId));
Assert.assertEquals(expected.getClientId(), actual.getClientId()); Assert.assertThat("client ID", actual.getClientId(), is(expected.getClientId()));
Assert.assertEquals(expected.getError(), actual.getError()); Assert.assertThat("error", actual.getError(), is(expected.getError()));
Assert.assertEquals(expected.getIpAddress(), actual.getIpAddress()); Assert.assertThat("ip address", actual.getIpAddress(), is(expected.getIpAddress()));
Assert.assertThat(actual.getUserId(), userId); Assert.assertThat("user ID", actual.getUserId(), is(userId));
Assert.assertThat(actual.getSessionId(), sessionId); Assert.assertThat("session ID", actual.getSessionId(), is(sessionId));
if (details == null || details.isEmpty()) { if (details == null || details.isEmpty()) {
// Assert.assertNull(actual.getDetails()); // Assert.assertNull(actual.getDetails());
@ -292,7 +293,7 @@ public class AssertEvents implements TestRule {
Assert.fail(d.getKey() + " missing"); Assert.fail(d.getKey() + " missing");
} }
Assert.assertThat("Unexpected value for " + d.getKey(), actualValue, d.getValue()); Assert.assertThat("Unexpected value for " + d.getKey(), actualValue, is(d.getValue()));
} }
/* /*
for (String k : actual.getDetails().keySet()) { for (String k : actual.getDetails().keySet()) {

View file

@ -17,9 +17,6 @@
package org.keycloak.testsuite.forms; package org.keycloak.testsuite.forms;
import org.jboss.arquillian.graphene.page.Page; import org.jboss.arquillian.graphene.page.Page;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.keycloak.events.Details; import org.keycloak.events.Details;
import org.keycloak.events.Errors; import org.keycloak.events.Errors;
import org.keycloak.events.EventType; import org.keycloak.events.EventType;
@ -51,8 +48,7 @@ import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
import org.junit.*; import org.junit.*;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.*;
import static org.junit.Assert.assertTrue;
/** /**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a> * @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
@ -172,16 +168,34 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest {
} }
@Test @Test
@Ignore
public void resetPasswordTwice() throws IOException, MessagingException { public void resetPasswordTwice() throws IOException, MessagingException {
String changePasswordUrl = resetPassword("login-test"); String changePasswordUrl = resetPassword("login-test");
events.clear();
// TODO:hmlnarik is this correct??
assertSecondPasswordResetFails(changePasswordUrl, "test-app"); // KC_RESTART exists, hence client-ID is taken from it.
}
@Test
public void resetPasswordTwiceInNewBrowser() throws IOException, MessagingException {
String changePasswordUrl = resetPassword("login-test");
events.clear();
String resetUri = oauth.AUTH_SERVER_ROOT + "/realms/test/login-actions/reset-credentials";
driver.navigate().to(resetUri); // This is necessary to delete KC_RESTART cookie that is restricted to /auth/realms/test path
driver.manage().deleteAllCookies();
assertSecondPasswordResetFails(changePasswordUrl, null);
}
public void assertSecondPasswordResetFails(String changePasswordUrl, String clientId) {
driver.navigate().to(changePasswordUrl.trim()); driver.navigate().to(changePasswordUrl.trim());
errorPage.assertCurrent(); errorPage.assertCurrent();
assertEquals("An error occurred, please login again through your application.", errorPage.getError()); assertEquals("An error occurred, please login again through your application.", errorPage.getError());
events.expect(EventType.RESET_PASSWORD) events.expect(EventType.RESET_PASSWORD)
.client((String) null) .client(clientId)
.session((String) null) .session((String) null)
.user(userId) .user(userId)
.detail(Details.USERNAME, "login-test") .detail(Details.USERNAME, "login-test")
@ -337,7 +351,6 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest {
@Test @Test
public void resetPasswordExpiredCode() throws IOException, MessagingException, InterruptedException { public void resetPasswordExpiredCode() throws IOException, MessagingException, InterruptedException {
try {
initiateResetPasswordFromResetPasswordPage("login-test"); initiateResetPasswordFromResetPasswordPage("login-test");
events.expectRequiredAction(EventType.SEND_RESET_PASSWORD) events.expectRequiredAction(EventType.SEND_RESET_PASSWORD)
@ -350,6 +363,7 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest {
String changePasswordUrl = getPasswordResetEmailLink(message); String changePasswordUrl = getPasswordResetEmailLink(message);
try {
setTimeOffset(1800 + 23); setTimeOffset(1800 + 23);
driver.navigate().to(changePasswordUrl.trim()); driver.navigate().to(changePasswordUrl.trim());
@ -590,6 +604,7 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest {
String changePasswordUrl = getPasswordResetEmailLink(message); String changePasswordUrl = getPasswordResetEmailLink(message);
driver.navigate().to(resetUri); // This is necessary to delete KC_RESTART cookie that is restricted to /auth/realms/test path
driver.manage().deleteAllCookies(); driver.manage().deleteAllCookies();
driver.navigate().to(changePasswordUrl.trim()); driver.navigate().to(changePasswordUrl.trim());