From ccbddb22581e45d5060aa12bfe26dc386487f6d2 Mon Sep 17 00:00:00 2001 From: mposolda Date: Mon, 26 Jun 2023 13:55:13 +0200 Subject: [PATCH] Fix updating locale on info/error page after authenticationSession was already removed Closes #13922 --- .../forms/login/LoginFormsProvider.java | 8 ++ .../org/keycloak/forms/login/MessageType.java | 30 ++++ .../freemarker/DetachedInfoStateChecker.java | 108 +++++++++++++++ .../freemarker/DetachedInfoStateCookie.java | 131 ++++++++++++++++++ .../FreeMarkerLoginFormsProvider.java | 46 +++++- .../forms/login/freemarker/model/UrlBean.java | 8 -- .../locale/DefaultLocaleSelectorProvider.java | 6 +- .../protocol/oidc/utils/LogoutUtil.java | 5 +- .../main/java/org/keycloak/services/Urls.java | 8 +- .../services/error/KeycloakErrorHandler.java | 2 +- .../managers/AuthenticationManager.java | 1 + .../AuthenticationSessionManager.java | 5 +- .../resources/LoginActionsService.java | 50 +++++++ .../services/resources/SessionCodeChecks.java | 2 + .../keycloak/services/util/LocaleUtil.java | 9 +- .../org/keycloak/theme/beans/MessageBean.java | 2 + .../org/keycloak/theme/beans/MessageType.java | 28 ---- .../theme/beans/MessagesPerFieldBean.java | 2 + .../keycloak/testsuite/pages/ErrorPage.java | 2 +- .../keycloak/testsuite/i18n/EmailTest.java | 11 ++ .../testsuite/i18n/LoginPageTest.java | 57 ++++++++ .../oauth/RPInitiatedLogoutTest.java | 13 ++ 22 files changed, 474 insertions(+), 60 deletions(-) create mode 100755 server-spi-private/src/main/java/org/keycloak/forms/login/MessageType.java create mode 100644 services/src/main/java/org/keycloak/forms/login/freemarker/DetachedInfoStateChecker.java create mode 100644 services/src/main/java/org/keycloak/forms/login/freemarker/DetachedInfoStateCookie.java delete mode 100755 services/src/main/java/org/keycloak/theme/beans/MessageType.java diff --git a/server-spi-private/src/main/java/org/keycloak/forms/login/LoginFormsProvider.java b/server-spi-private/src/main/java/org/keycloak/forms/login/LoginFormsProvider.java index 05b1eba3c8..5f9138b6b7 100755 --- a/server-spi-private/src/main/java/org/keycloak/forms/login/LoginFormsProvider.java +++ b/server-spi-private/src/main/java/org/keycloak/forms/login/LoginFormsProvider.java @@ -139,6 +139,14 @@ public interface LoginFormsProvider extends Provider { LoginFormsProvider setInfo(String message, Object ... parameters); + LoginFormsProvider setMessage(MessageType type, String message, Object... parameters); + + /** + * Used when authenticationSession was already removed for this browser session and hence we don't have any + * authenticationSession or user data. Would just repeat previous info/error page after language is changed + */ + LoginFormsProvider setDetachedAuthSession(); + LoginFormsProvider setUser(UserModel user); LoginFormsProvider setResponseHeader(String headerName, String headerValue); diff --git a/server-spi-private/src/main/java/org/keycloak/forms/login/MessageType.java b/server-spi-private/src/main/java/org/keycloak/forms/login/MessageType.java new file mode 100755 index 0000000000..0a6d2f72e5 --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/forms/login/MessageType.java @@ -0,0 +1,30 @@ +/* + * Copyright 2023 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.forms.login; + +/** + * Enum with types of messages. + * + * @author Vlastimil Elias (velias at redhat dot com) + */ +public enum MessageType { + + SUCCESS, WARNING, INFO, ERROR + +} diff --git a/services/src/main/java/org/keycloak/forms/login/freemarker/DetachedInfoStateChecker.java b/services/src/main/java/org/keycloak/forms/login/freemarker/DetachedInfoStateChecker.java new file mode 100644 index 0000000000..9aecd3d48c --- /dev/null +++ b/services/src/main/java/org/keycloak/forms/login/freemarker/DetachedInfoStateChecker.java @@ -0,0 +1,108 @@ +/* + * Copyright 2023 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.forms.login.freemarker; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import jakarta.ws.rs.core.UriInfo; +import org.jboss.logging.Logger; +import org.keycloak.common.VerificationException; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.utils.KeycloakModelUtils; +import org.keycloak.services.managers.AuthenticationManager; +import org.keycloak.services.util.CookieHelper; + +/** + * @author Marek Posolda + */ +public class DetachedInfoStateChecker { + + private static final Logger logger = Logger.getLogger(DetachedInfoStateChecker.class); + + private static final String STATE_CHECKER_COOKIE_NAME = "KC_STATE_CHECKER"; + public static final String STATE_CHECKER_PARAM = "kc_state_checker"; + + private final KeycloakSession session; + private final RealmModel realm; + + public DetachedInfoStateChecker(KeycloakSession session, RealmModel realm) { + this.session = session; + this.realm = realm; + } + + public DetachedInfoStateCookie generateAndSetCookie(String messageKey, String messageType, Integer status, String clientId, Object[] messageParameters) { + UriInfo uriInfo = session.getContext().getHttpRequest().getUri(); + String path = AuthenticationManager.getRealmCookiePath(realm, uriInfo); + boolean secureOnly = realm.getSslRequired().isRequired(session.getContext().getConnection()); + + String currentStateCheckerInUrl = uriInfo.getQueryParameters().getFirst(STATE_CHECKER_PARAM); + String newStateChecker = KeycloakModelUtils.generateId(); + int cookieMaxAge = realm.getAccessCodeLifespanUserAction(); + + DetachedInfoStateCookie cookie = new DetachedInfoStateCookie(); + cookie.setMessageKey(messageKey); + cookie.setMessageType(messageType); + cookie.setStatus(status); + cookie.setClientUuid(clientId); + cookie.setCurrentUrlState(currentStateCheckerInUrl); + cookie.setRenderedUrlState(newStateChecker); + if (messageParameters != null) { + List params = Stream.of(messageParameters) + .map(Object::toString) + .collect(Collectors.toList()); + cookie.setMessageParameters(params); + } + + String encoded = session.tokens().encode(cookie); + logger.tracef("Generating new %s cookie. Cookie: %s, Cookie lifespan: %d", STATE_CHECKER_COOKIE_NAME, cookie, cookieMaxAge); + + CookieHelper.addCookie(STATE_CHECKER_COOKIE_NAME, encoded, path, null, null, cookieMaxAge, secureOnly, true, session); + return cookie; + } + + public DetachedInfoStateCookie verifyStateCheckerParameter(String stateCheckerParam) throws VerificationException { + Set cookieVal = CookieHelper.getCookieValue(session, STATE_CHECKER_COOKIE_NAME); + if (cookieVal == null || cookieVal.isEmpty()) { + throw new VerificationException("State checker cookie is empty"); + } + if (stateCheckerParam == null || stateCheckerParam.isEmpty()) { + throw new VerificationException("State checker parameter is empty"); + } + + String cookieEncoded = cookieVal.iterator().next(); + DetachedInfoStateCookie cookie = session.tokens().decode(cookieEncoded, DetachedInfoStateCookie.class); + if (cookie == null) { + throw new VerificationException("Failed to verify DetachedInfoStateCookie"); + } + + // May want to compare with the currentUrlState (when refreshing detached info/error page) or with renderedUrlState (when user changes locale on the info/error page through the combobox). + // As the currentUrlState is in the browser URL when renderedUrlState is in the link inside the user's combobox + if (stateCheckerParam.equals(cookie.getCurrentUrlState()) || stateCheckerParam.equals(cookie.getRenderedUrlState())) { + return cookie; + } else { + throw new VerificationException(String.format("Failed to verify state. StateCheckerParameter: %s, cookie current state checker: %s, Cookie rendered state checker: %s", + stateCheckerParam, cookie.getCurrentUrlState(), cookie.getRenderedUrlState())); + } + } +} diff --git a/services/src/main/java/org/keycloak/forms/login/freemarker/DetachedInfoStateCookie.java b/services/src/main/java/org/keycloak/forms/login/freemarker/DetachedInfoStateCookie.java new file mode 100644 index 0000000000..c577067e39 --- /dev/null +++ b/services/src/main/java/org/keycloak/forms/login/freemarker/DetachedInfoStateCookie.java @@ -0,0 +1,131 @@ +/* + * Copyright 2023 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.forms.login.freemarker; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonProperty; +import org.keycloak.Token; +import org.keycloak.TokenCategory; + +/** + * Cookie encapsulating data to be displayed on the info/error page. We need this data due the fact that authenticationSession may not exists. + * This is needed so the info/error page can be restored after user changed language. + * + * @author Marek Posolda + */ +public class DetachedInfoStateCookie implements Token { + + @JsonProperty("mky") + private String messageKey; + + @JsonProperty("mty") + private String messageType; + + @JsonProperty("mpar") + private List messageParameters; + + @JsonProperty("stat") + private Integer status; + + @JsonProperty("clid") + private String clientUuid; + + @JsonProperty("st1") + private String currentUrlState; + + @JsonProperty("st2") + private String renderedUrlState; + + public String getMessageKey() { + return messageKey; + } + + public void setMessageKey(String messageKey) { + this.messageKey = messageKey; + } + + public String getMessageType() { + return messageType; + } + + public void setMessageType(String messageType) { + this.messageType = messageType; + } + + public List getMessageParameters() { + return messageParameters; + } + + public void setMessageParameters(List messageParameters) { + this.messageParameters = messageParameters; + } + + public Integer getStatus() { + return status; + } + + public void setStatus(Integer status) { + this.status = status; + } + + public String getClientUuid() { + return clientUuid; + } + + public void setClientUuid(String clientUuid) { + this.clientUuid = clientUuid; + } + + public String getCurrentUrlState() { + return currentUrlState; + } + + public void setCurrentUrlState(String currentUrlState) { + this.currentUrlState = currentUrlState; + } + + public String getRenderedUrlState() { + return renderedUrlState; + } + + public void setRenderedUrlState(String renderedUrlState) { + this.renderedUrlState = renderedUrlState; + } + + @Override + public TokenCategory getCategory() { + return TokenCategory.INTERNAL; + } + + @Override + public String toString() { + return new StringBuilder("DetachedInfoStateCookie [ ") + .append("messageKey=" + messageKey) + .append(", messageType=" + messageType) + .append(", status=" + status) + .append(", clientUuid=" + clientUuid) + .append(", messageParameters=" + messageParameters) + .append(", currentUrlState=" + currentUrlState) + .append(", renderedUrlState=" + renderedUrlState) + .append(" ]") + .toString(); + } +} diff --git a/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java b/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java index e43ca28aec..4dde78160e 100755 --- a/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java +++ b/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java @@ -86,7 +86,7 @@ import org.keycloak.theme.beans.AdvancedMessageFormatterMethod; import org.keycloak.theme.beans.LocaleBean; import org.keycloak.theme.beans.MessageBean; import org.keycloak.theme.beans.MessageFormatterMethod; -import org.keycloak.theme.beans.MessageType; +import org.keycloak.forms.login.MessageType; import org.keycloak.theme.beans.MessagesPerFieldBean; import org.keycloak.theme.freemarker.FreeMarkerProvider; import org.keycloak.userprofile.UserProfileContext; @@ -112,6 +112,7 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider { protected MessageType messageType = MessageType.ERROR; protected MultivaluedMap formData; + protected boolean detachedAuthSession = false; protected KeycloakSession session; /** authenticationSession can be null for some renderings, mainly error pages */ @@ -490,6 +491,22 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider { case LOGOUT_CONFIRM: b = UriBuilder.fromUri(Urls.logoutConfirm(baseUri, realm.getName())); break; + case INFO: + case ERROR: + if (isDetachedAuthenticationSession()) { + FormMessage formMessage = getFirstMessage(); + if (formMessage == null) { + throw new IllegalStateException("Not able to create info/error page with detached authentication session as no info/error message available"); + } + + DetachedInfoStateCookie cookie = new DetachedInfoStateChecker(session, realm).generateAndSetCookie( + formMessage.getMessage(), messageType.toString(), status == null ? null : status.getStatusCode(), + client == null ? null : client.getId(), formMessage.getParameters()); + + b = UriBuilder.fromUri(Urls.loginActionsDetachedInfo(baseUri, realm.getName())) + .queryParam(DetachedInfoStateChecker.STATE_CHECKER_PARAM, cookie.getRenderedUrlState()); + break; + } default: b = UriBuilder.fromUri(baseUri).path(uriInfo.getPath()); break; @@ -703,17 +720,24 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider { return createResponse(LoginFormsPages.LOGOUT_CONFIRM); } - protected void setMessage(MessageType type, String message, Object... parameters) { + @Override + public LoginFormsProvider setMessage(MessageType type, String message, Object... parameters) { messageType = type; messages = new ArrayList<>(); messages.add(new FormMessage(null, message, parameters)); + return this; + } + + private FormMessage getFirstMessage() { + if (messages != null && !messages.isEmpty()) { + return messages.get(0); + } + return null; } protected String getFirstMessageUnformatted() { - if (messages != null && !messages.isEmpty()) { - return messages.get(0).getMessage(); - } - return null; + FormMessage formMessage = getFirstMessage(); + return formMessage == null ? null : formMessage.getMessage(); } protected String formatMessage(FormMessage message, Properties messagesBundle, Locale locale) { @@ -783,6 +807,16 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider { return this; } + @Override + public LoginFormsProvider setDetachedAuthSession() { + detachedAuthSession = true; + return this; + } + + private boolean isDetachedAuthenticationSession() { + return detachedAuthSession || authenticationSession == null; + } + @Override public LoginFormsProvider setAuthenticationSession(AuthenticationSessionModel authenticationSession) { this.authenticationSession = authenticationSession; diff --git a/services/src/main/java/org/keycloak/forms/login/freemarker/model/UrlBean.java b/services/src/main/java/org/keycloak/forms/login/freemarker/model/UrlBean.java index 154dccd3d7..266ffc54e2 100755 --- a/services/src/main/java/org/keycloak/forms/login/freemarker/model/UrlBean.java +++ b/services/src/main/java/org/keycloak/forms/login/freemarker/model/UrlBean.java @@ -75,14 +75,6 @@ public class UrlBean { return Urls.realmRegisterPage(baseURI, realm).toString(); } - public String getLoginUpdatePasswordUrl() { - return Urls.loginActionUpdatePassword(baseURI, realm).toString(); - } - - public String getLoginUpdateTotpUrl() { - return Urls.loginActionUpdateTotp(baseURI, realm).toString(); - } - public String getLoginUpdateProfileUrl() { return Urls.loginActionUpdateProfile(baseURI, realm).toString(); } diff --git a/services/src/main/java/org/keycloak/locale/DefaultLocaleSelectorProvider.java b/services/src/main/java/org/keycloak/locale/DefaultLocaleSelectorProvider.java index 20b86e4ea3..6b377b27dc 100644 --- a/services/src/main/java/org/keycloak/locale/DefaultLocaleSelectorProvider.java +++ b/services/src/main/java/org/keycloak/locale/DefaultLocaleSelectorProvider.java @@ -92,11 +92,7 @@ public class DefaultLocaleSelectorProvider implements LocaleSelectorProvider { } private Locale getUserSelectedLocale(RealmModel realm, AuthenticationSessionModel session) { - if (session == null) { - return null; - } - - String locale = session.getAuthNote(USER_REQUEST_LOCALE); + String locale = session == null ? this.session.getAttribute(USER_REQUEST_LOCALE, String.class) : session.getAuthNote(USER_REQUEST_LOCALE); if (locale == null) { return null; } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/utils/LogoutUtil.java b/services/src/main/java/org/keycloak/protocol/oidc/utils/LogoutUtil.java index dd88b21de7..b2540aaab0 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/utils/LogoutUtil.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/utils/LogoutUtil.java @@ -28,7 +28,6 @@ import org.keycloak.models.Constants; import org.keycloak.models.KeycloakSession; import org.keycloak.models.utils.SystemClientUtil; import org.keycloak.protocol.oidc.OIDCLoginProtocol; -import org.keycloak.services.managers.AuthenticationManager; import org.keycloak.services.messages.Messages; import org.keycloak.sessions.AuthenticationSessionModel; @@ -51,7 +50,9 @@ public class LogoutUtil { if (usedSystemClient) { loginForm.setAttribute(Constants.SKIP_LINK, true); } - return loginForm.createInfoPage(); + return loginForm + .setDetachedAuthSession() + .createInfoPage(); } diff --git a/services/src/main/java/org/keycloak/services/Urls.java b/services/src/main/java/org/keycloak/services/Urls.java index 4057e382b6..c9d5a34add 100755 --- a/services/src/main/java/org/keycloak/services/Urls.java +++ b/services/src/main/java/org/keycloak/services/Urls.java @@ -107,12 +107,8 @@ public class Urls { return realmLogout(baseUri).path(LogoutEndpoint.class, "logoutConfirmAction").build(realmName); } - public static URI loginActionUpdatePassword(URI baseUri, String realmName) { - return loginActionsBase(baseUri).path(LoginActionsService.class, "updatePassword").build(realmName); - } - - public static URI loginActionUpdateTotp(URI baseUri, String realmName) { - return loginActionsBase(baseUri).path(LoginActionsService.class, "updateTotp").build(realmName); + public static URI loginActionsDetachedInfo(URI baseUri, String realmName) { + return loginActionsBase(baseUri).path(LoginActionsService.class, "detachedInfo").build(realmName); } public static UriBuilder requiredActionBase(URI baseUri) { diff --git a/services/src/main/java/org/keycloak/services/error/KeycloakErrorHandler.java b/services/src/main/java/org/keycloak/services/error/KeycloakErrorHandler.java index 7de50ff731..4d76aba3a1 100644 --- a/services/src/main/java/org/keycloak/services/error/KeycloakErrorHandler.java +++ b/services/src/main/java/org/keycloak/services/error/KeycloakErrorHandler.java @@ -16,7 +16,7 @@ import org.keycloak.theme.Theme; import org.keycloak.theme.beans.LocaleBean; import org.keycloak.theme.beans.MessageBean; import org.keycloak.theme.beans.MessageFormatterMethod; -import org.keycloak.theme.beans.MessageType; +import org.keycloak.forms.login.MessageType; import org.keycloak.theme.freemarker.FreeMarkerProvider; import org.keycloak.utils.MediaType; import org.keycloak.utils.MediaTypeMatcher; 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 ccfd4ec379..89c4515a2e 100755 --- a/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java +++ b/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java @@ -1072,6 +1072,7 @@ public class AuthenticationManager { infoPage.setAttribute(Constants.SKIP_LINK, true); } Response response = infoPage + .setDetachedAuthSession() .createInfoPage(); new AuthenticationSessionManager(session).removeAuthenticationSession(authSession.getRealm(), authSession, true); diff --git a/services/src/main/java/org/keycloak/services/managers/AuthenticationSessionManager.java b/services/src/main/java/org/keycloak/services/managers/AuthenticationSessionManager.java index 2c26caff05..e2f4180ac6 100644 --- a/services/src/main/java/org/keycloak/services/managers/AuthenticationSessionManager.java +++ b/services/src/main/java/org/keycloak/services/managers/AuthenticationSessionManager.java @@ -18,8 +18,8 @@ package org.keycloak.services.managers; import org.jboss.logging.Logger; -import org.keycloak.common.ClientConnection; import org.keycloak.common.util.ServerCookie.SameSiteAttributeValue; +import org.keycloak.forms.login.LoginFormsProvider; import org.keycloak.models.ClientModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; @@ -222,6 +222,9 @@ public class AuthenticationSessionManager { if (expireRestartCookie) { UriInfo uriInfo = session.getContext().getUri(); RestartLoginCookie.expireRestartCookie(realm, uriInfo, session); + + // With browser session, this makes sure that info/error pages will be rendered correctly when locale is changed on them + session.getProvider(LoginFormsProvider.class).setDetachedAuthSession(); } } 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 d10cea04c0..ec8e0d85cc 100755 --- a/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java +++ b/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java @@ -19,6 +19,10 @@ package org.keycloak.services.resources; import org.jboss.logging.Logger; import org.keycloak.common.Profile; import org.keycloak.common.util.ResponseSessionTask; +import org.keycloak.forms.login.LoginFormsProvider; +import org.keycloak.forms.login.MessageType; +import org.keycloak.forms.login.freemarker.DetachedInfoStateChecker; +import org.keycloak.forms.login.freemarker.DetachedInfoStateCookie; import org.keycloak.http.HttpRequest; import org.keycloak.OAuth2Constants; import org.keycloak.TokenVerifier; @@ -122,6 +126,8 @@ public class LoginActionsService { public static final String RESTART_PATH = "restart"; + public static final String DETACHED_INFO_PATH = "detached-info"; + public static final String FORWARDED_ERROR_MESSAGE_NOTE = "forwardedErrorMessage"; public static final String SESSION_CODE = "session_code"; @@ -243,6 +249,50 @@ public class LoginActionsService { return Response.status(Response.Status.FOUND).location(redirectUri).build(); } + /** + * protocol independent "detached info" page. Shown when locale is changed by user on info/error page + * after authenticationSession was already removed. + * + * @return + */ + @Path(DETACHED_INFO_PATH) + @GET + public Response detachedInfo(@QueryParam(DetachedInfoStateChecker.STATE_CHECKER_PARAM) String stateCheckerParam) { + DetachedInfoStateCookie cookie; + try { + cookie = new DetachedInfoStateChecker(session, realm).verifyStateCheckerParameter(stateCheckerParam); + logger.tracef("Detached info endpoint invoked and cookie successfully verified. StateCheckerParam=%s, StateCookie=%s", stateCheckerParam, cookie); + } catch (VerificationException ve) { + logger.warn(ve.getMessage()); + return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.EXPIRED_ACTION_TOKEN_NO_SESSION); + } + + processLocaleParam(null); + + boolean skipLink = true; + if (cookie.getClientUuid() != null) { + ClientModel client = session.clients().getClientById(realm, cookie.getClientUuid()); + if (client != null) { + session.getContext().setClient(client); + skipLink = client.equals(SystemClientUtil.getSystemClient(realm)); + } + } + + MessageType type = Enum.valueOf(MessageType.class, cookie.getMessageType()); + Response.Status statusObj = cookie.getStatus() == null ? Response.Status.BAD_REQUEST : Response.Status.fromStatusCode(cookie.getStatus()); + Object[] paramsAsObject = cookie.getMessageParameters() == null ? null : cookie.getMessageParameters().toArray(); + + LoginFormsProvider loginForm = session.getProvider(LoginFormsProvider.class) + .setDetachedAuthSession() + .setMessage(type, cookie.getMessageKey(), paramsAsObject); + + if (skipLink) { + loginForm.setAttribute(Constants.SKIP_LINK, true); + } + + return type == MessageType.ERROR ? loginForm.createErrorPage(statusObj) : loginForm.createInfoPage(); + } + /** * protocol independent login page entry point diff --git a/services/src/main/java/org/keycloak/services/resources/SessionCodeChecks.java b/services/src/main/java/org/keycloak/services/resources/SessionCodeChecks.java index 854b242bd8..26d9778bde 100644 --- a/services/src/main/java/org/keycloak/services/resources/SessionCodeChecks.java +++ b/services/src/main/java/org/keycloak/services/resources/SessionCodeChecks.java @@ -222,6 +222,7 @@ public class SessionCodeChecks { ClientModel client = authSession.getClient(); if (client == null) { event.error(Errors.CLIENT_NOT_FOUND); + session.getProvider(LoginFormsProvider.class).setDetachedAuthSession(); response = ErrorPage.error(session, authSession, Response.Status.BAD_REQUEST, Messages.UNKNOWN_LOGIN_REQUESTER); clientCode.removeExpiredClientSession(); return false; @@ -232,6 +233,7 @@ public class SessionCodeChecks { if (checkClientDisabled(client)) { event.error(Errors.CLIENT_DISABLED); + session.getProvider(LoginFormsProvider.class).setDetachedAuthSession(); response = ErrorPage.error(session, authSession, Response.Status.BAD_REQUEST, Messages.LOGIN_REQUESTER_NOT_ENABLED); clientCode.removeExpiredClientSession(); return false; diff --git a/services/src/main/java/org/keycloak/services/util/LocaleUtil.java b/services/src/main/java/org/keycloak/services/util/LocaleUtil.java index 7f541b5335..09ad7d4e1e 100644 --- a/services/src/main/java/org/keycloak/services/util/LocaleUtil.java +++ b/services/src/main/java/org/keycloak/services/util/LocaleUtil.java @@ -43,10 +43,15 @@ public class LocaleUtil { } public static void processLocaleParam(KeycloakSession session, RealmModel realm, AuthenticationSessionModel authSession) { - if (authSession != null && realm.isInternationalizationEnabled()) { + if (realm.isInternationalizationEnabled()) { String locale = session.getContext().getUri().getQueryParameters().getFirst(LocaleSelectorProvider.KC_LOCALE_PARAM); if (locale != null) { - authSession.setAuthNote(LocaleSelectorProvider.USER_REQUEST_LOCALE, locale); + if (authSession != null) { + authSession.setAuthNote(LocaleSelectorProvider.USER_REQUEST_LOCALE, locale); + } else { + // Might be on info/error page when we don't have authenticationSession + session.setAttribute(LocaleSelectorProvider.USER_REQUEST_LOCALE, locale); + } LocaleUpdaterProvider localeUpdater = session.getProvider(LocaleUpdaterProvider.class); localeUpdater.updateLocaleCookie(locale); diff --git a/services/src/main/java/org/keycloak/theme/beans/MessageBean.java b/services/src/main/java/org/keycloak/theme/beans/MessageBean.java index a8e76913de..baba798b2f 100755 --- a/services/src/main/java/org/keycloak/theme/beans/MessageBean.java +++ b/services/src/main/java/org/keycloak/theme/beans/MessageBean.java @@ -16,6 +16,8 @@ */ package org.keycloak.theme.beans; +import org.keycloak.forms.login.MessageType; + /** * @author Stian Thorgersen */ diff --git a/services/src/main/java/org/keycloak/theme/beans/MessageType.java b/services/src/main/java/org/keycloak/theme/beans/MessageType.java deleted file mode 100755 index da37816620..0000000000 --- a/services/src/main/java/org/keycloak/theme/beans/MessageType.java +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright 2016 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.theme.beans; - -/** - * Enum with types of messages. - * - * @author Vlastimil Elias (velias at redhat dot com) - */ -public enum MessageType { - - SUCCESS, WARNING, INFO, ERROR - -} diff --git a/services/src/main/java/org/keycloak/theme/beans/MessagesPerFieldBean.java b/services/src/main/java/org/keycloak/theme/beans/MessagesPerFieldBean.java index e846cb9c13..49112dfeea 100755 --- a/services/src/main/java/org/keycloak/theme/beans/MessagesPerFieldBean.java +++ b/services/src/main/java/org/keycloak/theme/beans/MessagesPerFieldBean.java @@ -19,6 +19,8 @@ package org.keycloak.theme.beans; import java.util.HashMap; import java.util.Map; +import org.keycloak.forms.login.MessageType; + /** * Bean used to hold form messages per field. Stored under messagesPerField key in Freemarker context. * diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/ErrorPage.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/ErrorPage.java index a72d26752f..993f5a6cde 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/ErrorPage.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/ErrorPage.java @@ -26,7 +26,7 @@ import org.openqa.selenium.support.FindBy; /** * @author Stian Thorgersen */ -public class ErrorPage extends AbstractPage { +public class ErrorPage extends LanguageComboboxAwarePage { @ArquillianResource protected OAuthClient oauth; diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/i18n/EmailTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/i18n/EmailTest.java index 8ed08ed2d7..e5170892cb 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/i18n/EmailTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/i18n/EmailTest.java @@ -146,6 +146,7 @@ public class EmailTest extends AbstractI18NTest { } //KEYCLOAK-7478 + // Issue 13922 @Test public void changeLocaleOnInfoPage() throws InterruptedException, IOException { UserResource testUser = ApiUtil.findUserByUsernameId(testRealm(), "login-test"); @@ -178,5 +179,15 @@ public class EmailTest extends AbstractI18NTest { Assert.assertTrue("Expected to be on InfoPage, but it was on " + DroneUtils.getCurrentDriver().getTitle(), infoPage.isCurrent()); assertThat(infoPage.getInfo(), containsString("Your account has been updated.")); + + // Change language again when on final info page with the message about updated account (authSession removed already at this point) + infoPage.openLanguage("Deutsch"); + assertEquals("Deutsch", infoPage.getLanguageDropdownText()); + assertThat(infoPage.getInfo(), containsString("Ihr Benutzerkonto wurde aktualisiert.")); + + infoPage.openLanguage("English"); + assertEquals("English", infoPage.getLanguageDropdownText()); + assertThat(infoPage.getInfo(), containsString("Your account has been updated.")); + } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/i18n/LoginPageTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/i18n/LoginPageTest.java index ef8a5c2ac3..c8e9b2862b 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/i18n/LoginPageTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/i18n/LoginPageTest.java @@ -17,6 +17,8 @@ package org.keycloak.testsuite.i18n; import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; import java.util.Arrays; import java.util.Locale; @@ -29,12 +31,15 @@ import org.junit.Test; import org.keycloak.OAuth2Constants; import org.keycloak.adapters.HttpClientBuilder; import org.keycloak.admin.client.resource.UserResource; +import org.keycloak.common.util.KeycloakUriBuilder; +import org.keycloak.forms.login.freemarker.DetachedInfoStateChecker; import org.keycloak.locale.LocaleSelectorProvider; import org.keycloak.models.UserModel; import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.testsuite.admin.ApiUtil; import org.keycloak.testsuite.pages.AppPage; +import org.keycloak.testsuite.pages.ErrorPage; import org.keycloak.testsuite.pages.LanguageComboboxAwarePage; import org.keycloak.testsuite.pages.LoginPage; @@ -62,6 +67,9 @@ public class LoginPageTest extends AbstractI18NTest { @Page protected LoginPage loginPage; + @Page + protected ErrorPage errorPage; + @Page protected LoginPasswordUpdatePage changePasswordPage; @@ -255,6 +263,55 @@ public class LoginPageTest extends AbstractI18NTest { Assert.assertNull(localeCookie); } + + // Test for user updating locale on the error page (when authenticationSession is not available) + @Test + public void languageUserUpdatesOnErrorPage() { + // Login page with invalid redirect_uri + oauth.redirectUri("http://invalid"); + loginPage.open(); + + errorPage.assertCurrent(); + Assert.assertEquals("Invalid parameter: redirect_uri", errorPage.getError()); + + // Change language should be OK + errorPage.openLanguage("Deutsch"); + assertEquals("Deutsch", errorPage.getLanguageDropdownText()); + Assert.assertEquals("Ungültiger Parameter: redirect_uri", errorPage.getError()); + + // Refresh browser button should keep german language + driver.navigate().refresh(); + assertEquals("Deutsch", errorPage.getLanguageDropdownText()); + Assert.assertEquals("Ungültiger Parameter: redirect_uri", errorPage.getError()); + + // Changing to english should work + errorPage.openLanguage("English"); + assertEquals("English", errorPage.getLanguageDropdownText()); + Assert.assertEquals("Invalid parameter: redirect_uri", errorPage.getError()); + } + + @Test + public void languageUserUpdatesOnErrorPageStateCheckerTest() throws URISyntaxException { + // Login page with invalid redirect_uri + oauth.redirectUri("http://invalid"); + loginPage.open(); + + errorPage.assertCurrent(); + Assert.assertEquals("Invalid parameter: redirect_uri", errorPage.getError()); + + errorPage.openLanguage("Deutsch"); + Assert.assertEquals("Ungültiger Parameter: redirect_uri", errorPage.getError()); + + // Add incorrect state checker parameter. Error page should be shown about expired action. Language won't be changed + String currentUrl = driver.getCurrentUrl(); + String newUrl = KeycloakUriBuilder.fromUri(new URI(currentUrl)) + .replaceQueryParam(LocaleSelectorProvider.KC_LOCALE_PARAM, "en") + .replaceQueryParam(DetachedInfoStateChecker.STATE_CHECKER_PARAM, "invalid").buildAsString(); + driver.navigate().to(newUrl); + + Assert.assertEquals("Die Aktion ist nicht mehr gültig.", errorPage.getError()); // Action expired. + } + @Test public void realmLocalizationMessagesAreApplied() { String realmLocalizationMessageKey = "loginAccountTitle"; diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/RPInitiatedLogoutTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/RPInitiatedLogoutTest.java index b21b011318..19fc9df2b7 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/RPInitiatedLogoutTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/RPInitiatedLogoutTest.java @@ -901,6 +901,19 @@ public class RPInitiatedLogoutTest extends AbstractTestRealmKeycloakTest { infoPage.assertCurrent(); Assert.assertEquals("Odhlášení bylo úspěšné", infoPage.getInfo()); // Logout success message + + // Change locale on the info page (AuthenticationSession does not exists on server at this point) + infoPage.openLanguage("English"); + Assert.assertEquals("You are logged out", infoPage.getInfo()); // Logout success message + + // Change locale again + infoPage.openLanguage("Čeština"); + Assert.assertEquals("Odhlášení bylo úspěšné", infoPage.getInfo()); // Logout success message + + // Refresh page + driver.navigate().refresh(); + Assert.assertEquals("Odhlášení bylo úspěšné", infoPage.getInfo()); // Logout success message + infoPage.clickBackToApplicationLinkCs(); WaitUtils.waitForPageToLoad(); MatcherAssert.assertThat(driver.getCurrentUrl(), endsWith("/app/auth"));