From 19a41c8704b8d26f78a8bf07060e03777dfab8ea Mon Sep 17 00:00:00 2001 From: Hynek Mlnarik Date: Mon, 6 Mar 2017 14:45:57 +0100 Subject: [PATCH] KEYCLOAK-4627 Refactor TokenVerifier to support more than just access token checks. Action tokens implementation with reset e-mail action converted to AT --- .../java/org/keycloak/RSATokenVerifier.java | 4 +- .../main/java/org/keycloak/TokenVerifier.java | 288 +++++++++--- .../exceptions/TokenNotActiveException.java | 43 ++ .../TokenSignatureInvalidException.java | 42 ++ .../authentication/DefaultActionToken.java | 103 ++++ .../authentication/DefaultActionTokenKey.java | 43 ++ .../ResetCredentialsActionToken.java | 148 ++++++ .../resetcred/ResetCredentialEmail.java | 98 ++-- .../keycloak/protocol/RestartLoginCookie.java | 7 +- .../managers/AuthenticationManager.java | 7 +- .../resources/LoginActionsService.java | 445 +++++++++++++----- .../LoginActionsServiceException.java | 53 +++ .../testsuite/forms/ResetPasswordTest.java | 234 ++++----- 13 files changed, 1171 insertions(+), 344 deletions(-) create mode 100644 core/src/main/java/org/keycloak/exceptions/TokenNotActiveException.java create mode 100644 core/src/main/java/org/keycloak/exceptions/TokenSignatureInvalidException.java create mode 100644 services/src/main/java/org/keycloak/authentication/DefaultActionToken.java create mode 100644 services/src/main/java/org/keycloak/authentication/DefaultActionTokenKey.java create mode 100644 services/src/main/java/org/keycloak/authentication/ResetCredentialsActionToken.java create mode 100644 services/src/main/java/org/keycloak/services/resources/LoginActionsServiceException.java diff --git a/core/src/main/java/org/keycloak/RSATokenVerifier.java b/core/src/main/java/org/keycloak/RSATokenVerifier.java index db8fc5ae5b..653f205d33 100755 --- a/core/src/main/java/org/keycloak/RSATokenVerifier.java +++ b/core/src/main/java/org/keycloak/RSATokenVerifier.java @@ -29,10 +29,10 @@ import java.security.PublicKey; */ public class RSATokenVerifier { - private TokenVerifier tokenVerifier; + private final TokenVerifier tokenVerifier; private RSATokenVerifier(String tokenString) { - this.tokenVerifier = TokenVerifier.create(tokenString); + this.tokenVerifier = TokenVerifier.create(tokenString, AccessToken.class); } public static RSATokenVerifier create(String tokenString) { diff --git a/core/src/main/java/org/keycloak/TokenVerifier.java b/core/src/main/java/org/keycloak/TokenVerifier.java index 9c30bfdc69..6bfcb3bf32 100755 --- a/core/src/main/java/org/keycloak/TokenVerifier.java +++ b/core/src/main/java/org/keycloak/TokenVerifier.java @@ -18,7 +18,8 @@ package org.keycloak; import org.keycloak.common.VerificationException; -import org.keycloak.jose.jws.Algorithm; +import org.keycloak.exceptions.TokenNotActiveException; +import org.keycloak.exceptions.TokenSignatureInvalidException; import org.keycloak.jose.jws.AlgorithmType; import org.keycloak.jose.jws.JWSHeader; import org.keycloak.jose.jws.JWSInput; @@ -26,67 +27,235 @@ import org.keycloak.jose.jws.JWSInputException; import org.keycloak.jose.jws.crypto.HMACProvider; import org.keycloak.jose.jws.crypto.RSAProvider; import org.keycloak.representations.AccessToken; +import org.keycloak.representations.JsonWebToken; import org.keycloak.util.TokenUtil; import javax.crypto.SecretKey; import java.security.PublicKey; +import java.util.*; /** * @author Bill Burke * @version $Revision: 1 $ */ -public class TokenVerifier { +public class TokenVerifier { - private final String tokenString; + // 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. + + // @FunctionalInterface + public static interface Predicate { + /** + * Performs a single check on the given token verifier. + * @param t Token, guaranteed to be non-null. + * @return + * @throws VerificationException + */ + boolean test(T t) throws VerificationException; + } + + public static final Predicate SUBJECT_EXISTS_CHECK = new Predicate() { + @Override + public boolean test(JsonWebToken t) throws VerificationException { + String subject = t.getSubject(); + if (subject == null) { + throw new VerificationException("Subject missing in token"); + } + + return true; + } + }; + + public static final Predicate IS_ACTIVE = new Predicate() { + @Override + public boolean test(JsonWebToken t) throws VerificationException { + if (! t.isActive()) { + throw new TokenNotActiveException("Token is not active"); + } + + return true; + } + }; + + public static class RealmUrlCheck implements Predicate { + + private static final RealmUrlCheck NULL_INSTANCE = new RealmUrlCheck(null); + + private final String realmUrl; + + public RealmUrlCheck(String realmUrl) { + this.realmUrl = realmUrl; + } + + @Override + public boolean test(JsonWebToken t) throws VerificationException { + if (this.realmUrl == null) { + throw new VerificationException("Realm URL not set"); + } + + if (! this.realmUrl.equals(t.getIssuer())) { + throw new VerificationException("Invalid token issuer. Expected '" + this.realmUrl + "', but was '" + t.getIssuer() + "'"); + } + + return true; + } + }; + + public static class TokenTypeCheck implements Predicate { + + private static final TokenTypeCheck INSTANCE_BEARER = new TokenTypeCheck(TokenUtil.TOKEN_TYPE_BEARER); + + private final String tokenType; + + public TokenTypeCheck(String tokenType) { + this.tokenType = tokenType; + } + + @Override + public boolean test(JsonWebToken t) throws VerificationException { + if (! tokenType.equalsIgnoreCase(t.getType())) { + throw new VerificationException("Token type is incorrect. Expected '" + tokenType + "' but was '" + t.getType() + "'"); + } + return true; + } + }; + + private String tokenString; + private Class clazz; private PublicKey publicKey; private SecretKey secretKey; private String realmUrl; + private String expectedTokenType = TokenUtil.TOKEN_TYPE_BEARER; private boolean checkTokenType = true; - private boolean checkActive = true; private boolean checkRealmUrl = true; + private final LinkedList> checks = new LinkedList<>(); private JWSInput jws; - private AccessToken token; + private T token; - protected TokenVerifier(String tokenString) { + protected TokenVerifier(String tokenString, Class clazz) { this.tokenString = tokenString; + this.clazz = clazz; } - public static TokenVerifier create(String tokenString) { - return new TokenVerifier(tokenString); + protected TokenVerifier(T token) { + this.token = token; } - public TokenVerifier publicKey(PublicKey publicKey) { + /** + * Creates a {@code TokenVerifier instance. The method is here for backwards compatibility. + * @param tokenString + * @return + * @deprecated use {@link #create(java.lang.String, java.lang.Class) } instead + */ + public static TokenVerifier create(String tokenString) { + return create(tokenString, AccessToken.class); + } + + public static TokenVerifier create(String tokenString, Class clazz) { + return new TokenVerifier(tokenString, clazz) + .check(RealmUrlCheck.NULL_INSTANCE) + .check(SUBJECT_EXISTS_CHECK) + .check(TokenTypeCheck.INSTANCE_BEARER) + .check(IS_ACTIVE); + } + + public static TokenVerifier from(T token) { + return new TokenVerifier(token) + .check(RealmUrlCheck.NULL_INSTANCE) + .check(SUBJECT_EXISTS_CHECK) + .check(TokenTypeCheck.INSTANCE_BEARER) + .check(IS_ACTIVE); + } + + private void removeCheck(Class> checkClass) { + for (Iterator> it = checks.iterator(); it.hasNext();) { + if (it.next().getClass() == checkClass) { + it.remove(); + } + } + } + + private void removeCheck(Predicate check) { + checks.remove(check); + } + + private

> TokenVerifier replaceCheck(Class> checkClass, boolean active, P predicate) { + removeCheck(checkClass); + if (active) { + checks.add(predicate); + } + return this; + } + + private

> TokenVerifier replaceCheck(Predicate check, boolean active, P predicate) { + removeCheck(check); + if (active) { + checks.add(predicate); + } + return this; + } + + /** + * Resets all preset checks and will test the given checks in {@link #verify()} method. + * @param checks + * @return + */ + public TokenVerifier checkOnly(Predicate... checks) { + this.checks.clear(); + if (checks != null) { + this.checks.addAll(Arrays.asList(checks)); + } + return this; + } + + /** + * Will test the given checks in {@link #verify()} method in addition to already set checks. + * @param checks + * @return + */ + public TokenVerifier check(Predicate... checks) { + if (checks != null) { + this.checks.addAll(Arrays.asList(checks)); + } + return this; + } + + public TokenVerifier publicKey(PublicKey publicKey) { this.publicKey = publicKey; return this; } - public TokenVerifier secretKey(SecretKey secretKey) { + public TokenVerifier secretKey(SecretKey secretKey) { this.secretKey = secretKey; return this; } - public TokenVerifier realmUrl(String realmUrl) { + public TokenVerifier realmUrl(String realmUrl) { this.realmUrl = realmUrl; - return this; + return replaceCheck(RealmUrlCheck.class, checkRealmUrl, new RealmUrlCheck(realmUrl)); } - public TokenVerifier checkTokenType(boolean checkTokenType) { + public TokenVerifier checkTokenType(boolean checkTokenType) { this.checkTokenType = checkTokenType; - return this; + return replaceCheck(TokenTypeCheck.class, this.checkTokenType, new TokenTypeCheck(expectedTokenType)); } - public TokenVerifier checkActive(boolean checkActive) { - this.checkActive = checkActive; - return this; + public TokenVerifier tokenType(String tokenType) { + this.expectedTokenType = tokenType; + return replaceCheck(TokenTypeCheck.class, this.checkTokenType, new TokenTypeCheck(expectedTokenType)); } - public TokenVerifier checkRealmUrl(boolean checkRealmUrl) { + public TokenVerifier checkActive(boolean checkActive) { + return replaceCheck(IS_ACTIVE, checkActive, IS_ACTIVE); + } + + public TokenVerifier checkRealmUrl(boolean checkRealmUrl) { this.checkRealmUrl = checkRealmUrl; - return this; + return replaceCheck(RealmUrlCheck.class, this.checkRealmUrl, new RealmUrlCheck(realmUrl)); } - public TokenVerifier parse() throws VerificationException { + public TokenVerifier parse() throws VerificationException { if (jws == null) { if (tokenString == null) { throw new VerificationException("Token not set"); @@ -100,7 +269,7 @@ public class TokenVerifier { try { - token = jws.readJsonContent(AccessToken.class); + token = jws.readJsonContent(clazz); } catch (JWSInputException e) { throw new VerificationException("Failed to read access token from JWT", e); } @@ -108,8 +277,10 @@ public class TokenVerifier { return this; } - public AccessToken getToken() throws VerificationException { - parse(); + public T getToken() throws VerificationException { + if (token == null) { + parse(); + } return token; } @@ -118,50 +289,43 @@ public class TokenVerifier { return jws.getHeader(); } - public TokenVerifier verify() throws VerificationException { - parse(); - - if (checkRealmUrl && realmUrl == null) { - throw new VerificationException("Realm URL not set"); - } - + public void verifySignature() throws VerificationException { AlgorithmType algorithmType = getHeader().getAlgorithm().getType(); - if (AlgorithmType.RSA.equals(algorithmType)) { - if (publicKey == null) { - throw new VerificationException("Public key not set"); - } + if (null == algorithmType) { + throw new VerificationException("Unknown or unsupported token algorithm"); + } else switch (algorithmType) { + case RSA: + if (publicKey == null) { + throw new VerificationException("Public key not set"); + } + if (!RSAProvider.verify(jws, publicKey)) { + throw new TokenSignatureInvalidException("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"); + } break; + default: + throw new VerificationException("Unknown or unsupported token algorithm"); + } + } - if (!RSAProvider.verify(jws, publicKey)) { - throw new VerificationException("Invalid token signature"); - } - } else if (AlgorithmType.HMAC.equals(algorithmType)) { - if (secretKey == null) { - throw new VerificationException("Secret key not set"); - } - - if (!HMACProvider.verify(jws, secretKey)) { - throw new VerificationException("Invalid token signature"); - } - } else { - throw new VerificationException("Unknown or unsupported token algorith"); + public TokenVerifier verify() throws VerificationException { + if (getToken() == null) { + parse(); + } + if (jws != null) { + verifySignature(); } - String user = token.getSubject(); - if (user == null) { - throw new VerificationException("Subject missing in token"); - } - - if (checkRealmUrl && !realmUrl.equals(token.getIssuer())) { - throw new VerificationException("Invalid token issuer. Expected '" + realmUrl + "', but was '" + token.getIssuer() + "'"); - } - - if (checkTokenType && !TokenUtil.TOKEN_TYPE_BEARER.equalsIgnoreCase(token.getType())) { - throw new VerificationException("Token type is incorrect. Expected '" + TokenUtil.TOKEN_TYPE_BEARER + "' but was '" + token.getType() + "'"); - } - - if (checkActive && !token.isActive()) { - throw new VerificationException("Token is not active"); + for (Predicate check : checks) { + if (! check.test(getToken())) { + throw new VerificationException("JWT check failed for check " + check); + } } return this; diff --git a/core/src/main/java/org/keycloak/exceptions/TokenNotActiveException.java b/core/src/main/java/org/keycloak/exceptions/TokenNotActiveException.java new file mode 100644 index 0000000000..2253f5ed7d --- /dev/null +++ b/core/src/main/java/org/keycloak/exceptions/TokenNotActiveException.java @@ -0,0 +1,43 @@ +/* + * 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; + +/** + * 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 TokenNotActiveException() { + } + + public TokenNotActiveException(String message) { + super(message); + } + + public TokenNotActiveException(String message, Throwable cause) { + super(message, cause); + } + + public TokenNotActiveException(Throwable cause) { + super(cause); + } + +} diff --git a/core/src/main/java/org/keycloak/exceptions/TokenSignatureInvalidException.java b/core/src/main/java/org/keycloak/exceptions/TokenSignatureInvalidException.java new file mode 100644 index 0000000000..13225fa9cd --- /dev/null +++ b/core/src/main/java/org/keycloak/exceptions/TokenSignatureInvalidException.java @@ -0,0 +1,42 @@ +/* + * 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; + +/** + * Thrown when token signature is invalid. + * @author hmlnarik + */ +public class TokenSignatureInvalidException extends VerificationException { + + public TokenSignatureInvalidException() { + } + + public TokenSignatureInvalidException(String message) { + super(message); + } + + public TokenSignatureInvalidException(String message, Throwable cause) { + super(message, cause); + } + + public TokenSignatureInvalidException(Throwable cause) { + super(cause); + } + +} diff --git a/services/src/main/java/org/keycloak/authentication/DefaultActionToken.java b/services/src/main/java/org/keycloak/authentication/DefaultActionToken.java new file mode 100644 index 0000000000..8c51d1f83a --- /dev/null +++ b/services/src/main/java/org/keycloak/authentication/DefaultActionToken.java @@ -0,0 +1,103 @@ +/* + * 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.common.VerificationException; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.*; + +/** + * Part of action token that is intended to be used e.g. in link sent in password-reset email. + * The token encapsulates user, expected action and its time of expiry. + * + * @author hmlnarik + */ +public class DefaultActionToken extends DefaultActionTokenKey { + + public static final String JSON_FIELD_ACTION_VERIFICATION_NONCE = "nonce"; + + public static Predicate ACTION_TOKEN_BASIC_CHECKS = t -> { + if (t.getActionVerificationNonce() == null) { + throw new VerificationException("Nonce not present."); + } + + return true; + }; + + /** + * Single-use random value used for verification whether the relevant action is allowed. + */ + @JsonProperty(value = JSON_FIELD_ACTION_VERIFICATION_NONCE, required = true) + private final UUID actionVerificationNonce; + + public DefaultActionToken(String userId, String actionId, int expirationInSecs) { + this(userId, actionId, expirationInSecs, UUID.randomUUID()); + } + + /** + * + * @param userId User ID + * @param actionId Action ID + * @param absoluteExpirationInSecs Absolute expiration time in seconds in timezone of Keycloak. + * @param actionVerificationNonce + */ + protected DefaultActionToken(String userId, String actionId, int absoluteExpirationInSecs, UUID actionVerificationNonce) { + super(userId, actionId); + this.actionVerificationNonce = actionVerificationNonce == null ? UUID.randomUUID() : actionVerificationNonce; + expiration = absoluteExpirationInSecs; + } + + public UUID getActionVerificationNonce() { + return actionVerificationNonce; + } + + @JsonIgnore + public Map getNotes() { + Map res = new HashMap<>(); + return res; + } + + public String getNote(String name) { + Object res = getOtherClaims().get(name); + return res instanceof String ? (String) res : null; + } + + /** + * Sets value of the given note + * @return original value (or {@code null} when no value was present) + */ + public final String setNote(String name, String value) { + Object res = value == null + ? getOtherClaims().remove(name) + : getOtherClaims().put(name, value); + return res instanceof String ? (String) res : null; + } + + /** + * Removes given note, and returns original value (or {@code null} when no value was present) + * @return see description + */ + public final String removeNote(String name) { + Object res = getOtherClaims().remove(name); + return res instanceof String ? (String) res : null; + } + +} diff --git a/services/src/main/java/org/keycloak/authentication/DefaultActionTokenKey.java b/services/src/main/java/org/keycloak/authentication/DefaultActionTokenKey.java new file mode 100644 index 0000000000..f9d44db254 --- /dev/null +++ b/services/src/main/java/org/keycloak/authentication/DefaultActionTokenKey.java @@ -0,0 +1,43 @@ +/* + * 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.representations.JsonWebToken; +import com.fasterxml.jackson.annotation.JsonIgnore; + +/** + * + * @author hmlnarik + */ +public class DefaultActionTokenKey extends JsonWebToken { + + public DefaultActionTokenKey(String userId, String actionId) { + subject = userId; + type = actionId; + } + + @JsonIgnore + public String getUserId() { + return getSubject(); + } + + @JsonIgnore + public String getActionId() { + return getType(); + } + +} diff --git a/services/src/main/java/org/keycloak/authentication/ResetCredentialsActionToken.java b/services/src/main/java/org/keycloak/authentication/ResetCredentialsActionToken.java new file mode 100644 index 0000000000..ef9277090f --- /dev/null +++ b/services/src/main/java/org/keycloak/authentication/ResetCredentialsActionToken.java @@ -0,0 +1,148 @@ +/* + * 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; +import org.keycloak.TokenVerifier.Predicate; +import org.keycloak.common.VerificationException; +import org.keycloak.common.util.Time; +import org.keycloak.jose.jws.*; +import org.keycloak.models.*; +import org.keycloak.services.Urls; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Map; +import java.util.UUID; +import javax.ws.rs.core.UriInfo; +import org.jboss.logging.Logger; + +/** + * Representation of a token that represents a time-limited reset credentials action. + *

+ * This implementation handles signature. + * + * @author hmlnarik + */ +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 NOTE_CLIENT_SESSION_ID = "clientSessionId"; + private static final String JSON_FIELD_CLIENT_SESSION_ID = "csid"; + private static final String JSON_FIELD_LAST_CHANGE_PASSWORD_TIMESTAMP = "lcpt"; + + @JsonIgnore + private ClientSessionModel clientSession; + + @JsonProperty(value = JSON_FIELD_LAST_CHANGE_PASSWORD_TIMESTAMP) + private Long lastChangedPasswordTimestamp; + + public ResetCredentialsActionToken(String userId, int absoluteExpirationInSecs, UUID actionVerificationNonce, Long lastChangedPasswordTimestamp, String clientSessionId) { + super(userId, RESET_CREDENTIALS_ACTION, absoluteExpirationInSecs, actionVerificationNonce); + setNote(NOTE_CLIENT_SESSION_ID, clientSessionId); + this.lastChangedPasswordTimestamp = lastChangedPasswordTimestamp; + } + + public ResetCredentialsActionToken(String userId, int absoluteExpirationInSecs, UUID actionVerificationNonce, Long lastChangedPasswordTimestamp, ClientSessionModel clientSession) { + this(userId, absoluteExpirationInSecs, actionVerificationNonce, lastChangedPasswordTimestamp, clientSession == null ? null : clientSession.getId()); + this.clientSession = clientSession; + } + + private ResetCredentialsActionToken() { + super(null, null, -1, null); + } + + public ClientSessionModel getClientSession() { + return this.clientSession; + } + + public void setClientSession(ClientSessionModel clientSession) { + this.clientSession = clientSession; + setClientSessionId(clientSession == null ? null : clientSession.getId()); + } + + @JsonProperty(value = JSON_FIELD_CLIENT_SESSION_ID) + public String getClientSessionId() { + return getNote(NOTE_CLIENT_SESSION_ID); + } + + public void setClientSessionId(String clientSessionId) { + setNote(NOTE_CLIENT_SESSION_ID, clientSessionId); + } + + public Long getLastChangedPasswordTimestamp() { + return lastChangedPasswordTimestamp; + } + + public void setLastChangedPasswordTimestamp(Long lastChangedPasswordTimestamp) { + this.lastChangedPasswordTimestamp = lastChangedPasswordTimestamp; + } + + @Override + @JsonIgnore + public Map getNotes() { + Map res = super.getNotes(); + if (this.clientSession != null) { + res.put(NOTE_CLIENT_SESSION_ID, getNote(NOTE_CLIENT_SESSION_ID)); + } + return res; + } + + public String serialize(KeycloakSession session, RealmModel realm, UriInfo uri) { + String issuerUri = getIssuer(realm, uri); + KeyManager.ActiveHmacKey keys = session.keys().getActiveHmacKey(realm); + + this + .issuedAt(Time.currentTime()) + .id(getActionVerificationNonce().toString()) + .issuer(issuerUri) + .audience(issuerUri); + + return new JWSBuilder() + .kid(keys.getKid()) + .jsonContent(this) + .hmac512(keys.getSecretKey()); + } + + private static String getIssuer(RealmModel realm, UriInfo uri) { + return Urls.realmIssuer(uri.getBaseUri(), realm.getName()); + } + + /** + * Returns a {@code DefaultActionToken} instance decoded from the given string. If decoding fails, returns {@code null} + * + * @param session + * @param actionTokenString + * @return + */ + public static ResetCredentialsActionToken deserialize(KeycloakSession session, RealmModel realm, UriInfo uri, String token, + Predicate... 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() + ; + } +} diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/resetcred/ResetCredentialEmail.java b/services/src/main/java/org/keycloak/authentication/authenticators/resetcred/ResetCredentialEmail.java index e74fa20882..8f21ddf4f6 100755 --- a/services/src/main/java/org/keycloak/authentication/authenticators/resetcred/ResetCredentialEmail.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/resetcred/ResetCredentialEmail.java @@ -19,33 +19,27 @@ package org.keycloak.authentication.authenticators.resetcred; import org.jboss.logging.Logger; import org.keycloak.Config; -import org.keycloak.authentication.AuthenticationFlowContext; -import org.keycloak.authentication.AuthenticationFlowError; -import org.keycloak.authentication.Authenticator; -import org.keycloak.authentication.AuthenticatorFactory; +import org.keycloak.authentication.*; import org.keycloak.authentication.authenticators.browser.AbstractUsernameFormAuthenticator; +import org.keycloak.common.VerificationException; +import org.keycloak.common.util.Time; +import org.keycloak.credential.*; import org.keycloak.email.EmailException; import org.keycloak.email.EmailTemplateProvider; import org.keycloak.events.Details; import org.keycloak.events.Errors; import org.keycloak.events.EventBuilder; import org.keycloak.events.EventType; -import org.keycloak.models.AuthenticationExecutionModel; -import org.keycloak.models.Constants; -import org.keycloak.models.KeycloakSession; -import org.keycloak.models.KeycloakSessionFactory; -import org.keycloak.models.RealmModel; -import org.keycloak.models.UserModel; +import org.keycloak.models.*; import org.keycloak.models.utils.FormMessage; -import org.keycloak.models.utils.HmacOTP; import org.keycloak.provider.ProviderConfigProperty; import org.keycloak.services.ServicesLogger; import org.keycloak.services.messages.Messages; import org.keycloak.services.resources.LoginActionsService; +import java.util.*; import javax.ws.rs.core.Response; import javax.ws.rs.core.UriBuilder; -import java.util.List; import java.util.concurrent.TimeUnit; /** @@ -53,9 +47,6 @@ import java.util.concurrent.TimeUnit; * @version $Revision: 1 $ */ public class ResetCredentialEmail implements Authenticator, AuthenticatorFactory { - public static final String RESET_CREDENTIAL_SECRET = "RESET_CREDENTIAL_SECRET"; - - private static final Logger logger = Logger.getLogger(ResetCredentialEmail.class); public static final String PROVIDER_ID = "reset-credential-email"; @@ -85,15 +76,25 @@ public class ResetCredentialEmail implements Authenticator, AuthenticatorFactory return; } - // We send the secret in the email in a link as a query param. We don't need to sign it or anything because - // it can only be guessed once, and it must match watch is stored in the client session. - String secret = HmacOTP.generateSecret(10); - context.getClientSession().setNote(RESET_CREDENTIAL_SECRET, secret); - String link = UriBuilder.fromUri(context.getActionUrl()).queryParam(Constants.KEY, secret).build().toString(); - long expiration = TimeUnit.SECONDS.toMinutes(context.getRealm().getAccessCodeLifespanUserAction()); + 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(); + + // We send the secret in the email in a link as a query param. + ResetCredentialsActionToken token = new ResetCredentialsActionToken(user.getId(), absoluteExpirationInSecs, null, lastCreatedPassword, context.getClientSession()); + KeycloakSession keycloakSession = context.getSession(); + String link = UriBuilder + .fromUri(context.getActionUrl()) + .queryParam(Constants.KEY, token.serialize(keycloakSession, context.getRealm(), context.getUriInfo())) + .build() + .toString(); + long expirationInMinutes = TimeUnit.SECONDS.toMinutes(validityInSecs); try { - context.getSession().getProvider(EmailTemplateProvider.class).setRealm(context.getRealm()).setUser(user).sendPasswordReset(link, expiration); + context.getSession().getProvider(EmailTemplateProvider.class).setRealm(context.getRealm()).setUser(user).sendPasswordReset(link, expirationInMinutes); event.clone().event(EventType.SEND_RESET_PASSWORD) .user(user) .detail(Details.USERNAME, username) @@ -114,19 +115,56 @@ public class ResetCredentialEmail implements Authenticator, AuthenticatorFactory @Override public void action(AuthenticationFlowContext context) { - /*String secret = context.getClientSession().getNote(RESET_CREDENTIAL_SECRET); - String key = context.getUriInfo().getQueryParameters().getFirst(Constants.KEY); - - // Can only guess once! We remove the note so another guess can't happen - context.getClientSession().removeNote(RESET_CREDENTIAL_SECRET); - if (secret == null || key == null || !secret.equals(key)) { - context.getEvent().error(Errors.INVALID_USER_CREDENTIALS); + /* + KeycloakSession keycloakSession = context.getSession(); + String actionTokenString = context.getUriInfo().getQueryParameters().getFirst(Constants.KEY); + 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_ACCESS_CODE) + .setError(Messages.INVALID_CODE) + .createErrorPage(); + context.failure(AuthenticationFlowError.INTERNAL_ERROR, challenge); + } + + String userId = tokenFromMail == null ? null : tokenFromMail.getUserId(); + + if (tokenFromMail == null) { + context.getEvent() + .error(Errors.INVALID_CODE); + Response challenge = context.form() + .setError(Messages.INVALID_CODE) .createErrorPage(); context.failure(AuthenticationFlowError.INTERNAL_ERROR, challenge); return; } + + PasswordCredentialProvider passwordProvider = (PasswordCredentialProvider) context.getSession().getProvider(CredentialProvider.class, PasswordCredentialProviderFactory.PROVIDER_ID); + CredentialModel password = passwordProvider.getPassword(context.getRealm(), context.getUser()); + + Long lastCreatedPasswordMail = tokenFromMail.getLastChangedPasswordTimestamp(); + Long lastCreatedPasswordFromStore = password == null ? null : password.getCreatedDate(); + + String clientSessionId = tokenFromMail.getClientSessionId(); + ClientSessionModel clientSession = clientSessionId == null ? null : keycloakSession.sessions().getClientSession(clientSessionId); + + if (clientSession == null + || ! Objects.equals(lastCreatedPasswordMail, lastCreatedPasswordFromStore) + || ! Objects.equals(userId, context.getUser().getId())) { + context.getEvent() + .user(userId) + .detail(Details.USERNAME, context.getUser().getUsername()) + .detail(Details.TOKEN_ID, tokenFromMail.getId()) + .error(Errors.EXPIRED_CODE); + Response challenge = context.form() + .setError(Messages.INVALID_CODE) + .createErrorPage(); + context.failure(AuthenticationFlowError.INTERNAL_ERROR, challenge); + return; + } + // We now know email is valid, so set it to valid. context.getUser().setEmailVerified(true); context.success();*/ diff --git a/services/src/main/java/org/keycloak/protocol/RestartLoginCookie.java b/services/src/main/java/org/keycloak/protocol/RestartLoginCookie.java index 4fda88959f..109f2c935f 100644 --- a/services/src/main/java/org/keycloak/protocol/RestartLoginCookie.java +++ b/services/src/main/java/org/keycloak/protocol/RestartLoginCookie.java @@ -154,6 +154,11 @@ public class RestartLoginCookie { // TODO:mposolda /* public static ClientSessionModel restartSession(KeycloakSession session, RealmModel realm, String code) throws Exception { + String[] parts = code.split("\\."); + return restartSessionByClientSession(session, realm, parts[1]); + } + + public static ClientSessionModel restartSessionByClientSession(KeycloakSession session, RealmModel realm, String clientSessionId) throws Exception { Cookie cook = session.getContext().getRequestHeaders().getCookies().get(KC_RESTART); if (cook == null) { logger.debug("KC_RESTART cookie doesn't exist"); @@ -167,8 +172,6 @@ public class RestartLoginCookie { return null; } RestartLoginCookie cookie = input.readJsonContent(RestartLoginCookie.class); - String[] parts = code.split("\\."); - String clientSessionId = parts[1]; if (!clientSessionId.equals(cookie.getClientSession())) { logger.debug("RestartLoginCookie clientSession does not match code's clientSession"); return null; diff --git a/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java b/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java index 3cb8c68655..e460a1dbcd 100755 --- a/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java +++ b/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java @@ -127,7 +127,10 @@ public class AuthenticationManager { if (cookie == null) return; String tokenString = cookie.getValue(); - TokenVerifier verifier = TokenVerifier.create(tokenString).realmUrl(Urls.realmIssuer(uriInfo.getBaseUri(), realm.getName())).checkActive(false).checkTokenType(false); + TokenVerifier verifier = TokenVerifier.create(tokenString, AccessToken.class) + .realmUrl(Urls.realmIssuer(uriInfo.getBaseUri(), realm.getName())) + .checkActive(false) + .checkTokenType(false); String kid = verifier.getHeader().getKeyId(); SecretKey secretKey = session.keys().getHmacSecretKey(realm, kid); @@ -710,7 +713,7 @@ 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 verifier = TokenVerifier.create(tokenString).realmUrl(Urls.realmIssuer(uriInfo.getBaseUri(), realm.getName())).checkActive(checkActive).checkTokenType(checkTokenType); + TokenVerifier verifier = TokenVerifier.create(tokenString).realmUrl(Urls.realmIssuer(uriInfo.getBaseUri(), realm.getName())).checkActive(checkActive).checkTokenType(checkTokenType); String kid = verifier.getHeader().getKeyId(); AlgorithmType algorithmType = verifier.getHeader().getAlgorithm().getType(); diff --git a/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java b/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java index 3c9b40b4af..9633212828 100755 --- a/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java +++ b/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java @@ -24,13 +24,13 @@ import org.keycloak.authentication.RequiredActionContext; import org.keycloak.authentication.RequiredActionContextResult; 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.authentication.authenticators.broker.AbstractIdpAuthenticator; -import org.keycloak.authentication.authenticators.broker.util.PostBrokerLoginConstants; -import org.keycloak.authentication.authenticators.broker.util.SerializedBrokeredIdentityContext; import org.keycloak.authentication.authenticators.browser.AbstractUsernameFormAuthenticator; -import org.keycloak.authentication.requiredactions.VerifyEmail; -import org.keycloak.broker.provider.BrokeredIdentityContext; import org.keycloak.common.ClientConnection; +import org.keycloak.common.VerificationException; import org.keycloak.common.util.Time; import org.keycloak.events.Details; import org.keycloak.events.Errors; @@ -48,7 +48,6 @@ import org.keycloak.models.RealmModel; import org.keycloak.models.RoleModel; import org.keycloak.models.UserConsentModel; import org.keycloak.models.UserModel; -import org.keycloak.models.UserModel.RequiredAction; import org.keycloak.models.UserSessionModel; import org.keycloak.models.utils.FormMessage; import org.keycloak.protocol.LoginProtocol; @@ -57,11 +56,14 @@ import org.keycloak.protocol.RestartLoginCookie; import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.utils.OIDCResponseMode; import org.keycloak.protocol.oidc.utils.OIDCResponseType; +import org.keycloak.representations.JsonWebToken; import org.keycloak.services.ErrorPage; 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.managers.ClientSessionCode.ParseResult; import org.keycloak.services.messages.Messages; import org.keycloak.services.util.CacheControlUtil; import org.keycloak.services.util.CookieHelper; @@ -84,6 +86,7 @@ import javax.ws.rs.core.UriBuilder; import javax.ws.rs.core.UriInfo; import javax.ws.rs.ext.Providers; import java.net.URI; +import java.util.Objects; /** * @author Stian Thorgersen @@ -166,24 +169,51 @@ public class LoginActionsService { } } + private SessionCodeChecks checksForCode(String code, Class expectedClazz) { + SessionCodeChecks res = new SessionCodeChecks<>(code, expectedClazz); + res.initialVerifyCode(); + return res; + } - private class Checks { - // TODO: Merge with Hynek's code. This may not be just loginSession - ClientSessionCode clientCode; + + + private class SessionCodeChecks { + ClientSessionCode clientCode; Response response; - ClientSessionCode.ParseResult result; + ClientSessionCode.ParseResult result; + Class expectedClazz; - boolean verifyCode(String code, String requiredAction, ClientSessionCode.ActionType actionType) { - if (!verifyCode(code)) { + private final String code; + + public SessionCodeChecks(String code, Class expectedClazz) { + this.code = code; + this.expectedClazz = expectedClazz; + } + + public C getClientSession() { + return clientCode == null ? null : clientCode.getClientSession(); + } + + public boolean passed() { + return response == null; + } + + public boolean failed() { + return response != null; + } + + + boolean verifyCode(String requiredAction, ClientSessionCode.ActionType actionType) { + if (failed()) { return false; } + if (!clientCode.isValidAction(requiredAction)) { - LoginSessionModel loginSession = clientCode.getClientSession(); - if (ClientSessionModel.Action.REQUIRED_ACTIONS.name().equals(loginSession.getAction())) { + C clientSession = getClientSession(); + if (ClientSessionModel.Action.REQUIRED_ACTIONS.name().equals(clientSession.getAction())) { response = redirectToRequiredActions(code); return false; - - } // TODO:mposolda + } // TODO:mposolda /*else if (clientSession.getUserSession() != null && clientSession.getUserSession().getState() == UserSessionModel.State.LOGGED_IN) { response = session.getProvider(LoginFormsProvider.class) .setSuccess(Messages.ALREADY_LOGGED_IN) @@ -191,9 +221,9 @@ public class LoginActionsService { return false; }*/ } - if (!isActionActive(actionType)) return false; - return true; - } + + return isActionActive(actionType); + } private boolean isValidAction(String requiredAction) { if (!clientCode.isValidAction(requiredAction)) { @@ -204,18 +234,19 @@ public class LoginActionsService { } private void invalidAction() { - event.client(clientCode.getClientSession().getClient()); + event.client(getClientSession().getClient()); event.error(Errors.INVALID_CODE); response = ErrorPage.error(session, Messages.INVALID_CODE); } private boolean isActionActive(ClientSessionCode.ActionType actionType) { if (!clientCode.isActionActive(actionType)) { - event.client(clientCode.getClientSession().getClient()); + event.client(getClientSession().getClient()); event.clone().error(Errors.EXPIRED_CODE); - if (clientCode.getClientSession().getAction().equals(ClientSessionModel.Action.AUTHENTICATE.name())) { - AuthenticationProcessor.resetFlow(clientCode.getClientSession()); - response = processAuthentication(null, clientCode.getClientSession(), Messages.LOGIN_TIMEOUT); + if (getClientSession().getAction().equals(ClientSessionModel.Action.AUTHENTICATE.name())) { + LoginSessionModel loginSession = (LoginSessionModel) getClientSession(); + AuthenticationProcessor.resetFlow(loginSession); + response = processAuthentication(null, loginSession, Messages.LOGIN_TIMEOUT); return false; } response = ErrorPage.error(session, Messages.EXPIRED_CODE); @@ -224,7 +255,7 @@ public class LoginActionsService { return true; } - public boolean verifyCode(String code) { + private boolean initialVerifyCode() { if (!checkSsl()) { event.error(Errors.SSL_REQUIRED); response = ErrorPage.error(session, Messages.HTTPS_REQUIRED); @@ -235,14 +266,12 @@ public class LoginActionsService { response = ErrorPage.error(session, Messages.REALM_NOT_ENABLED); return false; } - - // TODO:mposolda it may not be just loginSessionModel - result = ClientSessionCode.parseResult(code, session, realm, LoginSessionModel.class); + result = ClientSessionCode.parseResult(code, session, realm, expectedClazz); clientCode = result.getCode(); if (clientCode == null) { - // TODO:mposolda - /* - if (result.isLoginSessionNotFound()) { // timeout + if (result.isLoginSessionNotFound()) { // timeout or loginSession already logged + // TODO:mposolda + /* try { ClientSessionModel clientSession = RestartLoginCookie.restartSession(session, realm, code); if (clientSession != null) { @@ -252,13 +281,14 @@ public class LoginActionsService { } } catch (Exception e) { ServicesLogger.LOGGER.failedToParseRestartLoginCookie(e); - } + }*/ } event.error(Errors.INVALID_CODE); - response = ErrorPage.error(session, Messages.INVALID_CODE);*/ + response = ErrorPage.error(session, Messages.INVALID_CODE); return false; } - LoginSessionModel clientSession = clientCode.getClientSession(); + + C clientSession = getClientSession(); if (clientSession == null) { event.error(Errors.INVALID_CODE); response = ErrorPage.error(session, Messages.INVALID_CODE); @@ -269,62 +299,48 @@ public class LoginActionsService { if (client == null) { event.error(Errors.CLIENT_NOT_FOUND); response = ErrorPage.error(session, Messages.UNKNOWN_LOGIN_REQUESTER); - session.loginSessions().removeLoginSession(realm, clientSession); + // TODO:mposolda + //session.sessions().removeClientSession(realm, clientSession); return false; } if (!client.isEnabled()) { event.error(Errors.CLIENT_NOT_FOUND); response = ErrorPage.error(session, Messages.LOGIN_REQUESTER_NOT_ENABLED); - session.loginSessions().removeLoginSession(realm, clientSession); + // TODO:mposolda + //session.sessions().removeClientSession(realm, clientSession); return false; } session.getContext().setClient(client); return true; } - public boolean verifyRequiredAction(String code, String executedAction) { - // TODO:mposolda - /* - if (!verifyCode(code)) { + public boolean verifyRequiredAction(String executedAction) { + if (failed()) { return false; } + if (!isValidAction(ClientSessionModel.Action.REQUIRED_ACTIONS.name())) return false; if (!isActionActive(ClientSessionCode.ActionType.USER)) return false; - final ClientSessionModel clientSession = clientCode.getClientSession(); + final LoginSessionModel loginSession = (LoginSessionModel) getClientSession(); - final UserSessionModel userSession = clientSession.getUserSession(); - if (userSession == null) { - ServicesLogger.LOGGER.userSessionNull(); - event.error(Errors.USER_SESSION_NOT_FOUND); - throw new WebApplicationException(ErrorPage.error(session, Messages.SESSION_NOT_ACTIVE)); - } - if (!AuthenticationManager.isSessionValid(realm, userSession)) { - AuthenticationManager.backchannelLogout(session, realm, userSession, uriInfo, clientConnection, headers, true); - event.error(Errors.INVALID_CODE); - response = ErrorPage.error(session, Messages.SESSION_NOT_ACTIVE); - return false; - } - - if (executedAction == null && userSession != null) { // do next required action only if user is already authenticated - initEvent(clientSession); + if (executedAction == null) { // do next required action only if user is already authenticated + initLoginEvent(loginSession); event.event(EventType.LOGIN); - response = AuthenticationManager.nextActionAfterAuthentication(session, userSession, clientSession, clientConnection, request, uriInfo, event); + response = AuthenticationManager.nextActionAfterAuthentication(session, loginSession, clientConnection, request, uriInfo, event); return false; } - if (!executedAction.equals(clientSession.getNote(AuthenticationManager.CURRENT_REQUIRED_ACTION))) { + if (!executedAction.equals(loginSession.getNote(AuthenticationManager.CURRENT_REQUIRED_ACTION))) { logger.debug("required action doesn't match current required action"); - clientSession.removeNote(AuthenticationManager.CURRENT_REQUIRED_ACTION); + loginSession.removeNote(AuthenticationManager.CURRENT_REQUIRED_ACTION); response = redirectToRequiredActions(code); return false; - }*/ + } return true; - } } - /** * protocol independent login page entry point * @@ -341,8 +357,8 @@ public class LoginActionsService { if (loginSession != null && code.equals(loginSession.getNote(LAST_PROCESSED_CODE))) { // Allow refresh of previous page } else { - Checks checks = new Checks(); - if (!checks.verifyCode(code, ClientSessionModel.Action.AUTHENTICATE.name(), ClientSessionCode.ActionType.LOGIN)) { + SessionCodeChecks checks = checksForCode(code, LoginSessionModel.class); + if (!checks.verifyCode(ClientSessionModel.Action.AUTHENTICATE.name(), ClientSessionCode.ActionType.LOGIN)) { return checks.response; } @@ -402,8 +418,8 @@ public class LoginActionsService { return authenticate(code, null); } - Checks checks = new Checks(); - if (!checks.verifyCode(code, ClientSessionModel.Action.AUTHENTICATE.name(), ClientSessionCode.ActionType.LOGIN)) { + SessionCodeChecks checks = checksForCode(code, LoginSessionModel.class); + if (!checks.verifyCode(ClientSessionModel.Action.AUTHENTICATE.name(), ClientSessionCode.ActionType.LOGIN)) { return checks.response; } final ClientSessionCode clientCode = checks.clientCode; @@ -422,6 +438,163 @@ public class LoginActionsService { return null; } + 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.getClientSession().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 { + // TODO:mposolda + /* + String clientSessionId = t == null ? null : t.getNote(ResetCredentialsActionToken.NOTE_CLIENT_SESSION_ID); + + if (t == null || clientSessionId == null) { + event.error(Errors.INVALID_CODE); + throw new LoginActionsServiceException(ErrorPage.error(session, Messages.INVALID_CODE)); + } + + ClientSessionModel clientSession = session.sessions().getClientSession(clientSessionId); + t.setClientSession(clientSession); + + if (clientSession == null) { // timeout + try { + clientSession = RestartLoginCookie.restartSessionByClientSession(session, realm, clientSessionId); + } catch (Exception e) { + ServicesLogger.LOGGER.failedToParseRestartLoginCookie(e); + } + + if (clientSession != null) { + event.clone().detail(Details.RESTART_AFTER_TIMEOUT, "true").error(Errors.EXPIRED_CODE); + throw new LoginActionsServiceException(processFlow(null, clientSession, AUTHENTICATE_PATH, realm.getBrowserFlow(), Messages.LOGIN_TIMEOUT, new AuthenticationProcessor())); + } + } + + if (clientSession == null) { + event.error(Errors.INVALID_CODE); + throw new LoginActionsServiceException(ErrorPage.error(session, Messages.INVALID_CODE)); + } + + event.detail(Details.CODE_ID, clientSession.getId());*/ + + return true; + } + + private boolean canResolveClient(ResetCredentialsActionToken t) throws VerificationException { + ClientModel client = t.getClientSession().getClient(); + if (client == null) { + event.error(Errors.CLIENT_NOT_FOUND); + session.sessions().removeClientSession(realm, t.getClientSession()); + throw new LoginActionsServiceException(ErrorPage.error(session, Messages.UNKNOWN_LOGIN_REQUESTER)); + } + + if (! client.isEnabled()) { + event.error(Errors.CLIENT_NOT_FOUND); + session.sessions().removeClientSession(realm, t.getClientSession()); + throw new LoginActionsServiceException(ErrorPage.error(session, Messages.LOGIN_REQUESTER_NOT_ENABLED)); + } + session.getContext().setClient(client); + + return true; + } + + private class IsValidAction implements Predicate { + + private final String requiredAction; + + public IsValidAction(String requiredAction) { + this.requiredAction = requiredAction; + } + + @Override + public boolean test(ResetCredentialsActionToken t) throws VerificationException { + ClientSessionModel clientSession = t.getClientSession(); + if (! Objects.equals(clientSession.getAction(), this.requiredAction)) { + + if (ClientSessionModel.Action.REQUIRED_ACTIONS.name().equals(clientSession.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(code)); + } 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 { + private final ClientSessionCode.ActionType actionType; + + public IsActiveAction(ActionType actionType) { + this.actionType = actionType; + } + + @Override + public boolean test(ResetCredentialsActionToken t) throws VerificationException { + int timestamp = t.getClientSession().getTimestamp(); + if (! isActionActive(actionType, timestamp)) { + event.client(t.getClientSession().getClient()); + event.clone().error(Errors.EXPIRED_CODE); + + if (t.getClientSession().getAction().equals(ClientSessionModel.Action.AUTHENTICATE.name())) { + // TODO:mposolda incompatible types + LoginSessionModel loginSession = (LoginSessionModel) t.getClientSession(); + + AuthenticationProcessor.resetFlow(loginSession); + throw new LoginActionsServiceException(processAuthentication(null, loginSession, 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. @@ -433,12 +606,10 @@ public class LoginActionsService { @Path(RESET_CREDENTIALS_PATH) @GET public Response resetCredentialsGET(@QueryParam("code") String code, - @QueryParam("execution") String execution) { + @QueryParam("execution") String execution, + @QueryParam("key") String key) { // we allow applications to link to reset credentials without going through OAuth or SAML handshakes - // - // TODO:mposolda - /* - if (code == null) { + if (code == null && key == null) { if (!realm.isResetPasswordAllowed()) { event.event(EventType.RESET_PASSWORD); event.error(Errors.NOT_ALLOWED); @@ -450,7 +621,7 @@ public class LoginActionsService { ClientSessionModel clientSession = session.sessions().createClientSession(realm, client); clientSession.setAction(ClientSessionModel.Action.AUTHENTICATE.name()); //clientSession.setNote(AuthenticationManager.END_AFTER_REQUIRED_ACTIONS, "true"); - clientSession.setAuthMethod(OIDCLoginProtocol.LOGIN_PROTOCOL); + clientSession.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); String redirectUri = Urls.accountBase(uriInfo.getBaseUri()).path("/").build(realm.getName()).toString(); clientSession.setRedirectUri(redirectUri); clientSession.setAction(ClientSessionModel.Action.AUTHENTICATE.name()); @@ -459,32 +630,96 @@ public class LoginActionsService { clientSession.setNote(OIDCLoginProtocol.ISSUER, Urls.realmIssuer(uriInfo.getBaseUri(), realm.getName())); return processResetCredentials(null, clientSession, null); } + + if (key != null) { + try { + ResetCredentialsActionToken token = ResetCredentialsActionToken.deserialize( + session, realm, session.getContext().getUri(), key); + return resetCredentials(code, 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); - */ - return null; } - /* + + /** + * @deprecated In favor of {@link #resetCredentials(String, org.keycloak.authentication.ResetCredentialsActionToken, java.lang.String)} + * @param code + * @param execution + * @return + */ protected Response resetCredentials(String code, String execution) { event.event(EventType.RESET_PASSWORD); - Checks checks = new Checks(); - if (!checks.verifyCode(code, ClientSessionModel.Action.AUTHENTICATE.name(), ClientSessionCode.ActionType.USER)) { + SessionCodeChecks checks = checksForCode(code, LoginSessionModel.class); + if (!checks.verifyCode(ClientSessionModel.Action.AUTHENTICATE.name(), ClientSessionCode.ActionType.USER)) { return checks.response; } - final ClientSessionCode clientCode = checks.clientCode; - final ClientSessionModel clientSession = clientCode.getClientSession(); + final LoginSessionModel clientSession = checks.getClientSession(); if (!realm.isResetPasswordAllowed()) { - event.client(clientCode.getClientSession().getClient()); + event.client(clientSession.getClient()); event.error(Errors.NOT_ALLOWED); return ErrorPage.error(session, Messages.RESET_CREDENTIAL_NOT_ALLOWED); } + // TODO:mposolda + //return processResetCredentials(execution, clientSession, null); + return null; + } + + protected Response resetCredentials(String code, ResetCredentialsActionToken token, 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); + } + + 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(); + } catch (LoginActionsServiceException ex) { + if (ex.getResponse() == null) { + event.event(EventType.RESET_PASSWORD) + .detail(Details.REASON, ex.getMessage()) + .error(Errors.INVALID_REQUEST); + return ErrorPage.error(session, Messages.RESET_CREDENTIAL_NOT_ALLOWED); + } else { + return ex.getResponse(); + } + } 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); + } + + final ClientSessionModel clientSession = token.getClientSession(); + return processResetCredentials(execution, clientSession, null); } protected Response processResetCredentials(String execution, ClientSessionModel clientSession, String errorMessage) { + // TODO:mposolda + /* AuthenticationProcessor authProcessor = new AuthenticationProcessor() { @Override @@ -507,7 +742,9 @@ public class LoginActionsService { }; return processFlow(execution, clientSession, RESET_CREDENTIALS_PATH, realm.getResetCredentialsFlow(), errorMessage, authProcessor); - }*/ + */ + return null; + } protected Response processRegistration(String execution, LoginSessionModel loginSession, String errorMessage) { @@ -531,8 +768,8 @@ public class LoginActionsService { return ErrorPage.error(session, Messages.REGISTRATION_NOT_ALLOWED); } - Checks checks = new Checks(); - if (!checks.verifyCode(code, ClientSessionModel.Action.AUTHENTICATE.name(), ClientSessionCode.ActionType.LOGIN)) { + SessionCodeChecks checks = checksForCode(code, LoginSessionModel.class); + if (!checks.verifyCode(ClientSessionModel.Action.AUTHENTICATE.name(), ClientSessionCode.ActionType.LOGIN)) { return checks.response; } event.detail(Details.CODE_ID, code); @@ -561,14 +798,14 @@ public class LoginActionsService { event.error(Errors.REGISTRATION_DISABLED); return ErrorPage.error(session, Messages.REGISTRATION_NOT_ALLOWED); } - Checks checks = new Checks(); - if (!checks.verifyCode(code, ClientSessionModel.Action.AUTHENTICATE.name(), ClientSessionCode.ActionType.LOGIN)) { + SessionCodeChecks checks = checksForCode(code, LoginSessionModel.class); + if (!checks.verifyCode(ClientSessionModel.Action.AUTHENTICATE.name(), ClientSessionCode.ActionType.LOGIN)) { return checks.response; } + ClientSessionCode clientCode = checks.clientCode; LoginSessionModel loginSession = clientCode.getClientSession(); - return processRegistration(execution, loginSession, null); } @@ -607,13 +844,12 @@ public class LoginActionsService { EventType eventType = firstBrokerLogin ? EventType.IDENTITY_PROVIDER_FIRST_LOGIN : EventType.IDENTITY_PROVIDER_POST_LOGIN; event.event(eventType); - Checks checks = new Checks(); - if (!checks.verifyCode(code, ClientSessionModel.Action.AUTHENTICATE.name(), ClientSessionCode.ActionType.LOGIN)) { + SessionCodeChecks checks = checksForCode(code); + if (!checks.verifyCode(ClientSessionModel.Action.AUTHENTICATE.name(), ClientSessionCode.ActionType.LOGIN)) { return checks.response; } event.detail(Details.CODE_ID, code); - ClientSessionCode clientSessionCode = checks.clientCode; - final ClientSessionModel clientSessionn = clientSessionCode.getClientSession(); + final ClientSessionModel clientSessionn = checks.getClientSession(); String noteKey = firstBrokerLogin ? AbstractIdpAuthenticator.BROKERED_CONTEXT_NOTE : PostBrokerLoginConstants.PBL_BROKERED_IDENTITY_CONTEXT; SerializedBrokeredIdentityContext serializedCtx = SerializedBrokeredIdentityContext.readFromClientSession(clientSessionn, noteKey); @@ -681,10 +917,11 @@ public class LoginActionsService { public Response processConsent(final MultivaluedMap formData) { event.event(EventType.LOGIN); String code = formData.getFirst("code"); - Checks checks = new Checks(); - if (!checks.verifyRequiredAction(code, ClientSessionModel.Action.OAUTH_GRANT.name())) { + SessionCodeChecks checks = checksForCode(code, LoginSessionModel.class); + if (!checks.verifyRequiredAction(ClientSessionModel.Action.OAUTH_GRANT.name())) { return checks.response; } + ClientSessionCode accessCode = checks.clientCode; LoginSessionModel loginSession = accessCode.getClientSession(); @@ -750,16 +987,15 @@ public class LoginActionsService { clientSession.removeNote(Constants.VERIFY_EMAIL_KEY); - Checks checks = new Checks(); - if (!checks.verifyCode(code, ClientSessionModel.Action.REQUIRED_ACTIONS.name(), ClientSessionCode.ActionType.USER)) { + SessionCodeChecks checks = checksForCode(code); + if (!checks.verifyCode(ClientSessionModel.Action.REQUIRED_ACTIONS.name(), ClientSessionCode.ActionType.USER)) { if (checks.clientCode == null && checks.result.isClientSessionNotFound() || checks.result.isIllegalHash()) { return ErrorPage.error(session, Messages.STALE_VERIFY_EMAIL_LINK); } return checks.response; } - ClientSessionCode accessCode = checks.clientCode; - clientSession = accessCode.getClientSession(); + clientSession = checks.getClientSession(); if (!ClientSessionModel.Action.VERIFY_EMAIL.name().equals(clientSession.getNote(AuthenticationManager.CURRENT_REQUIRED_ACTION))) { ServicesLogger.LOGGER.reqdActionDoesNotMatch(); event.error(Errors.INVALID_CODE); @@ -789,12 +1025,12 @@ public class LoginActionsService { return AuthenticationProcessor.redirectToRequiredActions(session, realm, clientSession, uriInfo); } else { - Checks checks = new Checks(); - if (!checks.verifyCode(code, ClientSessionModel.Action.REQUIRED_ACTIONS.name(), ClientSessionCode.ActionType.USER)) { + SessionCodeChecks checks = checksForCode(code); + if (!checks.verifyCode(ClientSessionModel.Action.REQUIRED_ACTIONS.name(), ClientSessionCode.ActionType.USER)) { return checks.response; } ClientSessionCode accessCode = checks.clientCode; - ClientSessionModel clientSession = accessCode.getClientSession(); + ClientSessionModel clientSession = checks.getClientSession(); UserSessionModel userSession = clientSession.getUserSession(); initEvent(clientSession); @@ -824,11 +1060,11 @@ public class LoginActionsService { /* event.event(EventType.EXECUTE_ACTIONS); if (key != null) { - Checks checks = new Checks(); - if (!checks.verifyCode(key, ClientSessionModel.Action.EXECUTE_ACTIONS.name(), ClientSessionCode.ActionType.USER)) { + SessionCodeChecks checks = checksForCode(key); + if (!checks.verifyCode(ClientSessionModel.Action.EXECUTE_ACTIONS.name(), ClientSessionCode.ActionType.USER)) { return checks.response; } - ClientSessionModel clientSession = checks.clientCode.getClientSession(); + ClientSessionModel clientSession = checks.getClientSession(); // verify user email as we know it is valid as this entry point would never have gotten here. clientSession.getUserSession().getUser().setEmailVerified(true); clientSession.setNote(AuthenticationManager.END_AFTER_REQUIRED_ACTIONS, "true"); @@ -936,12 +1172,11 @@ public class LoginActionsService { /* event.event(EventType.CUSTOM_REQUIRED_ACTION); event.detail(Details.CUSTOM_REQUIRED_ACTION, action); - Checks checks = new Checks(); - if (!checks.verifyRequiredAction(code, action)) { + SessionCodeChecks checks = checksForCode(code); + if (!checks.verifyRequiredAction(action)) { return checks.response; } - final ClientSessionCode clientCode = checks.clientCode; - final ClientSessionModel clientSession = clientCode.getClientSession(); + final ClientSessionModel clientSession = checks.getClientSession(); final UserSessionModel userSession = clientSession.getUserSession(); diff --git a/services/src/main/java/org/keycloak/services/resources/LoginActionsServiceException.java b/services/src/main/java/org/keycloak/services/resources/LoginActionsServiceException.java new file mode 100644 index 0000000000..3e758dfdf2 --- /dev/null +++ b/services/src/main/java/org/keycloak/services/resources/LoginActionsServiceException.java @@ -0,0 +1,53 @@ +/* + * 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.services.resources; + +import org.keycloak.common.VerificationException; +import javax.ws.rs.core.Response; + +/** + * + * @author hmlnarik + */ +public class LoginActionsServiceException extends VerificationException { + + private final Response response; + + public LoginActionsServiceException(Response response) { + this.response = response; + } + + public LoginActionsServiceException(Response response, String message) { + super(message); + this.response = response; + } + + public LoginActionsServiceException(Response response, String message, Throwable cause) { + super(message, cause); + this.response = response; + } + + public LoginActionsServiceException(Response response, Throwable cause) { + super(cause); + this.response = response; + } + + public Response getResponse() { + return response; + } + +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/ResetPasswordTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/ResetPasswordTest.java index 3a12a76133..201a625ac4 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/ResetPasswordTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/ResetPasswordTest.java @@ -50,6 +50,7 @@ import java.util.HashMap; 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; @@ -74,6 +75,7 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest { .build(); userId = ApiUtil.createUserAndResetPasswordWithAdminClient(testRealm(), user, "password"); + expectedMessagesCount = 0; getCleanup().addUserId(userId); } @@ -104,6 +106,8 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest { @Rule public AssertEvents events = new AssertEvents(this); + private int expectedMessagesCount; + @Test public void resetPasswordLink() throws IOException, MessagingException { String username = "login-test"; @@ -167,6 +171,24 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest { resetPassword("login-test"); } + @Test + @Ignore + public void resetPasswordTwice() throws IOException, MessagingException { + String changePasswordUrl = resetPassword("login-test"); + 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) + .session((String) null) + .user(userId) + .detail(Details.USERNAME, "login-test") + .error(Errors.EXPIRED_CODE) + .assertEvent(); + } + @Test public void resetPasswordWithSpacesInUsername() throws IOException, MessagingException { resetPassword(" login-test "); @@ -174,15 +196,7 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest { @Test public void resetPasswordCancelChangeUser() throws IOException, MessagingException { - loginPage.open(); - loginPage.resetPassword(); - - resetPasswordPage.assertCurrent(); - - resetPasswordPage.changePassword("test-user@localhost"); - - loginPage.assertCurrent(); - assertEquals("You should receive an email shortly with further instructions.", loginPage.getSuccessMessage()); + initiateResetPasswordFromResetPasswordPage("test-user@localhost"); events.expectRequiredAction(EventType.SEND_RESET_PASSWORD).detail(Details.USERNAME, "test-user@localhost") .session((String) null) @@ -206,16 +220,12 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest { resetPassword("login@test.com"); } - private void resetPassword(String username) throws IOException, MessagingException { - loginPage.open(); - loginPage.resetPassword(); + private String resetPassword(String username) throws IOException, MessagingException { + return resetPassword(username, "resetPassword"); + } - resetPasswordPage.assertCurrent(); - - resetPasswordPage.changePassword(username); - - loginPage.assertCurrent(); - assertEquals("You should receive an email shortly with further instructions.", loginPage.getSuccessMessage()); + private String resetPassword(String username, String password) throws IOException, MessagingException { + initiateResetPasswordFromResetPasswordPage(username); events.expectRequiredAction(EventType.SEND_RESET_PASSWORD) .user(userId) @@ -224,9 +234,9 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest { .session((String)null) .assertEvent(); - assertEquals(1, greenMail.getReceivedMessages().length); + assertEquals(expectedMessagesCount, greenMail.getReceivedMessages().length); - MimeMessage message = greenMail.getReceivedMessages()[0]; + MimeMessage message = greenMail.getReceivedMessages()[greenMail.getReceivedMessages().length - 1]; String changePasswordUrl = getPasswordResetEmailLink(message); @@ -234,7 +244,7 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest { updatePasswordPage.assertCurrent(); - updatePasswordPage.changePassword("resetPassword", "resetPassword"); + updatePasswordPage.changePassword(password, password); String sessionId = events.expectRequiredAction(EventType.UPDATE_PASSWORD).user(userId).detail(Details.USERNAME, username.trim()).assertEvent().getSessionId(); @@ -248,63 +258,27 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest { loginPage.open(); - loginPage.login("login-test", "resetPassword"); + loginPage.login("login-test", password); - events.expectLogin().user(userId).detail(Details.USERNAME, "login-test").assertEvent(); + sessionId = events.expectLogin().user(userId).detail(Details.USERNAME, "login-test").assertEvent().getSessionId(); assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType()); - } - - private void resetPassword(String username, String password) throws IOException, MessagingException { - loginPage.open(); - loginPage.resetPassword(); - - resetPasswordPage.assertCurrent(); - - resetPasswordPage.changePassword(username); - - loginPage.assertCurrent(); - assertEquals("You should receive an email shortly with further instructions.", loginPage.getSuccessMessage()); - - events.expectRequiredAction(EventType.SEND_RESET_PASSWORD).user(userId).session((String)null) - .detail(Details.USERNAME, username).detail(Details.EMAIL, "login@test.com").assertEvent(); - - MimeMessage message = greenMail.getReceivedMessages()[greenMail.getReceivedMessages().length - 1]; - - String changePasswordUrl = getPasswordResetEmailLink(message); - - driver.navigate().to(changePasswordUrl.trim()); - - updatePasswordPage.assertCurrent(); - - updatePasswordPage.changePassword(password, password); - - String sessionId = events.expectRequiredAction(EventType.UPDATE_PASSWORD).user(userId) - .detail(Details.USERNAME, username).assertEvent().getSessionId(); - - assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType()); - - events.expectLogin().user(userId).detail(Details.USERNAME, username).assertEvent(); oauth.openLogout(); events.expectLogout(sessionId).user(userId).session(sessionId).assertEvent(); + + return changePasswordUrl; } private void resetPasswordInvalidPassword(String username, String password, String error) throws IOException, MessagingException { - loginPage.open(); - loginPage.resetPassword(); - - resetPasswordPage.assertCurrent(); - - resetPasswordPage.changePassword(username); - - loginPage.assertCurrent(); - assertEquals("You should receive an email shortly with further instructions.", loginPage.getSuccessMessage()); + initiateResetPasswordFromResetPasswordPage(username); events.expectRequiredAction(EventType.SEND_RESET_PASSWORD).user(userId).session((String)null) .detail(Details.USERNAME, username).detail(Details.EMAIL, "login@test.com").assertEvent(); + assertEquals(expectedMessagesCount, greenMail.getReceivedMessages().length); + MimeMessage message = greenMail.getReceivedMessages()[greenMail.getReceivedMessages().length - 1]; String changePasswordUrl = getPasswordResetEmailLink(message); @@ -320,17 +294,22 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest { events.expectRequiredAction(EventType.UPDATE_PASSWORD_ERROR).error(Errors.PASSWORD_REJECTED).user(userId).detail(Details.USERNAME, "login-test").assertEvent().getSessionId(); } - @Test - public void resetPasswordWrongEmail() throws IOException, MessagingException, InterruptedException { + public void initiateResetPasswordFromResetPasswordPage(String username) { loginPage.open(); loginPage.resetPassword(); resetPasswordPage.assertCurrent(); - - resetPasswordPage.changePassword("invalid"); + + resetPasswordPage.changePassword(username); loginPage.assertCurrent(); assertEquals("You should receive an email shortly with further instructions.", loginPage.getSuccessMessage()); + expectedMessagesCount++; + } + + @Test + public void resetPasswordWrongEmail() throws IOException, MessagingException, InterruptedException { + initiateResetPasswordFromResetPasswordPage("invalid"); assertEquals(0, greenMail.getReceivedMessages().length); @@ -359,15 +338,7 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest { @Test public void resetPasswordExpiredCode() throws IOException, MessagingException, InterruptedException { try { - loginPage.open(); - loginPage.resetPassword(); - - resetPasswordPage.assertCurrent(); - - resetPasswordPage.changePassword("login-test"); - - loginPage.assertCurrent(); - assertEquals("You should receive an email shortly with further instructions.", loginPage.getSuccessMessage()); + initiateResetPasswordFromResetPasswordPage("login-test"); events.expectRequiredAction(EventType.SEND_RESET_PASSWORD) .session((String)null) @@ -403,15 +374,7 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest { testRealm().update(realmRep); try { - loginPage.open(); - loginPage.resetPassword(); - - resetPasswordPage.assertCurrent(); - - resetPasswordPage.changePassword("login-test"); - - loginPage.assertCurrent(); - assertEquals("You should receive an email shortly with further instructions.", loginPage.getSuccessMessage()); + initiateResetPasswordFromResetPasswordPage("login-test"); events.expectRequiredAction(EventType.SEND_RESET_PASSWORD) .session((String)null) @@ -434,55 +397,50 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest { events.expectRequiredAction(EventType.RESET_PASSWORD).error("expired_code").client("test-app").user((String) null).session((String) null).clearDetails().assertEvent(); } finally { setTimeOffset(0); + + realmRep.setAccessCodeLifespanUserAction(originalValue.get()); + testRealm().update(realmRep); } } @Test public void resetPasswordDisabledUser() throws IOException, MessagingException, InterruptedException { UserRepresentation user = findUser("login-test"); - user.setEnabled(false); - updateUser(user); + try { + user.setEnabled(false); + updateUser(user); - loginPage.open(); - loginPage.resetPassword(); + initiateResetPasswordFromResetPasswordPage("login-test"); - resetPasswordPage.assertCurrent(); + assertEquals(0, greenMail.getReceivedMessages().length); - resetPasswordPage.changePassword("login-test"); - - loginPage.assertCurrent(); - assertEquals("You should receive an email shortly with further instructions.", loginPage.getSuccessMessage()); - - assertEquals(0, greenMail.getReceivedMessages().length); - - events.expectRequiredAction(EventType.RESET_PASSWORD).session((String) null).user(userId).detail(Details.USERNAME, "login-test").removeDetail(Details.CODE_ID).error("user_disabled").assertEvent(); - - user.setEnabled(true); - updateUser(user); + events.expectRequiredAction(EventType.RESET_PASSWORD).session((String) null).user(userId).detail(Details.USERNAME, "login-test").removeDetail(Details.CODE_ID).error("user_disabled").assertEvent(); + } finally { + user.setEnabled(true); + updateUser(user); + } } @Test public void resetPasswordNoEmail() throws IOException, MessagingException, InterruptedException { - final String[] email = new String[1]; + final String email; UserRepresentation user = findUser("login-test"); - email[0] = user.getEmail(); - user.setEmail(""); - updateUser(user); + email = user.getEmail(); - loginPage.open(); - loginPage.resetPassword(); + try { + user.setEmail(""); + updateUser(user); - resetPasswordPage.assertCurrent(); + initiateResetPasswordFromResetPasswordPage("login-test"); - resetPasswordPage.changePassword("login-test"); + assertEquals(0, greenMail.getReceivedMessages().length); - loginPage.assertCurrent(); - assertEquals("You should receive an email shortly with further instructions.", loginPage.getSuccessMessage()); - - assertEquals(0, greenMail.getReceivedMessages().length); - - events.expectRequiredAction(EventType.RESET_PASSWORD_ERROR).session((String) null).user(userId).detail(Details.USERNAME, "login-test").removeDetail(Details.CODE_ID).error("invalid_email").assertEvent(); + events.expectRequiredAction(EventType.RESET_PASSWORD_ERROR).session((String) null).user(userId).detail(Details.USERNAME, "login-test").removeDetail(Details.CODE_ID).error("invalid_email").assertEvent(); + } finally { + user.setEmail(email); + updateUser(user); + } } @Test @@ -496,29 +454,31 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest { RealmRepresentation realmRep = testRealm().toRepresentation(); Map oldSmtp = realmRep.getSmtpServer(); - realmRep.setSmtpServer(smtpConfig); - testRealm().update(realmRep); + try { + realmRep.setSmtpServer(smtpConfig); + testRealm().update(realmRep); - loginPage.open(); - loginPage.resetPassword(); + loginPage.open(); + loginPage.resetPassword(); - resetPasswordPage.assertCurrent(); + resetPasswordPage.assertCurrent(); - resetPasswordPage.changePassword("login-test"); + resetPasswordPage.changePassword("login-test"); - errorPage.assertCurrent(); + errorPage.assertCurrent(); - assertEquals("Failed to send email, please try again later.", errorPage.getError()); + assertEquals("Failed to send email, please try again later.", errorPage.getError()); - assertEquals(0, greenMail.getReceivedMessages().length); + assertEquals(0, greenMail.getReceivedMessages().length); - events.expectRequiredAction(EventType.SEND_RESET_PASSWORD_ERROR).user(userId) - .session((String)null) - .detail(Details.USERNAME, "login-test").removeDetail(Details.CODE_ID).error(Errors.EMAIL_SEND_FAILED).assertEvent(); - - // Revert SMTP back - realmRep.setSmtpServer(oldSmtp); - testRealm().update(realmRep); + events.expectRequiredAction(EventType.SEND_RESET_PASSWORD_ERROR).user(userId) + .session((String)null) + .detail(Details.USERNAME, "login-test").removeDetail(Details.CODE_ID).error(Errors.EMAIL_SEND_FAILED).assertEvent(); + } finally { + // Revert SMTP back + realmRep.setSmtpServer(oldSmtp); + testRealm().update(realmRep); + } } private void setPasswordPolicy(String policy) { @@ -531,15 +491,7 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest { public void resetPasswordWithLengthPasswordPolicy() throws IOException, MessagingException { setPasswordPolicy("length"); - loginPage.open(); - loginPage.resetPassword(); - - resetPasswordPage.assertCurrent(); - - resetPasswordPage.changePassword("login-test"); - - loginPage.assertCurrent(); - assertEquals("You should receive an email shortly with further instructions.", loginPage.getSuccessMessage()); + initiateResetPasswordFromResetPasswordPage("login-test"); assertEquals(1, greenMail.getReceivedMessages().length);