/* * 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.TokenVerifier.Predicate; import org.keycloak.authentication.AuthenticationProcessor; import org.keycloak.authentication.actiontoken.DefaultActionToken; import org.keycloak.authentication.ExplainedVerificationException; import org.keycloak.authentication.actiontoken.ActionTokenContext; import org.keycloak.authentication.actiontoken.ExplainedTokenVerificationException; import org.keycloak.common.VerificationException; import org.keycloak.events.Errors; import org.keycloak.forms.login.LoginFormsProvider; import org.keycloak.models.*; import org.keycloak.protocol.oidc.utils.RedirectUtils; import org.keycloak.representations.JsonWebToken; import org.keycloak.services.managers.AuthenticationManager; import org.keycloak.services.managers.AuthenticationSessionManager; import org.keycloak.services.messages.Messages; import org.keycloak.sessions.AuthenticationSessionModel; import org.keycloak.sessions.CommonClientSessionModel.Action; import java.util.Objects; import java.util.function.Consumer; import org.jboss.logging.Logger; /** * * @author hmlnarik */ public class LoginActionsServiceChecks { private static final Logger LOG = Logger.getLogger(LoginActionsServiceChecks.class.getName()); /** * This check verifies that user ID (subject) from the token matches * the one from the authentication session. */ public static class AuthenticationSessionUserIdMatchesOneFromToken implements Predicate<JsonWebToken> { private final ActionTokenContext<?> context; public AuthenticationSessionUserIdMatchesOneFromToken(ActionTokenContext<?> context) { this.context = context; } @Override public boolean test(JsonWebToken t) throws VerificationException { AuthenticationSessionModel authSession = context.getAuthenticationSession(); if (authSession == null || authSession.getAuthenticatedUser() == null || ! Objects.equals(t.getSubject(), authSession.getAuthenticatedUser().getId())) { throw new ExplainedTokenVerificationException(t, Errors.INVALID_TOKEN, Messages.INVALID_USER); } 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> */ public static class IsActionRequired implements Predicate<JsonWebToken> { private final ActionTokenContext<?> context; private final ClientSessionModel.Action expectedAction; public IsActionRequired(ActionTokenContext<?> context, Action expectedAction) { this.context = context; this.expectedAction = expectedAction; } @Override public boolean test(JsonWebToken t) throws VerificationException { AuthenticationSessionModel authSession = context.getAuthenticationSession(); if (authSession != null && ! Objects.equals(authSession.getAction(), this.expectedAction.name())) { if (Objects.equals(ClientSessionModel.Action.REQUIRED_ACTIONS.name(), authSession.getAction())) { throw new LoginActionsServiceException( AuthenticationManager.nextActionAfterAuthentication(context.getSession(), authSession, context.getClientConnection(), context.getRequest(), context.getUriInfo(), context.getEvent())); } throw new ExplainedTokenVerificationException(t, Errors.INVALID_TOKEN, Messages.INVALID_CODE); } 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. */ public static <T extends JsonWebToken> void checkNotLoggedInYet(ActionTokenContext<T> context, String authSessionId) throws VerificationException { if (authSessionId == null) { return; } UserSessionModel userSession = context.getSession().sessions().getUserSession(context.getRealm(), authSessionId); if (userSession != null) { LoginFormsProvider loginForm = context.getSession().getProvider(LoginFormsProvider.class) .setSuccess(Messages.ALREADY_LOGGED_IN); ClientModel client = null; String lastClientUuid = userSession.getNote(AuthenticationManager.LAST_AUTHENTICATED_CLIENT); if (lastClientUuid != null) { client = context.getRealm().getClientById(lastClientUuid); } if (client != null) { context.getSession().getContext().setClient(client); } else { loginForm.setAttribute("skipLink", true); } throw new LoginActionsServiceException(loginForm.createInfoPage()); } } /** * Verifies whether the user given by ID both exists in the current realm. If yes, * it optionally also injects the user using the given function (e.g. into session context). */ public static void checkIsUserValid(KeycloakSession session, RealmModel realm, String userId, Consumer<UserModel> userSetter) throws VerificationException { UserModel user = userId == null ? null : session.users().getUserById(userId, realm); if (user == null) { throw new ExplainedVerificationException(Errors.USER_NOT_FOUND, Messages.INVALID_USER); } if (! user.isEnabled()) { throw new ExplainedVerificationException(Errors.USER_DISABLED, Messages.INVALID_USER); } if (userSetter != null) { userSetter.accept(user); } } /** * Verifies whether the user given by ID both exists in the current realm. If yes, * it optionally also injects the user using the given function (e.g. into session context). */ public static <T extends DefaultActionToken> void checkIsUserValid(T token, ActionTokenContext<T> context) throws VerificationException { try { checkIsUserValid(context.getSession(), context.getRealm(), token.getUserId(), context.getAuthenticationSession()::setAuthenticatedUser); } catch (ExplainedVerificationException ex) { throw new ExplainedTokenVerificationException(token, ex); } } /** * Verifies whether the client denoted by client ID in token's {@code iss} ({@code issuedFor}) * field both exists and is enabled. */ public static void checkIsClientValid(KeycloakSession session, ClientModel client) throws VerificationException { if (client == null) { throw new ExplainedVerificationException(Errors.CLIENT_NOT_FOUND, Messages.UNKNOWN_LOGIN_REQUESTER); } if (! client.isEnabled()) { throw new ExplainedVerificationException(Errors.CLIENT_NOT_FOUND, Messages.LOGIN_REQUESTER_NOT_ENABLED); } } /** * Verifies whether the client denoted by client ID in token's {@code iss} ({@code issuedFor}) * field both exists and is enabled. */ public static <T extends DefaultActionToken> void checkIsClientValid(T token, ActionTokenContext<T> context) throws VerificationException { String clientId = token.getIssuedFor(); AuthenticationSessionModel authSession = context.getAuthenticationSession(); ClientModel client = authSession == null ? null : authSession.getClient(); try { checkIsClientValid(context.getSession(), client); if (clientId != null && ! Objects.equals(client.getClientId(), clientId)) { throw new ExplainedTokenVerificationException(token, Errors.CLIENT_NOT_FOUND, Messages.UNKNOWN_LOGIN_REQUESTER); } } catch (ExplainedVerificationException ex) { throw new ExplainedTokenVerificationException(token, ex); } } /** * Verifies whether the given redirect URL, when set, is valid for the given client. */ public static class IsRedirectValid implements Predicate<JsonWebToken> { private final ActionTokenContext<?> context; private final String redirectUri; public IsRedirectValid(ActionTokenContext<?> context, String redirectUri) { this.context = context; this.redirectUri = redirectUri; } @Override public boolean test(JsonWebToken t) throws VerificationException { if (redirectUri == null) { return true; } ClientModel client = context.getAuthenticationSession().getClient(); if (RedirectUtils.verifyRedirectUri(context.getUriInfo(), redirectUri, context.getRealm(), client) == null) { throw new ExplainedTokenVerificationException(t, Errors.INVALID_REDIRECT_URI, Messages.INVALID_REDIRECT_URI); } return true; } } /** * This check verifies that current authentication session is consistent with the one specified in token. * Examples: * <ul> * <li>1. Email from administrator with reset e-mail - token does not contain auth session ID</li> * <li>2. Email from "verify e-mail" step within flow - token contains auth session ID.</li> * <li>3. User clicked the link in an e-mail and gets to a new browser - authentication session cookie is not set</li> * <li>4. User clicked the link in an e-mail while having authentication running - authentication session cookie * is already set in the browser</li> * </ul> * * <ul> * <li>For combinations 1 and 3, 1 and 4, and 2 and 3: Requests next step</li> * <li>For combination 2 and 4: * <ul> * <li>If the auth session IDs from token and cookie match, pass</li> * <li>Else if the auth session from cookie was forked and its parent auth session ID * matches that of token, replaces current auth session with that of parent and passes</li> * <li>Else requests restart by throwing RestartFlow exception</li> * </ul> * </li> * </ul> * * When the check passes, it also sets the authentication session in token context accordingly. * * @param <T> */ public static <T extends JsonWebToken> boolean doesAuthenticationSessionFromCookieMatchOneFromToken(ActionTokenContext<T> context, String authSessionIdFromToken) throws VerificationException { if (authSessionIdFromToken == null) { return false; } AuthenticationSessionManager asm = new AuthenticationSessionManager(context.getSession()); String authSessionIdFromCookie = asm.getCurrentAuthenticationSessionId(context.getRealm()); if (authSessionIdFromCookie == null) { return false; } AuthenticationSessionModel authSessionFromCookie = context.getSession() .authenticationSessions().getAuthenticationSession(context.getRealm(), authSessionIdFromCookie); if (authSessionFromCookie == null) { // Cookie contains ID of expired auth session return false; } if (Objects.equals(authSessionIdFromCookie, authSessionIdFromToken)) { context.setAuthenticationSession(authSessionFromCookie, false); return true; } String parentSessionId = authSessionFromCookie.getAuthNote(AuthenticationProcessor.FORKED_FROM); if (parentSessionId == null || ! Objects.equals(authSessionIdFromToken, parentSessionId)) { return false; } AuthenticationSessionModel authSessionFromParent = context.getSession() .authenticationSessions().getAuthenticationSession(context.getRealm(), parentSessionId); // It's the correct browser. Let's remove forked session as we won't continue // from the login form (browser flow) but from the token's flow // Don't expire KC_RESTART cookie at this point asm.removeAuthenticationSession(context.getRealm(), authSessionFromCookie, false); LOG.debugf("Removed forked session: %s", authSessionFromCookie.getId()); // Refresh browser cookie asm.setAuthSessionCookie(parentSessionId, context.getRealm()); context.setAuthenticationSession(authSessionFromParent, false); context.setExecutionId(authSessionFromParent.getAuthNote(AuthenticationProcessor.LAST_PROCESSED_EXECUTION)); return true; } public static <T extends DefaultActionToken> void checkTokenWasNotUsedYet(T token, ActionTokenContext<T> context) throws VerificationException { ActionTokenStoreProvider actionTokenStore = context.getSession().getProvider(ActionTokenStoreProvider.class); if (actionTokenStore.get(token) != null) { throw new ExplainedTokenVerificationException(token, Errors.EXPIRED_CODE, Messages.EXPIRED_ACTION); } } }