KEYCLOAK-4627 Changes in TokenVerifier to include token in exceptions. Reset credentials uses checks to validate individual token aspects
This commit is contained in:
parent
a9ec69e424
commit
b55b089355
12 changed files with 599 additions and 310 deletions
|
@ -32,7 +32,7 @@ public class RSATokenVerifier {
|
|||
private final TokenVerifier<AccessToken> tokenVerifier;
|
||||
|
||||
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) {
|
||||
|
|
|
@ -33,6 +33,8 @@ import org.keycloak.util.TokenUtil;
|
|||
import javax.crypto.SecretKey;
|
||||
import java.security.PublicKey;
|
||||
import java.util.*;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
|
||||
|
@ -40,9 +42,15 @@ import java.util.*;
|
|||
*/
|
||||
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.
|
||||
// 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
|
||||
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>() {
|
||||
@Override
|
||||
public boolean test(JsonWebToken t) throws VerificationException {
|
||||
if (! t.isActive()) {
|
||||
throw new TokenNotActiveException("Token is not active");
|
||||
throw new TokenNotActiveException(t, "Token is not active");
|
||||
}
|
||||
|
||||
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.
|
||||
* @param tokenString
|
||||
* Creates an instance of {@code TokenVerifier} from the given string on a JWT of the given class.
|
||||
* 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
|
||||
* @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) {
|
||||
return new TokenVerifier(tokenString, clazz)
|
||||
.check(RealmUrlCheck.NULL_INSTANCE)
|
||||
.check(SUBJECT_EXISTS_CHECK)
|
||||
.check(TokenTypeCheck.INSTANCE_BEARER)
|
||||
.check(IS_ACTIVE);
|
||||
return new TokenVerifier(tokenString, clazz);
|
||||
}
|
||||
|
||||
public static <T extends JsonWebToken> TokenVerifier<T> from(T token) {
|
||||
return new TokenVerifier(token)
|
||||
.check(RealmUrlCheck.NULL_INSTANCE)
|
||||
.check(SUBJECT_EXISTS_CHECK)
|
||||
.check(TokenTypeCheck.INSTANCE_BEARER)
|
||||
.check(IS_ACTIVE);
|
||||
/**
|
||||
* Creates an instance of {@code TokenVerifier} from the given string on a JWT of the given class.
|
||||
* The token verifier has no checks defined. Note that the checks are only tested when
|
||||
* {@link #verify()} method is invoked.
|
||||
* @return
|
||||
*/
|
||||
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) {
|
||||
|
@ -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
|
||||
* @return
|
||||
*/
|
||||
public TokenVerifier<T> checkOnly(Predicate<? super T>... checks) {
|
||||
this.checks.clear();
|
||||
public TokenVerifier<T> withChecks(Predicate<? super T>... checks) {
|
||||
if (checks != null) {
|
||||
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.
|
||||
* @param checks
|
||||
* Sets the key for verification of RSA-based signature.
|
||||
* @param publicKey
|
||||
* @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) {
|
||||
this.publicKey = publicKey;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the key for verification of HMAC-based signature.
|
||||
* @param secretKey
|
||||
* @return
|
||||
*/
|
||||
public TokenVerifier<T> secretKey(SecretKey secretKey) {
|
||||
this.secretKey = secretKey;
|
||||
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) {
|
||||
this.realmUrl = 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) {
|
||||
this.checkTokenType = checkTokenType;
|
||||
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) {
|
||||
this.expectedTokenType = tokenType;
|
||||
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) {
|
||||
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) {
|
||||
this.checkRealmUrl = checkRealmUrl;
|
||||
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");
|
||||
}
|
||||
if (!RSAProvider.verify(jws, publicKey)) {
|
||||
throw new TokenSignatureInvalidException("Invalid token signature");
|
||||
throw new TokenSignatureInvalidException(token, "Invalid token signature");
|
||||
} break;
|
||||
case HMAC:
|
||||
if (secretKey == null) {
|
||||
throw new VerificationException("Secret key not set");
|
||||
}
|
||||
if (!HMACProvider.verify(jws, secretKey)) {
|
||||
throw new TokenSignatureInvalidException("Invalid token signature");
|
||||
throw new TokenSignatureInvalidException(token, "Invalid token signature");
|
||||
} break;
|
||||
default:
|
||||
throw new VerificationException("Unknown or unsupported token algorithm");
|
||||
|
@ -331,4 +376,55 @@ public class TokenVerifier<T extends JsonWebToken> {
|
|||
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;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,28 +16,29 @@
|
|||
*/
|
||||
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).
|
||||
* Cf. {@link JsonWebToken#isActive()}.
|
||||
* @author hmlnarik
|
||||
*/
|
||||
public class TokenNotActiveException extends VerificationException {
|
||||
public class TokenNotActiveException extends TokenVerificationException {
|
||||
|
||||
public TokenNotActiveException() {
|
||||
public TokenNotActiveException(JsonWebToken token) {
|
||||
super(token);
|
||||
}
|
||||
|
||||
public TokenNotActiveException(String message) {
|
||||
super(message);
|
||||
public TokenNotActiveException(JsonWebToken token, String message) {
|
||||
super(token, message);
|
||||
}
|
||||
|
||||
public TokenNotActiveException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
public TokenNotActiveException(JsonWebToken token, String message, Throwable cause) {
|
||||
super(token, message, cause);
|
||||
}
|
||||
|
||||
public TokenNotActiveException(Throwable cause) {
|
||||
super(cause);
|
||||
public TokenNotActiveException(JsonWebToken token, Throwable cause) {
|
||||
super(token, cause);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -16,27 +16,28 @@
|
|||
*/
|
||||
package org.keycloak.exceptions;
|
||||
|
||||
import org.keycloak.common.VerificationException;
|
||||
import org.keycloak.representations.JsonWebToken;
|
||||
|
||||
/**
|
||||
* Thrown when token signature is invalid.
|
||||
* @author hmlnarik
|
||||
*/
|
||||
public class TokenSignatureInvalidException extends VerificationException {
|
||||
public class TokenSignatureInvalidException extends TokenVerificationException {
|
||||
|
||||
public TokenSignatureInvalidException() {
|
||||
public TokenSignatureInvalidException(JsonWebToken token) {
|
||||
super(token);
|
||||
}
|
||||
|
||||
public TokenSignatureInvalidException(String message) {
|
||||
super(message);
|
||||
public TokenSignatureInvalidException(JsonWebToken token, String message) {
|
||||
super(token, message);
|
||||
}
|
||||
|
||||
public TokenSignatureInvalidException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
public TokenSignatureInvalidException(JsonWebToken token, String message, Throwable cause) {
|
||||
super(token, message, cause);
|
||||
}
|
||||
|
||||
public TokenSignatureInvalidException(Throwable cause) {
|
||||
super(cause);
|
||||
public TokenSignatureInvalidException(JsonWebToken token, Throwable cause) {
|
||||
super(token, cause);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
|
@ -42,7 +42,7 @@ public class ResetCredentialsActionToken extends DefaultActionToken {
|
|||
|
||||
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";
|
||||
private static final String JSON_FIELD_AUTHENTICATION_SESSION_ID = "asid";
|
||||
private static final String JSON_FIELD_LAST_CHANGE_PASSWORD_TIMESTAMP = "lcpt";
|
||||
|
@ -54,7 +54,7 @@ public class ResetCredentialsActionToken extends DefaultActionToken {
|
|||
private Long lastChangedPasswordTimestamp;
|
||||
|
||||
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);
|
||||
this.lastChangedPasswordTimestamp = lastChangedPasswordTimestamp;
|
||||
}
|
||||
|
@ -131,19 +131,7 @@ public class ResetCredentialsActionToken extends DefaultActionToken {
|
|||
* @param actionTokenString
|
||||
* @return
|
||||
*/
|
||||
public static ResetCredentialsActionToken deserialize(KeycloakSession session, RealmModel realm, UriInfo uri, String token,
|
||||
Predicate<? super ResetCredentialsActionToken>... checks) throws VerificationException {
|
||||
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()
|
||||
;
|
||||
public static ResetCredentialsActionToken deserialize(String token) throws VerificationException {
|
||||
return TokenVerifier.create(token, ResetCredentialsActionToken.class).getToken();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -17,7 +17,6 @@
|
|||
|
||||
package org.keycloak.authentication.authenticators.resetcred;
|
||||
|
||||
import org.jboss.logging.Logger;
|
||||
import org.keycloak.Config;
|
||||
import org.keycloak.authentication.*;
|
||||
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.services.ServicesLogger;
|
||||
import org.keycloak.services.messages.Messages;
|
||||
import org.keycloak.services.resources.LoginActionsService;
|
||||
import org.keycloak.sessions.AuthenticationSessionModel;
|
||||
|
||||
import java.util.*;
|
||||
|
@ -78,13 +76,11 @@ public class ResetCredentialEmail implements Authenticator, AuthenticatorFactory
|
|||
int validityInSecs = context.getRealm().getAccessCodeLifespanUserAction();
|
||||
int absoluteExpirationInSecs = Time.currentTime() + validityInSecs;
|
||||
|
||||
PasswordCredentialProvider passwordProvider = (PasswordCredentialProvider) context.getSession().getProvider(CredentialProvider.class, PasswordCredentialProviderFactory.PROVIDER_ID);
|
||||
CredentialModel password = passwordProvider.getPassword(context.getRealm(), user);
|
||||
Long lastCreatedPassword = password == null ? null : password.getCreatedDate();
|
||||
KeycloakSession keycloakSession = context.getSession();
|
||||
Long lastCreatedPassword = getLastChangedTimestamp(keycloakSession, context.getRealm(), user);
|
||||
|
||||
// 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());
|
||||
KeycloakSession keycloakSession = context.getSession();
|
||||
String link = UriBuilder
|
||||
.fromUri(context.getRefreshExecutionUrl())
|
||||
.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
|
||||
public void action(AuthenticationFlowContext context) {
|
||||
KeycloakSession keycloakSession = context.getSession();
|
||||
String actionTokenString = context.getUriInfo().getQueryParameters().getFirst(Constants.KEY);
|
||||
String actionTokenString = context.getAuthenticationSession().getAuthNote(ResetCredentialsActionToken.class.getName());
|
||||
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) {
|
||||
context.getEvent()
|
||||
|
@ -139,14 +139,15 @@ public class ResetCredentialEmail implements Authenticator, AuthenticatorFactory
|
|||
return;
|
||||
}
|
||||
|
||||
PasswordCredentialProvider passwordProvider = (PasswordCredentialProvider) context.getSession().getProvider(CredentialProvider.class, PasswordCredentialProviderFactory.PROVIDER_ID);
|
||||
CredentialModel password = passwordProvider.getPassword(context.getRealm(), context.getUser());
|
||||
String userId = tokenFromMail.getUserId();
|
||||
|
||||
Long lastCreatedPasswordMail = tokenFromMail.getLastChangedPasswordTimestamp();
|
||||
Long lastCreatedPasswordFromStore = password == null ? null : password.getCreatedDate();
|
||||
Long lastCreatedPasswordFromStore = getLastChangedTimestamp(keycloakSession, context.getRealm(), context.getUser());
|
||||
|
||||
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
|
||||
|| ! Objects.equals(lastCreatedPasswordMail, lastCreatedPasswordFromStore)
|
||||
|
@ -157,7 +158,7 @@ public class ResetCredentialEmail implements Authenticator, AuthenticatorFactory
|
|||
.detail(Details.TOKEN_ID, tokenFromMail.getId())
|
||||
.error(Errors.EXPIRED_CODE);
|
||||
Response challenge = context.form()
|
||||
.setError(Messages.INVALID_CODE)
|
||||
.setError(Messages.EXPIRED_CODE)
|
||||
.createErrorPage();
|
||||
context.failure(AuthenticationFlowError.INTERNAL_ERROR, challenge);
|
||||
return;
|
||||
|
|
|
@ -750,7 +750,11 @@ public class AuthenticationManager {
|
|||
public static AuthResult verifyIdentityToken(KeycloakSession session, RealmModel realm, UriInfo uriInfo, ClientConnection connection, boolean checkActive, boolean checkTokenType,
|
||||
boolean isCookie, String tokenString, HttpHeaders headers) {
|
||||
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();
|
||||
AlgorithmType algorithmType = verifier.getHeader().getAlgorithm().getType();
|
||||
|
||||
|
|
|
@ -26,17 +26,18 @@ import org.keycloak.authentication.RequiredActionFactory;
|
|||
import org.keycloak.authentication.RequiredActionProvider;
|
||||
import org.keycloak.TokenVerifier;
|
||||
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.browser.AbstractUsernameFormAuthenticator;
|
||||
import org.keycloak.common.ClientConnection;
|
||||
import org.keycloak.common.VerificationException;
|
||||
import org.keycloak.common.util.ObjectUtil;
|
||||
import org.keycloak.common.util.Time;
|
||||
import org.keycloak.events.Details;
|
||||
import org.keycloak.events.Errors;
|
||||
import org.keycloak.events.EventBuilder;
|
||||
import org.keycloak.events.EventType;
|
||||
import org.keycloak.exceptions.TokenNotActiveException;
|
||||
import org.keycloak.forms.login.LoginFormsProvider;
|
||||
import org.keycloak.models.AuthenticationFlowModel;
|
||||
import org.keycloak.models.AuthenticatedClientSessionModel;
|
||||
|
@ -62,12 +63,12 @@ import org.keycloak.services.ServicesLogger;
|
|||
import org.keycloak.services.Urls;
|
||||
import org.keycloak.services.managers.AuthenticationManager;
|
||||
import org.keycloak.services.managers.ClientSessionCode;
|
||||
import org.keycloak.services.managers.ClientSessionCode.ActionType;
|
||||
import org.keycloak.services.messages.Messages;
|
||||
import org.keycloak.services.util.CacheControlUtil;
|
||||
import org.keycloak.services.util.CookieHelper;
|
||||
import org.keycloak.sessions.AuthenticationSessionModel;
|
||||
|
||||
import org.keycloak.sessions.CommonClientSessionModel.Action;
|
||||
import javax.ws.rs.Consumes;
|
||||
import javax.ws.rs.GET;
|
||||
import javax.ws.rs.POST;
|
||||
|
@ -85,6 +86,11 @@ import javax.ws.rs.core.UriInfo;
|
|||
import javax.ws.rs.ext.Providers;
|
||||
import java.net.URI;
|
||||
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>
|
||||
|
@ -518,171 +524,192 @@ public class LoginActionsService {
|
|||
return resetCredentials(code, execution);
|
||||
}
|
||||
|
||||
private boolean isSslUsed(JsonWebToken t) throws VerificationException {
|
||||
if (! checkSsl()) {
|
||||
event.error(Errors.SSL_REQUIRED);
|
||||
throw new LoginActionsServiceException(ErrorPage.error(session, Messages.HTTPS_REQUIRED));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean isRealmEnabled(JsonWebToken t) throws VerificationException {
|
||||
if (! realm.isEnabled()) {
|
||||
event.error(Errors.REALM_DISABLED);
|
||||
throw new LoginActionsServiceException(ErrorPage.error(session, Messages.REALM_NOT_ENABLED));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean isResetCredentialsAllowed(ResetCredentialsActionToken t) throws VerificationException {
|
||||
if (!realm.isResetPasswordAllowed()) {
|
||||
event.client(t.getAuthenticationSession().getClient());
|
||||
event.error(Errors.NOT_ALLOWED);
|
||||
throw new LoginActionsServiceException(ErrorPage.error(session, Messages.RESET_CREDENTIAL_NOT_ALLOWED));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean canResolveClientSession(ResetCredentialsActionToken t) throws VerificationException {
|
||||
String authSessionId = t == null ? null : t.getAuthenticationSessionId();
|
||||
|
||||
if (t == null || authSessionId == null) {
|
||||
event.error(Errors.INVALID_CODE);
|
||||
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);
|
||||
private Predicate<JsonWebToken> checkThat(BooleanSupplier function, String errorEvent, String errorMessage) {
|
||||
return t -> {
|
||||
if (! function.getAsBoolean()) {
|
||||
event.error(errorEvent);
|
||||
throw new LoginActionsServiceException(ErrorPage.error(session, errorMessage));
|
||||
}
|
||||
|
||||
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()));
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies that the authentication session has not yet been converted to user session, in other words
|
||||
* that the user has not yet completed authentication and logged in.
|
||||
* @param t token
|
||||
*/
|
||||
private class IsAuthenticationSessionNotConvertedToUserSession<T extends JsonWebToken> implements Predicate<T> {
|
||||
|
||||
private final Function<T, String> getAuthenticationSessionIdFromToken;
|
||||
|
||||
public IsAuthenticationSessionNotConvertedToUserSession(Function<T, String> getAuthenticationSessionIdFromToken) {
|
||||
this.getAuthenticationSessionIdFromToken = getAuthenticationSessionIdFromToken;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean test(T t) throws VerificationException {
|
||||
String authSessionId = t == null ? null : getAuthenticationSessionIdFromToken.apply(t);
|
||||
if (authSessionId == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (session.sessions().getUserSession(realm, authSessionId) != null) {
|
||||
throw new LoginActionsServiceException(
|
||||
session.getProvider(LoginFormsProvider.class)
|
||||
.setSuccess(Messages.ALREADY_LOGGED_IN)
|
||||
.createInfoPage());
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
private boolean canResolveClient(ResetCredentialsActionToken t) throws VerificationException {
|
||||
ClientModel client = t.getAuthenticationSession().getClient();
|
||||
if (client == null) {
|
||||
event.error(Errors.CLIENT_NOT_FOUND);
|
||||
session.authenticationSessions().removeAuthenticationSession(realm, t.getAuthenticationSession());
|
||||
throw new LoginActionsServiceException(ErrorPage.error(session, Messages.UNKNOWN_LOGIN_REQUESTER));
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
if (! client.isEnabled()) {
|
||||
event.error(Errors.CLIENT_NOT_FOUND);
|
||||
session.authenticationSessions().removeAuthenticationSession(realm, t.getAuthenticationSession());
|
||||
throw new LoginActionsServiceException(ErrorPage.error(session, Messages.LOGIN_REQUESTER_NOT_ENABLED));
|
||||
}
|
||||
session.getContext().setClient(client);
|
||||
@Override
|
||||
public boolean test(T t) throws VerificationException {
|
||||
AuthenticationSessionModel authenticationSession = getAuthenticationSessionFromToken.apply(t);
|
||||
|
||||
return true;
|
||||
ClientModel client = authenticationSession == null ? null : authenticationSession.getClient();
|
||||
|
||||
if (client == null) {
|
||||
event.error(Errors.CLIENT_NOT_FOUND);
|
||||
session.authenticationSessions().removeAuthenticationSession(realm, authenticationSession);
|
||||
throw new LoginActionsServiceException(ErrorPage.error(session, Messages.UNKNOWN_LOGIN_REQUESTER));
|
||||
}
|
||||
|
||||
if (! client.isEnabled()) {
|
||||
event.error(Errors.CLIENT_NOT_FOUND);
|
||||
session.authenticationSessions().removeAuthenticationSession(realm, authenticationSession);
|
||||
throw new LoginActionsServiceException(ErrorPage.error(session, Messages.LOGIN_REQUESTER_NOT_ENABLED));
|
||||
}
|
||||
|
||||
session.getContext().setClient(client);
|
||||
|
||||
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) {
|
||||
this.requiredAction = requiredAction;
|
||||
private final BiConsumer<T, AuthenticationSessionModel> setAuthenticationSessionToToken;
|
||||
|
||||
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
|
||||
public boolean test(ResetCredentialsActionToken t) throws VerificationException {
|
||||
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())) {
|
||||
// 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));
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
* 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
|
||||
public Response resetCredentialsGET(@QueryParam("code") String code,
|
||||
@QueryParam("execution") String execution,
|
||||
@QueryParam("key") String key) {
|
||||
@QueryParam(Constants.KEY) String key) {
|
||||
if (code != null && key != null) {
|
||||
// TODO:mposolda better handling of error
|
||||
throw new IllegalStateException("Illegal state");
|
||||
|
@ -704,48 +731,45 @@ public class LoginActionsService {
|
|||
AuthenticationSessionModel authSession = session.authenticationSessions().getCurrentAuthenticationSession(realm);
|
||||
|
||||
// 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()) {
|
||||
event.event(EventType.RESET_PASSWORD);
|
||||
event.error(Errors.NOT_ALLOWED);
|
||||
return ErrorPage.error(session, Messages.RESET_CREDENTIAL_NOT_ALLOWED);
|
||||
|
||||
}
|
||||
// set up the account service as the endpoint to call.
|
||||
ClientModel client = realm.getClientByClientId(Constants.ACCOUNT_MANAGEMENT_CLIENT_ID);
|
||||
authSession = session.authenticationSessions().createAuthenticationSession(realm, client, true);
|
||||
authSession.setAction(ClientSessionModel.Action.AUTHENTICATE.name());
|
||||
//authSession.setNote(AuthenticationManager.END_AFTER_REQUIRED_ACTIONS, "true");
|
||||
authSession.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
|
||||
String redirectUri = Urls.accountBase(uriInfo.getBaseUri()).path("/").build(realm.getName()).toString();
|
||||
authSession.setRedirectUri(redirectUri);
|
||||
authSession.setAction(ClientSessionModel.Action.AUTHENTICATE.name());
|
||||
authSession.setNote(OIDCLoginProtocol.RESPONSE_TYPE_PARAM, OAuth2Constants.CODE);
|
||||
authSession.setNote(OIDCLoginProtocol.REDIRECT_URI_PARAM, redirectUri);
|
||||
authSession.setNote(OIDCLoginProtocol.ISSUER, Urls.realmIssuer(uriInfo.getBaseUri(), realm.getName()));
|
||||
authSession = createAuthenticationSessionForClient(Constants.ACCOUNT_MANAGEMENT_CLIENT_ID);
|
||||
return processResetCredentials(false, null, authSession, null);
|
||||
}
|
||||
|
||||
if (key != null) {
|
||||
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 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.
|
||||
ClientModel client = realm.getClientByClientId(clientId);
|
||||
authSession = session.authenticationSessions().createAuthenticationSession(realm, client, true);
|
||||
authSession.setAction(ClientSessionModel.Action.AUTHENTICATE.name());
|
||||
//authSession.setNote(AuthenticationManager.END_AFTER_REQUIRED_ACTIONS, "true");
|
||||
authSession.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
|
||||
String redirectUri = Urls.accountBase(uriInfo.getBaseUri()).path("/").build(realm.getName()).toString();
|
||||
authSession.setRedirectUri(redirectUri);
|
||||
authSession.setNote(OIDCLoginProtocol.RESPONSE_TYPE_PARAM, OAuth2Constants.CODE);
|
||||
authSession.setNote(OIDCLoginProtocol.REDIRECT_URI_PARAM, redirectUri);
|
||||
authSession.setNote(OIDCLoginProtocol.ISSUER, Urls.realmIssuer(uriInfo.getBaseUri(), realm.getName()));
|
||||
|
||||
return authSession;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated In favor of {@link #resetCredentials(org.keycloak.authentication.ResetCredentialsActionToken, java.lang.String)}
|
||||
* @deprecated In favor of {@link #resetCredentialsByToken(String, String)}
|
||||
* @param code
|
||||
* @param execution
|
||||
* @return
|
||||
|
@ -768,49 +792,79 @@ public class LoginActionsService {
|
|||
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);
|
||||
|
||||
if (token == null) {
|
||||
// TODO: Use more appropriate code
|
||||
event.error(Errors.NOT_ALLOWED);
|
||||
return ErrorPage.error(session, Messages.RESET_CREDENTIAL_NOT_ALLOWED);
|
||||
}
|
||||
|
||||
ResetCredentialsActionToken token;
|
||||
ResetCredentialsActionTokenChecks singleUseCheck = new ResetCredentialsActionTokenChecks(session, realm, event);
|
||||
try {
|
||||
TokenVerifier.from(token).checkOnly(
|
||||
// Start basic checks
|
||||
this::isRealmEnabled,
|
||||
this::isSslUsed,
|
||||
this::isResetCredentialsAllowed,
|
||||
this::canResolveClientSession,
|
||||
this::canResolveClient,
|
||||
// End basic checks
|
||||
|
||||
new IsValidAction(ClientSessionModel.Action.AUTHENTICATE.name()),
|
||||
new IsActiveAction(ActionType.USER)
|
||||
).verify();
|
||||
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);
|
||||
}
|
||||
|
||||
event
|
||||
.detail(Details.REASON, ex.getMessage())
|
||||
.error(Errors.NOT_ALLOWED);
|
||||
return ErrorPage.error(session, Messages.RESET_CREDENTIAL_NOT_ALLOWED);
|
||||
} catch (LoginActionsServiceException ex) {
|
||||
if (ex.getResponse() == null) {
|
||||
event.event(EventType.RESET_PASSWORD)
|
||||
event
|
||||
.detail(Details.REASON, ex.getMessage())
|
||||
.error(Errors.INVALID_REQUEST);
|
||||
.error(Errors.NOT_ALLOWED);
|
||||
return ErrorPage.error(session, Messages.RESET_CREDENTIAL_NOT_ALLOWED);
|
||||
} else {
|
||||
return ex.getResponse();
|
||||
}
|
||||
} catch (VerificationException ex) {
|
||||
event.event(EventType.RESET_PASSWORD)
|
||||
event
|
||||
.detail(Details.REASON, ex.getMessage())
|
||||
.error(Errors.NOT_ALLOWED);
|
||||
return ErrorPage.error(session, Messages.RESET_CREDENTIAL_NOT_ALLOWED);
|
||||
}
|
||||
|
||||
final AuthenticationSessionModel authSession = token.getAuthenticationSession();
|
||||
authSession.setAuthNote(ResetCredentialsActionToken.class.getName(), tokenString);
|
||||
|
||||
// Verify if action is processed in same browser.
|
||||
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
|
||||
setAuthSessionCookie(authSession.getId());
|
||||
|
@ -846,7 +900,7 @@ public class LoginActionsService {
|
|||
}
|
||||
|
||||
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);
|
||||
logger.infof("Removed forked session: %s", forkedSession.getId());
|
||||
|
||||
|
|
|
@ -40,6 +40,7 @@ import javax.ws.rs.core.Response;
|
|||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import static org.hamcrest.Matchers.is;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
|
||||
|
@ -271,16 +272,16 @@ public class AssertEvents implements TestRule {
|
|||
}
|
||||
|
||||
public EventRepresentation assertEvent(EventRepresentation actual) {
|
||||
if (expected.getError() != null && !expected.getType().toString().endsWith("_ERROR")) {
|
||||
if (expected.getError() != null && ! expected.getType().toString().endsWith("_ERROR")) {
|
||||
expected.setType(expected.getType() + "_ERROR");
|
||||
}
|
||||
Assert.assertEquals(expected.getType(), actual.getType());
|
||||
Assert.assertThat(actual.getRealmId(), realmId);
|
||||
Assert.assertEquals(expected.getClientId(), actual.getClientId());
|
||||
Assert.assertEquals(expected.getError(), actual.getError());
|
||||
Assert.assertEquals(expected.getIpAddress(), actual.getIpAddress());
|
||||
Assert.assertThat(actual.getUserId(), userId);
|
||||
Assert.assertThat(actual.getSessionId(), sessionId);
|
||||
Assert.assertThat("type", actual.getType(), is(expected.getType()));
|
||||
Assert.assertThat("realm ID", actual.getRealmId(), is(realmId));
|
||||
Assert.assertThat("client ID", actual.getClientId(), is(expected.getClientId()));
|
||||
Assert.assertThat("error", actual.getError(), is(expected.getError()));
|
||||
Assert.assertThat("ip address", actual.getIpAddress(), is(expected.getIpAddress()));
|
||||
Assert.assertThat("user ID", actual.getUserId(), is(userId));
|
||||
Assert.assertThat("session ID", actual.getSessionId(), is(sessionId));
|
||||
|
||||
if (details == null || details.isEmpty()) {
|
||||
// Assert.assertNull(actual.getDetails());
|
||||
|
@ -292,7 +293,7 @@ public class AssertEvents implements TestRule {
|
|||
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()) {
|
||||
|
|
|
@ -17,9 +17,6 @@
|
|||
package org.keycloak.testsuite.forms;
|
||||
|
||||
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.Errors;
|
||||
import org.keycloak.events.EventType;
|
||||
|
@ -51,8 +48,7 @@ import java.util.Map;
|
|||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import org.junit.*;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
|
||||
|
@ -172,16 +168,34 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
@Ignore
|
||||
public void resetPasswordTwice() throws IOException, MessagingException {
|
||||
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());
|
||||
|
||||
errorPage.assertCurrent();
|
||||
assertEquals("An error occurred, please login again through your application.", errorPage.getError());
|
||||
|
||||
events.expect(EventType.RESET_PASSWORD)
|
||||
.client((String) null)
|
||||
.client(clientId)
|
||||
.session((String) null)
|
||||
.user(userId)
|
||||
.detail(Details.USERNAME, "login-test")
|
||||
|
@ -337,19 +351,19 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest {
|
|||
|
||||
@Test
|
||||
public void resetPasswordExpiredCode() throws IOException, MessagingException, InterruptedException {
|
||||
initiateResetPasswordFromResetPasswordPage("login-test");
|
||||
|
||||
events.expectRequiredAction(EventType.SEND_RESET_PASSWORD)
|
||||
.session((String)null)
|
||||
.user(userId).detail(Details.USERNAME, "login-test").detail(Details.EMAIL, "login@test.com").assertEvent();
|
||||
|
||||
assertEquals(1, greenMail.getReceivedMessages().length);
|
||||
|
||||
MimeMessage message = greenMail.getReceivedMessages()[0];
|
||||
|
||||
String changePasswordUrl = getPasswordResetEmailLink(message);
|
||||
|
||||
try {
|
||||
initiateResetPasswordFromResetPasswordPage("login-test");
|
||||
|
||||
events.expectRequiredAction(EventType.SEND_RESET_PASSWORD)
|
||||
.session((String)null)
|
||||
.user(userId).detail(Details.USERNAME, "login-test").detail(Details.EMAIL, "login@test.com").assertEvent();
|
||||
|
||||
assertEquals(1, greenMail.getReceivedMessages().length);
|
||||
|
||||
MimeMessage message = greenMail.getReceivedMessages()[0];
|
||||
|
||||
String changePasswordUrl = getPasswordResetEmailLink(message);
|
||||
|
||||
setTimeOffset(1800 + 23);
|
||||
|
||||
driver.navigate().to(changePasswordUrl.trim());
|
||||
|
@ -590,6 +604,7 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest {
|
|||
|
||||
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.navigate().to(changePasswordUrl.trim());
|
||||
|
||||
|
|
Loading…
Reference in a new issue