Fix updating locale on info/error page after authenticationSession was already removed

Closes #13922
This commit is contained in:
mposolda 2023-06-26 13:55:13 +02:00 committed by Pedro Igor
parent e3e123b577
commit ccbddb2258
22 changed files with 474 additions and 60 deletions

View file

@ -139,6 +139,14 @@ public interface LoginFormsProvider extends Provider {
LoginFormsProvider setInfo(String message, Object ... parameters); 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 setUser(UserModel user);
LoginFormsProvider setResponseHeader(String headerName, String headerValue); LoginFormsProvider setResponseHeader(String headerName, String headerValue);

View file

@ -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
}

View file

@ -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 <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
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<String> 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<String> 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()));
}
}
}

View file

@ -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 <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
public class DetachedInfoStateCookie implements Token {
@JsonProperty("mky")
private String messageKey;
@JsonProperty("mty")
private String messageType;
@JsonProperty("mpar")
private List<String> 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<String> getMessageParameters() {
return messageParameters;
}
public void setMessageParameters(List<String> 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();
}
}

View file

@ -86,7 +86,7 @@ import org.keycloak.theme.beans.AdvancedMessageFormatterMethod;
import org.keycloak.theme.beans.LocaleBean; import org.keycloak.theme.beans.LocaleBean;
import org.keycloak.theme.beans.MessageBean; import org.keycloak.theme.beans.MessageBean;
import org.keycloak.theme.beans.MessageFormatterMethod; 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.beans.MessagesPerFieldBean;
import org.keycloak.theme.freemarker.FreeMarkerProvider; import org.keycloak.theme.freemarker.FreeMarkerProvider;
import org.keycloak.userprofile.UserProfileContext; import org.keycloak.userprofile.UserProfileContext;
@ -112,6 +112,7 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider {
protected MessageType messageType = MessageType.ERROR; protected MessageType messageType = MessageType.ERROR;
protected MultivaluedMap<String, String> formData; protected MultivaluedMap<String, String> formData;
protected boolean detachedAuthSession = false;
protected KeycloakSession session; protected KeycloakSession session;
/** authenticationSession can be null for some renderings, mainly error pages */ /** authenticationSession can be null for some renderings, mainly error pages */
@ -490,6 +491,22 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider {
case LOGOUT_CONFIRM: case LOGOUT_CONFIRM:
b = UriBuilder.fromUri(Urls.logoutConfirm(baseUri, realm.getName())); b = UriBuilder.fromUri(Urls.logoutConfirm(baseUri, realm.getName()));
break; 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: default:
b = UriBuilder.fromUri(baseUri).path(uriInfo.getPath()); b = UriBuilder.fromUri(baseUri).path(uriInfo.getPath());
break; break;
@ -703,17 +720,24 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider {
return createResponse(LoginFormsPages.LOGOUT_CONFIRM); 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; messageType = type;
messages = new ArrayList<>(); messages = new ArrayList<>();
messages.add(new FormMessage(null, message, parameters)); 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() { protected String getFirstMessageUnformatted() {
if (messages != null && !messages.isEmpty()) { FormMessage formMessage = getFirstMessage();
return messages.get(0).getMessage(); return formMessage == null ? null : formMessage.getMessage();
}
return null;
} }
protected String formatMessage(FormMessage message, Properties messagesBundle, Locale locale) { protected String formatMessage(FormMessage message, Properties messagesBundle, Locale locale) {
@ -783,6 +807,16 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider {
return this; return this;
} }
@Override
public LoginFormsProvider setDetachedAuthSession() {
detachedAuthSession = true;
return this;
}
private boolean isDetachedAuthenticationSession() {
return detachedAuthSession || authenticationSession == null;
}
@Override @Override
public LoginFormsProvider setAuthenticationSession(AuthenticationSessionModel authenticationSession) { public LoginFormsProvider setAuthenticationSession(AuthenticationSessionModel authenticationSession) {
this.authenticationSession = authenticationSession; this.authenticationSession = authenticationSession;

View file

@ -75,14 +75,6 @@ public class UrlBean {
return Urls.realmRegisterPage(baseURI, realm).toString(); 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() { public String getLoginUpdateProfileUrl() {
return Urls.loginActionUpdateProfile(baseURI, realm).toString(); return Urls.loginActionUpdateProfile(baseURI, realm).toString();
} }

View file

@ -92,11 +92,7 @@ public class DefaultLocaleSelectorProvider implements LocaleSelectorProvider {
} }
private Locale getUserSelectedLocale(RealmModel realm, AuthenticationSessionModel session) { private Locale getUserSelectedLocale(RealmModel realm, AuthenticationSessionModel session) {
if (session == null) { String locale = session == null ? this.session.getAttribute(USER_REQUEST_LOCALE, String.class) : session.getAuthNote(USER_REQUEST_LOCALE);
return null;
}
String locale = session.getAuthNote(USER_REQUEST_LOCALE);
if (locale == null) { if (locale == null) {
return null; return null;
} }

View file

@ -28,7 +28,6 @@ import org.keycloak.models.Constants;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.utils.SystemClientUtil; import org.keycloak.models.utils.SystemClientUtil;
import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.messages.Messages; import org.keycloak.services.messages.Messages;
import org.keycloak.sessions.AuthenticationSessionModel; import org.keycloak.sessions.AuthenticationSessionModel;
@ -51,7 +50,9 @@ public class LogoutUtil {
if (usedSystemClient) { if (usedSystemClient) {
loginForm.setAttribute(Constants.SKIP_LINK, true); loginForm.setAttribute(Constants.SKIP_LINK, true);
} }
return loginForm.createInfoPage(); return loginForm
.setDetachedAuthSession()
.createInfoPage();
} }

View file

@ -107,12 +107,8 @@ public class Urls {
return realmLogout(baseUri).path(LogoutEndpoint.class, "logoutConfirmAction").build(realmName); return realmLogout(baseUri).path(LogoutEndpoint.class, "logoutConfirmAction").build(realmName);
} }
public static URI loginActionUpdatePassword(URI baseUri, String realmName) { public static URI loginActionsDetachedInfo(URI baseUri, String realmName) {
return loginActionsBase(baseUri).path(LoginActionsService.class, "updatePassword").build(realmName); return loginActionsBase(baseUri).path(LoginActionsService.class, "detachedInfo").build(realmName);
}
public static URI loginActionUpdateTotp(URI baseUri, String realmName) {
return loginActionsBase(baseUri).path(LoginActionsService.class, "updateTotp").build(realmName);
} }
public static UriBuilder requiredActionBase(URI baseUri) { public static UriBuilder requiredActionBase(URI baseUri) {

View file

@ -16,7 +16,7 @@ import org.keycloak.theme.Theme;
import org.keycloak.theme.beans.LocaleBean; import org.keycloak.theme.beans.LocaleBean;
import org.keycloak.theme.beans.MessageBean; import org.keycloak.theme.beans.MessageBean;
import org.keycloak.theme.beans.MessageFormatterMethod; 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.theme.freemarker.FreeMarkerProvider;
import org.keycloak.utils.MediaType; import org.keycloak.utils.MediaType;
import org.keycloak.utils.MediaTypeMatcher; import org.keycloak.utils.MediaTypeMatcher;

View file

@ -1072,6 +1072,7 @@ public class AuthenticationManager {
infoPage.setAttribute(Constants.SKIP_LINK, true); infoPage.setAttribute(Constants.SKIP_LINK, true);
} }
Response response = infoPage Response response = infoPage
.setDetachedAuthSession()
.createInfoPage(); .createInfoPage();
new AuthenticationSessionManager(session).removeAuthenticationSession(authSession.getRealm(), authSession, true); new AuthenticationSessionManager(session).removeAuthenticationSession(authSession.getRealm(), authSession, true);

View file

@ -18,8 +18,8 @@
package org.keycloak.services.managers; package org.keycloak.services.managers;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
import org.keycloak.common.ClientConnection;
import org.keycloak.common.util.ServerCookie.SameSiteAttributeValue; import org.keycloak.common.util.ServerCookie.SameSiteAttributeValue;
import org.keycloak.forms.login.LoginFormsProvider;
import org.keycloak.models.ClientModel; import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
@ -222,6 +222,9 @@ public class AuthenticationSessionManager {
if (expireRestartCookie) { if (expireRestartCookie) {
UriInfo uriInfo = session.getContext().getUri(); UriInfo uriInfo = session.getContext().getUri();
RestartLoginCookie.expireRestartCookie(realm, uriInfo, session); 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();
} }
} }

View file

@ -19,6 +19,10 @@ package org.keycloak.services.resources;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
import org.keycloak.common.Profile; import org.keycloak.common.Profile;
import org.keycloak.common.util.ResponseSessionTask; 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.http.HttpRequest;
import org.keycloak.OAuth2Constants; import org.keycloak.OAuth2Constants;
import org.keycloak.TokenVerifier; import org.keycloak.TokenVerifier;
@ -122,6 +126,8 @@ public class LoginActionsService {
public static final String RESTART_PATH = "restart"; 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 FORWARDED_ERROR_MESSAGE_NOTE = "forwardedErrorMessage";
public static final String SESSION_CODE = "session_code"; public static final String SESSION_CODE = "session_code";
@ -243,6 +249,50 @@ public class LoginActionsService {
return Response.status(Response.Status.FOUND).location(redirectUri).build(); 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 * protocol independent login page entry point

View file

@ -222,6 +222,7 @@ public class SessionCodeChecks {
ClientModel client = authSession.getClient(); ClientModel client = authSession.getClient();
if (client == null) { if (client == null) {
event.error(Errors.CLIENT_NOT_FOUND); event.error(Errors.CLIENT_NOT_FOUND);
session.getProvider(LoginFormsProvider.class).setDetachedAuthSession();
response = ErrorPage.error(session, authSession, Response.Status.BAD_REQUEST, Messages.UNKNOWN_LOGIN_REQUESTER); response = ErrorPage.error(session, authSession, Response.Status.BAD_REQUEST, Messages.UNKNOWN_LOGIN_REQUESTER);
clientCode.removeExpiredClientSession(); clientCode.removeExpiredClientSession();
return false; return false;
@ -232,6 +233,7 @@ public class SessionCodeChecks {
if (checkClientDisabled(client)) { if (checkClientDisabled(client)) {
event.error(Errors.CLIENT_DISABLED); event.error(Errors.CLIENT_DISABLED);
session.getProvider(LoginFormsProvider.class).setDetachedAuthSession();
response = ErrorPage.error(session, authSession, Response.Status.BAD_REQUEST, Messages.LOGIN_REQUESTER_NOT_ENABLED); response = ErrorPage.error(session, authSession, Response.Status.BAD_REQUEST, Messages.LOGIN_REQUESTER_NOT_ENABLED);
clientCode.removeExpiredClientSession(); clientCode.removeExpiredClientSession();
return false; return false;

View file

@ -43,10 +43,15 @@ public class LocaleUtil {
} }
public static void processLocaleParam(KeycloakSession session, RealmModel realm, AuthenticationSessionModel authSession) { 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); String locale = session.getContext().getUri().getQueryParameters().getFirst(LocaleSelectorProvider.KC_LOCALE_PARAM);
if (locale != null) { 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); LocaleUpdaterProvider localeUpdater = session.getProvider(LocaleUpdaterProvider.class);
localeUpdater.updateLocaleCookie(locale); localeUpdater.updateLocaleCookie(locale);

View file

@ -16,6 +16,8 @@
*/ */
package org.keycloak.theme.beans; package org.keycloak.theme.beans;
import org.keycloak.forms.login.MessageType;
/** /**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a> * @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
*/ */

View file

@ -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
}

View file

@ -19,6 +19,8 @@ package org.keycloak.theme.beans;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import org.keycloak.forms.login.MessageType;
/** /**
* Bean used to hold form messages per field. Stored under <code>messagesPerField</code> key in Freemarker context. * Bean used to hold form messages per field. Stored under <code>messagesPerField</code> key in Freemarker context.
* *

View file

@ -26,7 +26,7 @@ import org.openqa.selenium.support.FindBy;
/** /**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a> * @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
*/ */
public class ErrorPage extends AbstractPage { public class ErrorPage extends LanguageComboboxAwarePage {
@ArquillianResource @ArquillianResource
protected OAuthClient oauth; protected OAuthClient oauth;

View file

@ -146,6 +146,7 @@ public class EmailTest extends AbstractI18NTest {
} }
//KEYCLOAK-7478 //KEYCLOAK-7478
// Issue 13922
@Test @Test
public void changeLocaleOnInfoPage() throws InterruptedException, IOException { public void changeLocaleOnInfoPage() throws InterruptedException, IOException {
UserResource testUser = ApiUtil.findUserByUsernameId(testRealm(), "login-test"); 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()); 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.")); 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."));
} }
} }

View file

@ -17,6 +17,8 @@
package org.keycloak.testsuite.i18n; package org.keycloak.testsuite.i18n;
import java.io.IOException; import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Arrays; import java.util.Arrays;
import java.util.Locale; import java.util.Locale;
@ -29,12 +31,15 @@ import org.junit.Test;
import org.keycloak.OAuth2Constants; import org.keycloak.OAuth2Constants;
import org.keycloak.adapters.HttpClientBuilder; import org.keycloak.adapters.HttpClientBuilder;
import org.keycloak.admin.client.resource.UserResource; 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.locale.LocaleSelectorProvider;
import org.keycloak.models.UserModel; import org.keycloak.models.UserModel;
import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testsuite.admin.ApiUtil; import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.pages.AppPage; import org.keycloak.testsuite.pages.AppPage;
import org.keycloak.testsuite.pages.ErrorPage;
import org.keycloak.testsuite.pages.LanguageComboboxAwarePage; import org.keycloak.testsuite.pages.LanguageComboboxAwarePage;
import org.keycloak.testsuite.pages.LoginPage; import org.keycloak.testsuite.pages.LoginPage;
@ -62,6 +67,9 @@ public class LoginPageTest extends AbstractI18NTest {
@Page @Page
protected LoginPage loginPage; protected LoginPage loginPage;
@Page
protected ErrorPage errorPage;
@Page @Page
protected LoginPasswordUpdatePage changePasswordPage; protected LoginPasswordUpdatePage changePasswordPage;
@ -255,6 +263,55 @@ public class LoginPageTest extends AbstractI18NTest {
Assert.assertNull(localeCookie); 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 @Test
public void realmLocalizationMessagesAreApplied() { public void realmLocalizationMessagesAreApplied() {
String realmLocalizationMessageKey = "loginAccountTitle"; String realmLocalizationMessageKey = "loginAccountTitle";

View file

@ -901,6 +901,19 @@ public class RPInitiatedLogoutTest extends AbstractTestRealmKeycloakTest {
infoPage.assertCurrent(); infoPage.assertCurrent();
Assert.assertEquals("Odhlášení bylo úspěšné", infoPage.getInfo()); // Logout success message 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(); infoPage.clickBackToApplicationLinkCs();
WaitUtils.waitForPageToLoad(); WaitUtils.waitForPageToLoad();
MatcherAssert.assertThat(driver.getCurrentUrl(), endsWith("/app/auth")); MatcherAssert.assertThat(driver.getCurrentUrl(), endsWith("/app/auth"));