Closes #9498 - Fix cases when user is forced to re-authenticate (#9580)

This commit is contained in:
Marek Posolda 2022-02-07 09:02:08 +01:00 committed by GitHub
parent f107f0596e
commit d9c8cb30a5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 606 additions and 168 deletions

View file

@ -111,6 +111,21 @@ public interface AbstractAuthenticationFlowContext {
*/
FormMessage getForwardedSuccessMessage();
/**
* This could be an info message forwarded from another authenticator. This info message will be usually displayed only once on the
* first screen shown to the user during authentication. The authenticator forwarding the info message does not know which the screen would be.
* For example during user re-authentication, the user should see info message like "Please re-authenticate", but at the beginning of the
* authentication, it is not 100% clear which screen will be the first shown screen where this message should be displayed
*/
FormMessage getForwardedInfoMessage();
/**
* @see #getForwardedInfoMessage()
* @param message to be forwarded
* @param parameters parameters of the message if any
*/
void setForwardedInfoMessage(String message, Object... parameters);
/**
* Generates access code and updates clientsession timestamp
* Access codes must be included in form action callbacks as a query parameter.

View file

@ -38,7 +38,7 @@ public interface LoginFormsProvider extends Provider {
String IDENTITY_PROVIDER_BROKER_CONTEXT = "identityProviderBrokerCtx";
String USERNAME_EDIT_DISABLED = "usernameEditDisabled";
String USERNAME_HIDDEN = "usernameHidden";
String REGISTRATION_DISABLED = "registrationDisabled";

View file

@ -105,6 +105,11 @@ public class AuthenticationProcessor {
*/
protected ForwardedFormMessageStore forwardedSuccessMessageStore = new ForwardedFormMessageStore(ForwardedFormMessageType.SUCCESS);
/**
* This could be an success message forwarded from another authenticator
*/
protected ForwardedFormMessageStore forwardedInfoMessageStore = new ForwardedFormMessageStore(ForwardedFormMessageType.INFO);
// Used for client authentication
protected ClientModel client;
protected Map<String, String> clientAuthAttributes = new HashMap<>();
@ -232,6 +237,11 @@ public class AuthenticationProcessor {
return this;
}
public AuthenticationProcessor setForwardedInfoMessage(FormMessage forwardedInfoMessage) {
this.forwardedInfoMessageStore.setForwardedMessage(forwardedInfoMessage);
return this;
}
public String generateCode() {
ClientSessionCode accessCode = new ClientSessionCode(session, getRealm(), getAuthenticationSession());
authenticationSession.getParentSession().setTimestamp(Time.currentTime());
@ -528,6 +538,9 @@ public class AuthenticationProcessor {
} else if (getForwardedSuccessMessage() != null) {
provider.addSuccess(getForwardedSuccessMessage());
forwardedSuccessMessageStore.removeForwardedMessage();
} else if (getForwardedInfoMessage() != null) {
provider.setInfo(getForwardedInfoMessage().getMessage(), getForwardedInfoMessage().getParameters());
forwardedInfoMessageStore.removeForwardedMessage();
}
return provider;
}
@ -642,6 +655,16 @@ public class AuthenticationProcessor {
return AuthenticationProcessor.this.forwardedSuccessMessageStore.getForwardedMessage();
}
@Override
public void setForwardedInfoMessage(String message, Object... parameters) {
AuthenticationProcessor.this.setForwardedInfoMessage(new FormMessage(message, parameters));
}
@Override
public FormMessage getForwardedInfoMessage() {
return AuthenticationProcessor.this.forwardedInfoMessageStore.getForwardedMessage();
}
public FormMessage getErrorMessage() {
return errorMessage;
}
@ -1139,7 +1162,7 @@ public class AuthenticationProcessor {
}
private enum ForwardedFormMessageType {
SUCCESS("fwMessageSuccess"), ERROR("fwMessageError");
SUCCESS("fwMessageSuccess"), ERROR("fwMessageError"), INFO("fwMessageInfo");
private final String key;

View file

@ -54,6 +54,9 @@ public abstract class AbstractUsernameFormAuthenticator extends AbstractFormAuth
public static final String REGISTRATION_FORM_ACTION = "registration_form";
public static final String ATTEMPTED_USERNAME = "ATTEMPTED_USERNAME";
// Flag is true if user was already set in the authContext before this authenticator was triggered. In this case we skip clearing of the user after unsuccessful password authentication
protected static final String USER_SET_BEFORE_USERNAME_PASSWORD_AUTH = "USER_SET_BEFORE_USERNAME_PASSWORD_AUTH";
@Override
public void action(AuthenticationFlowContext context) {
@ -142,18 +145,30 @@ public abstract class AbstractUsernameFormAuthenticator extends AbstractFormAuth
public boolean validateUserAndPassword(AuthenticationFlowContext context, MultivaluedMap<String, String> inputData) {
context.clearUser();
UserModel user = getUser(context, inputData);
return user != null && validatePassword(context, user, inputData) && validateUser(context, user, inputData);
boolean shouldClearUserFromCtxAfterBadPassword = !isUserAlreadySetBeforeUsernamePasswordAuth(context);
return user != null && validatePassword(context, user, inputData, shouldClearUserFromCtxAfterBadPassword) && validateUser(context, user, inputData);
}
public boolean validateUser(AuthenticationFlowContext context, MultivaluedMap<String, String> inputData) {
context.clearUser();
UserModel user = getUser(context, inputData);
return user != null && validateUser(context, user, inputData);
}
private UserModel getUser(AuthenticationFlowContext context, MultivaluedMap<String, String> inputData) {
if (isUserAlreadySetBeforeUsernamePasswordAuth(context)) {
// Get user from the authentication context in case he was already set before this authenticator
UserModel user = context.getUser();
testInvalidUser(context, user);
return user;
} else {
// Normal login. In this case this authenticator is supposed to establish identity of the user from the provided username
context.clearUser();
return getUserFromForm(context, inputData);
}
}
private UserModel getUserFromForm(AuthenticationFlowContext context, MultivaluedMap<String, String> inputData) {
String username = inputData.getFirst(AuthenticationManager.FORM_USERNAME);
if (username == null) {
context.getEvent().error(Errors.USER_NOT_FOUND);
@ -203,10 +218,6 @@ public abstract class AbstractUsernameFormAuthenticator extends AbstractFormAuth
return true;
}
public boolean validatePassword(AuthenticationFlowContext context, UserModel user, MultivaluedMap<String, String> inputData) {
return validatePassword(context, user, inputData, true);
}
public boolean validatePassword(AuthenticationFlowContext context, UserModel user, MultivaluedMap<String, String> inputData, boolean clearUser) {
String password = inputData.getFirst(CredentialRepresentation.PASSWORD);
if (password == null || password.isEmpty()) {
@ -226,6 +237,13 @@ public abstract class AbstractUsernameFormAuthenticator extends AbstractFormAuth
private boolean badPasswordHandler(AuthenticationFlowContext context, UserModel user, boolean clearUser,boolean isEmptyPassword) {
context.getEvent().user(user);
context.getEvent().error(Errors.INVALID_USER_CREDENTIALS);
if (isUserAlreadySetBeforeUsernamePasswordAuth(context)) {
LoginFormsProvider form = context.form();
form.setAttribute(LoginFormsProvider.USERNAME_HIDDEN, true);
form.setAttribute(LoginFormsProvider.REGISTRATION_DISABLED, true);
}
Response challengeResponse = challenge(context, getDefaultChallengeMessage(context), FIELD_PASSWORD);
if(isEmptyPassword) {
context.forceChallenge(challengeResponse);
@ -252,6 +270,15 @@ public abstract class AbstractUsernameFormAuthenticator extends AbstractFormAuth
}
protected String getDefaultChallengeMessage(AuthenticationFlowContext context) {
return Messages.INVALID_USER;
if (isUserAlreadySetBeforeUsernamePasswordAuth(context)) {
return Messages.INVALID_PASSWORD;
} else {
return Messages.INVALID_USER;
}
}
protected boolean isUserAlreadySetBeforeUsernamePasswordAuth(AuthenticationFlowContext context) {
String userSet = context.getAuthenticationSession().getAuthNote(USER_SET_BEFORE_USERNAME_PASSWORD_AUTH);
return Boolean.parseBoolean(userSet);
}
}

View file

@ -26,6 +26,7 @@ import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.protocol.LoginProtocol;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.messages.Messages;
import org.keycloak.sessions.AuthenticationSessionModel;
/**
@ -55,6 +56,7 @@ public class CookieAuthenticator implements Authenticator {
if (protocol.requireReauthentication(authResult.getSession(), authSession)) {
// Full re-authentication, so we start with no loa
authSession.setAuthNote(Constants.LEVEL_OF_AUTHENTICATION, String.valueOf(Constants.NO_LOA));
context.setForwardedInfoMessage(Messages.REAUTHENTICATE);
context.attempted();
} else if (!AuthenticatorUtil.isLevelOfAuthenticationSatisfied(authSession)) {
// Step-up authentication, we keep the loa from the existing user session.

View file

@ -17,8 +17,12 @@
package org.keycloak.authentication.authenticators.browser;
import java.util.List;
import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.forms.login.LoginFormsProvider;
import org.keycloak.forms.login.freemarker.LoginFormsUtil;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.services.messages.Messages;
import javax.ws.rs.core.MultivaluedMap;
@ -26,6 +30,20 @@ import javax.ws.rs.core.Response;
public final class UsernameForm extends UsernamePasswordForm {
@Override
public void authenticate(AuthenticationFlowContext context) {
if (context.getUser() != null) {
// We can skip the form when user is re-authenticating. Unless current user has some IDP set, so he can re-authenticate with that IDP
List<IdentityProviderModel> identityProviders = LoginFormsUtil
.filterIdentityProviders(context.getRealm().getIdentityProvidersStream(), context.getSession(), context);
if (identityProviders.isEmpty()) {
context.success();
return;
}
}
super.authenticate(context);
}
@Override
protected boolean validateForm(AuthenticationFlowContext context, MultivaluedMap<String, String> formData) {
return validateUser(context, formData);

View file

@ -62,12 +62,20 @@ public class UsernamePasswordForm extends AbstractUsernameFormAuthenticator impl
String rememberMeUsername = AuthenticationManager.getRememberMeUsername(context.getRealm(), context.getHttpRequest().getHttpHeaders());
if (loginHint != null || rememberMeUsername != null) {
if (loginHint != null) {
formData.add(AuthenticationManager.FORM_USERNAME, loginHint);
} else {
formData.add(AuthenticationManager.FORM_USERNAME, rememberMeUsername);
formData.add("rememberMe", "on");
if (context.getUser() != null) {
LoginFormsProvider form = context.form();
form.setAttribute(LoginFormsProvider.USERNAME_HIDDEN, true);
form.setAttribute(LoginFormsProvider.REGISTRATION_DISABLED, true);
context.getAuthenticationSession().setAuthNote(USER_SET_BEFORE_USERNAME_PASSWORD_AUTH, "true");
} else {
context.getAuthenticationSession().removeAuthNote(USER_SET_BEFORE_USERNAME_PASSWORD_AUTH);
if (loginHint != null || rememberMeUsername != null) {
if (loginHint != null) {
formData.add(AuthenticationManager.FORM_USERNAME, loginHint);
} else {
formData.add(AuthenticationManager.FORM_USERNAME, rememberMeUsername);
formData.add("rememberMe", "on");
}
}
}
Response challengeResponse = challenge(context, formData);

View file

@ -43,50 +43,33 @@ import java.util.stream.Stream;
*/
public class LoginFormsUtil {
// Display just those identityProviders on login screen, which are already linked to "known" established user
public static List<IdentityProviderModel> filterIdentityProvidersByUser(List<IdentityProviderModel> providers, KeycloakSession session, RealmModel realm,
Map<String, Object> attributes, MultivaluedMap<String, String> formData) {
Boolean usernameEditDisabled = (Boolean) attributes.get(LoginFormsProvider.USERNAME_EDIT_DISABLED);
if (usernameEditDisabled != null && usernameEditDisabled) {
String username = formData.getFirst(UserModel.USERNAME);
if (username == null) {
throw new IllegalStateException("USERNAME_EDIT_DISABLED but username not known");
}
UserModel user = session.users().getUserByUsername(realm, username);
if (user == null || !user.isEnabled()) {
throw new IllegalStateException("User " + username + " not found or disabled");
}
Set<String> federatedIdentities = session.users().getFederatedIdentitiesStream(realm, user)
.map(federatedIdentityModel -> federatedIdentityModel.getIdentityProvider())
.collect(Collectors.toSet());
List<IdentityProviderModel> result = new LinkedList<>();
for (IdentityProviderModel idp : providers) {
if (federatedIdentities.contains(idp.getAlias())) {
result.add(idp);
}
}
return result;
} else {
return providers;
}
}
public static List<IdentityProviderModel> filterIdentityProviders(Stream<IdentityProviderModel> providers, KeycloakSession session, AuthenticationFlowContext context) {
if (context != null) {
AuthenticationSessionModel authSession = context.getAuthenticationSession();
SerializedBrokeredIdentityContext serializedCtx = SerializedBrokeredIdentityContext.readFromAuthenticationSession(authSession, AbstractIdpAuthenticator.BROKERED_CONTEXT_NOTE);
if (serializedCtx != null) {
IdentityProviderModel idp = serializedCtx.deserialize(session, authSession).getIdpConfig();
return providers
.filter(p -> !Objects.equals(p.getAlias(), idp.getAlias()))
.collect(Collectors.toList());
final IdentityProviderModel existingIdp = (serializedCtx == null) ? null : serializedCtx.deserialize(session, authSession).getIdpConfig();
final Set<String> federatedIdentities;
if (context.getUser() != null) {
federatedIdentities = session.users().getFederatedIdentitiesStream(session.getContext().getRealm(), context.getUser())
.map(federatedIdentityModel -> federatedIdentityModel.getIdentityProvider())
.collect(Collectors.toSet());
} else {
federatedIdentities = null;
}
return providers
.filter(p -> { // Filter current IDP during first-broker-login flow. Re-authentication with the "linked" broker should not be possible
if (existingIdp == null) return true;
return !Objects.equals(p.getAlias(), existingIdp.getAlias());
})
.filter(idp -> { // In case that we already have user established in authentication session, we show just providers already linked to this user
if (federatedIdentities == null) return true;
return federatedIdentities.contains(idp.getAlias());
})
.collect(Collectors.toList());
}
return providers.collect(Collectors.toList());
}

View file

@ -24,6 +24,8 @@ public class Messages {
public static final String DISPLAY_UNSUPPORTED = "displayUnsupported";
public static final String LOGIN_TIMEOUT = "loginTimeout";
public static final String REAUTHENTICATE = "reauthenticate";
public static final String INVALID_USER = "invalidUserMessage";
public static final String INVALID_USERNAME = "invalidUsernameMessage";

View file

@ -59,6 +59,7 @@ import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserConsentModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.models.utils.AuthenticationFlowResolver;
import org.keycloak.models.utils.FormMessage;
import org.keycloak.models.utils.KeycloakModelUtils;
@ -231,6 +232,14 @@ public class LoginActionsService {
flowPath = AUTHENTICATE_PATH;
}
// See if we already have userSession attached to authentication session. This means restart of authentication session during re-authentication
// We logout userSession in this case
UserSessionModel userSession = new AuthenticationSessionManager(session).getUserSession(authSession);
if (userSession != null) {
logger.debugf("Logout of user session %s when restarting flow during re-authentication", userSession.getId());
AuthenticationManager.backchannelLogout(session, userSession, false);
}
AuthenticationProcessor.resetFlow(authSession, flowPath);
URI redirectUri = getLastExecutionUrl(flowPath, null, authSession.getClient().getClientId(), tabId);
@ -849,7 +858,6 @@ public class LoginActionsService {
/**
* OAuth grant page. You should not invoked this directly!
*
* @param formData
* @return
*/
@Path("consent")

View file

@ -122,6 +122,18 @@ public class LoginPage extends LanguageComboboxAwarePage {
return usernameInput.isEnabled();
}
public boolean isUsernameInputPresent() {
return !driver.findElements(By.id("username")).isEmpty();
}
public boolean isRegisterLinkPresent() {
return !driver.findElements(By.linkText("Register")).isEmpty();
}
public boolean isRememberMeCheckboxPresent() {
return !driver.findElements(By.id("rememberMe")).isEmpty();
}
public String getPassword() {
return passwordInput.getAttribute("value");
}
@ -154,7 +166,11 @@ public class LoginPage extends LanguageComboboxAwarePage {
return loginSuccessMessage != null ? loginSuccessMessage.getText() : null;
}
public String getInfoMessage() {
return loginInfoMessage != null ? loginInfoMessage.getText() : null;
try {
return getTextFromElement(loginInfoMessage);
} catch (NoSuchElementException e) {
return null;
}
}
@ -187,6 +203,11 @@ public class LoginPage extends LanguageComboboxAwarePage {
return DroneUtils.getCurrentDriver().findElement(By.id(id));
}
public boolean isSocialButtonPresent(String alias) {
String id = "social-" + alias;
return !DroneUtils.getCurrentDriver().findElements(By.id(id)).isEmpty();
}
public void resetPassword() {
clickLink(resetPasswordLink);
}

View file

@ -32,6 +32,11 @@ public class LoginUsernameOnlyPage extends LoginPage {
}
}
// Click button without fill anything
public void clickSubmitButton() {
submitButton.click();
}
/**
* Not supported for this implementation
*

View file

@ -19,6 +19,7 @@ package org.keycloak.testsuite.actions;
import org.jboss.arquillian.drone.api.annotation.Drone;
import org.jboss.arquillian.graphene.page.Page;
import org.junit.After;
import org.junit.Assert;
import org.junit.Rule;
import org.junit.Test;
import org.keycloak.admin.client.resource.UserResource;
@ -114,7 +115,8 @@ public class AppInitiatedActionResetPasswordTest extends AbstractAppInitiatedAct
doAIA();
loginPage.assertCurrent();
loginPage.login("test-user@localhost", "password");
Assert.assertEquals("test-user@localhost", loginPage.getAttemptedUsername());
loginPage.login("password");
changePasswordPage.assertCurrent();
assertTrue(changePasswordPage.isCancelDisplayed());

View file

@ -538,52 +538,6 @@ public class ClientInitiatedAccountLinkTest extends AbstractServletsAdapterTest
}
@Test
@DisableFeature(value = Profile.Feature.ACCOUNT2, skipRestart = true) // TODO remove this (KEYCLOAK-16228)
public void testAccountNotLinkedAutomatically() throws Exception {
RealmResource realm = adminClient.realms().realm(CHILD_IDP);
List<FederatedIdentityRepresentation> links = realm.users().get(childUserId).getFederatedIdentity();
Assert.assertTrue(links.isEmpty());
// Login to account mgmt first
profilePage.open(CHILD_IDP);
WaitUtils.waitForPageToLoad();
Assert.assertTrue(loginPage.isCurrent(CHILD_IDP));
loginPage.login("child", "password");
profilePage.assertCurrent();
// Now in another tab, open login screen with "prompt=login" . Login screen will be displayed even if I have SSO cookie
UriBuilder linkBuilder = UriBuilder.fromUri(appPage.getInjectedUrl().toString())
.path("nosuch");
String linkUrl = linkBuilder.clone()
.queryParam(OIDCLoginProtocol.PROMPT_PARAM, OIDCLoginProtocol.PROMPT_VALUE_LOGIN)
.build().toString();
navigateTo(linkUrl);
Assert.assertTrue(loginPage.isCurrent(CHILD_IDP));
loginPage.clickSocial(PARENT_IDP);
Assert.assertTrue(loginPage.isCurrent(PARENT_IDP));
loginPage.login(PARENT_USERNAME, "password");
// Test I was not automatically linked.
links = realm.users().get(childUserId).getFederatedIdentity();
Assert.assertTrue(links.isEmpty());
loginUpdateProfilePage.assertCurrent();
loginUpdateProfilePage.update("Joe", "Doe", "joe@parent.com");
errorPage.assertCurrent();
Assert.assertEquals("You are already authenticated as different user 'child' in this session. Please sign out first.", errorPage.getError());
logoutAll();
// Remove newly created user
String newUserId = ApiUtil.findUserByUsername(realm, "parent").getId();
getCleanup("child").addUserId(newUserId);
}
@Test
@DisableFeature(value = Profile.Feature.ACCOUNT2, skipRestart = true) // TODO remove this (KEYCLOAK-16228)
public void testAccountLinkingExpired() throws Exception {

View file

@ -855,7 +855,9 @@ public class DemoServletsAdapterTest extends AbstractServletsAdapterTest {
String appUri = tokenMinTTLPage.getUriBuilder().queryParam(OIDCLoginProtocol.PROMPT_PARAM, OIDCLoginProtocol.PROMPT_VALUE_LOGIN).build().toString();
URLUtils.navigateToUri(appUri);
assertCurrentUrlStartsWithLoginUrlOf(testRealmPage);
testRealmLoginPage.form().login("bburke@redhat.com", "password");
WaitUtils.waitForPageToLoad();
testRealmLoginPage.form().setPassword("password");
testRealmLoginPage.form().login();
AccessToken token = tokenMinTTLPage.getAccessToken();
int authTime = token.getAuthTime();
assertThat(authTime, is(greaterThanOrEqualTo(currentTime + 10)));

View file

@ -226,6 +226,21 @@ public abstract class AbstractBaseBrokerTest extends AbstractKeycloakTest {
logInWithBroker(bc);
}
// We are re-authenticating to the IDP. Hence it is assumed that "username" field is not visible on the login form on the IDP side
protected void logInAsUserInIDPWithReAuthenticate() {
driver.navigate().to(getAccountUrl(getConsumerRoot(), bc.consumerRealmName()));
waitForPage(driver, "sign in to", true);
log.debug("Clicking social " + bc.getIDPAlias());
loginPage.clickSocial(bc.getIDPAlias());
waitForPage(driver, "sign in to", true);
// We are re-authenticating. Username field not visible
log.debug("Reauthenticating");
Assert.assertFalse(loginPage.isUsernameInputPresent());
loginPage.login(bc.getUserPassword());
}
protected void logInWithBroker(BrokerConfiguration bc) {
logInWithIdp(bc.getIDPAlias(), bc.getUserLogin(), bc.getUserPassword());
}

View file

@ -461,7 +461,7 @@ public abstract class AbstractFirstBrokerLoginTest extends AbstractInitializedBa
waitForPage(driver, "account already exists", false);
} catch (Exception e) {
// this is a workaround to make this test work for both oidc and saml. when doing oidc the browser is redirected to the login page to finish the linking
loginPage.login(bc.getUserLogin(), bc.getUserPassword());
loginPage.login(bc.getUserPassword());
}
waitForPage(driver, "account already exists", false);

View file

@ -111,7 +111,7 @@ public class KcOIDCBrokerWithSignatureTest extends AbstractBaseBrokerTest {
// Set time offset. New keys can be downloaded. Check that user is able to login.
setTimeOffset(20);
logInAsUserInIDP();
logInAsUserInIDPWithReAuthenticate();
assertLoggedInAccountManagement();
}
@ -159,7 +159,7 @@ public class KcOIDCBrokerWithSignatureTest extends AbstractBaseBrokerTest {
// Even after time offset is user not able to login, because it uses old key hardcoded in identityProvider config
setTimeOffset(20);
logInAsUserInIDP();
logInAsUserInIDPWithReAuthenticate();
assertErrorPage("Unexpected error when authenticating with identity provider");
}
@ -193,7 +193,7 @@ public class KcOIDCBrokerWithSignatureTest extends AbstractBaseBrokerTest {
// Set key id to a valid one
cfg.setPublicKeySignatureVerifierKeyId(expectedKeyId);
updateIdentityProvider(idpRep);
logInAsUserInIDP();
logInAsUserInIDPWithReAuthenticate();
assertLoggedInAccountManagement();
logoutFromRealm(getConsumerRoot(), bc.consumerRealmName());

View file

@ -0,0 +1,349 @@
/*
* Copyright 2021 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.testsuite.forms;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.function.Consumer;
import org.hamcrest.Matcher;
import org.hamcrest.Matchers;
import org.jboss.arquillian.drone.api.annotation.Drone;
import org.jboss.arquillian.graphene.page.Page;
import org.jboss.arquillian.test.api.ArquillianResource;
import org.junit.Rule;
import org.junit.Test;
import org.keycloak.admin.client.resource.UserResource;
import org.keycloak.authentication.authenticators.browser.OTPFormAuthenticatorFactory;
import org.keycloak.authentication.authenticators.browser.PasswordFormFactory;
import org.keycloak.authentication.authenticators.browser.UsernameFormFactory;
import org.keycloak.authentication.authenticators.conditional.ConditionalUserConfiguredAuthenticatorFactory;
import org.keycloak.events.Details;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.representations.IDToken;
import org.keycloak.representations.idm.EventRepresentation;
import org.keycloak.representations.idm.FederatedIdentityRepresentation;
import org.keycloak.representations.idm.IdentityProviderRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
import org.keycloak.testsuite.Assert;
import org.keycloak.testsuite.AssertEvents;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude;
import org.keycloak.testsuite.auth.page.login.OneTimeCode;
import org.keycloak.testsuite.broker.SocialLoginTest;
import org.keycloak.testsuite.pages.AppPage;
import org.keycloak.testsuite.pages.ErrorPage;
import org.keycloak.testsuite.pages.LoginPage;
import org.keycloak.testsuite.pages.LoginTotpPage;
import org.keycloak.testsuite.pages.LoginUsernameOnlyPage;
import org.keycloak.testsuite.pages.PasswordPage;
import org.keycloak.testsuite.util.FederatedIdentityBuilder;
import org.keycloak.testsuite.util.FlowUtil;
import org.keycloak.testsuite.util.OAuthClient;
import org.openqa.selenium.By;
import org.openqa.selenium.NoSuchElementException;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import static org.hamcrest.CoreMatchers.is;
import static org.keycloak.testsuite.admin.AbstractAdminTest.loadJson;
import static org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude.AuthServer.REMOTE;
import static org.keycloak.testsuite.broker.SocialLoginTest.Provider.GITHUB;
import static org.keycloak.testsuite.broker.SocialLoginTest.Provider.GITLAB;
import static org.keycloak.testsuite.broker.SocialLoginTest.Provider.GOOGLE;
/**
* Test for various scenarios with user re-authentication
*
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
public class ReAuthenticationTest extends AbstractTestRealmKeycloakTest {
@ArquillianResource
protected OAuthClient oauth;
@Drone
protected WebDriver driver;
@Page
protected LoginPage loginPage;
@Page
protected LoginUsernameOnlyPage loginUsernameOnlyPage;
@Page
protected PasswordPage passwordPage;
@Page
protected ErrorPage errorPage;
@Page
protected LoginTotpPage loginTotpPage;
@Page
protected OneTimeCode oneTimeCodePage;
@Page
protected AppPage appPage;
@Override
public void configureTestRealm(RealmRepresentation testRealm) {
}
private RealmRepresentation loadTestRealm() {
RealmRepresentation res = loadJson(getClass().getResourceAsStream("/testrealm.json"), RealmRepresentation.class);
res.setBrowserFlow("browser");
res.setRememberMe(true);
// Add some sample dummy GitHub, Gitlab & Google social providers to the testing realm. Those are dummy providers for test if they are visible (clickable)
// on the login pages
List<IdentityProviderRepresentation> idps = new ArrayList<>();
for (SocialLoginTest.Provider provider : Arrays.asList(GITHUB, GOOGLE)) {
SocialLoginTest socialLoginTest = new SocialLoginTest();
idps.add(socialLoginTest.buildIdp(provider));
}
res.setIdentityProviders(idps);
return res;
}
@Override
public void addTestRealms(List<RealmRepresentation> testRealms) {
log.debug("Adding test realm for import from testrealm.json");
testRealms.add(loadTestRealm());
}
@Test
public void usernamePasswordFormReauthentication() {
// Add fake github link to user account
UserResource user = ApiUtil.findUserByUsernameId(testRealm(), "test-user@localhost");
FederatedIdentityRepresentation fedLink = FederatedIdentityBuilder.create()
.identityProvider("github")
.userId("123")
.userName("test")
.build();
user.addFederatedIdentity("github", fedLink);
// Login user
loginPage.open();
loginPage.assertCurrent();
assertUsernameFieldAndOtherFields(true);
assertSocialButtonsPresent(true, true);
loginPage.login("test-user@localhost", "password");
Assert.assertEquals(AppPage.RequestType.AUTH_RESPONSE, appPage.getRequestType());
// Set time offset
setTimeOffset(10);
// Request re-authentication
oauth.maxAge("1");
loginPage.open();
loginPage.assertCurrent();
// Username input hidden as well as register and rememberMe. Info message should be shown
assertUsernameFieldAndOtherFields(false);
assertInfoMessageAboutReAuthenticate(true);
// Assert github link present as it is linked to user account. Google link should be hidden
assertSocialButtonsPresent(true, false);
// Try bad password and assert things still hidden
loginPage.login("bad-password");
loginPage.assertCurrent();
Assert.assertEquals("Invalid password.", loginPage.getInputError());
assertUsernameFieldAndOtherFields(false);
assertInfoMessageAboutReAuthenticate(false);
loginPage.login("password");
Assert.assertEquals(AppPage.RequestType.AUTH_RESPONSE, appPage.getRequestType());
// Remove link
user.removeFederatedIdentity("github");
}
// Case when user press the link "Restart login" during re-authentication
@Test
public void usernamePasswordFormReauthenticationWithResetFlow() {
// Login user
loginPage.open();
loginPage.assertCurrent();
assertUsernameFieldAndOtherFields(true);
assertSocialButtonsPresent(true, true);
loginPage.login("test-user@localhost", "password");
Assert.assertEquals(AppPage.RequestType.AUTH_RESPONSE, appPage.getRequestType());
// Set time offset
setTimeOffset(10);
// Request re-authentication
oauth.maxAge("1");
loginPage.open();
loginPage.assertCurrent();
// Username input hidden as well as register and rememberMe. Info message should be shown
assertUsernameFieldAndOtherFields(false);
assertInfoMessageAboutReAuthenticate(true);
// Assert none of github link and google link present. As none of the providers is linked to user account
assertSocialButtonsPresent(false, false);
// Try click "Reset password" . This will start login page from the beginning due SSO logout
Assert.assertEquals("test-user@localhost", loginPage.getAttemptedUsername());
loginPage.clickResetLogin();
// Username field should be back. Attempted username should not be shown
loginPage.assertCurrent();
assertUsernameFieldAndOtherFields(true);
assertInfoMessageAboutReAuthenticate(false);
// Both social buttons should be present
assertSocialButtonsPresent(true, true);
// Successfully login as different user. It should be possible due previous SSO session was removed
loginPage.login("john-doh@localhost", "password");
Assert.assertEquals(AppPage.RequestType.AUTH_RESPONSE, appPage.getRequestType());
}
// Re-authentication with user form separate to the password form. The username form would be skipped
@Test
@AuthServerContainerExclude(REMOTE)
public void identityFirstFormReauthentication() {
// Set identity-first as realm flow
setupIdentityFirstFlow();
// Login user
loginPage.open();
loginUsernameOnlyPage.assertCurrent();
assertUsernameFieldAndOtherFields(true);
assertSocialButtonsPresent(true, true);
loginUsernameOnlyPage.login("test-user@localhost");
passwordPage.assertCurrent();
passwordPage.login("password");
Assert.assertEquals(AppPage.RequestType.AUTH_RESPONSE, appPage.getRequestType());
// Set time offset
setTimeOffset(10);
// Request re-authentication
oauth.maxAge("1");
loginPage.open();
// User directly on the password page. Info message should be shown here
passwordPage.assertCurrent();
Assert.assertEquals("test-user@localhost", passwordPage.getAttemptedUsername());
assertInfoMessageAboutReAuthenticate(true);
passwordPage.login("bad-password");
Assert.assertEquals("Invalid password.", passwordPage.getPasswordError());
passwordPage.login("password");
Assert.assertEquals(AppPage.RequestType.AUTH_RESPONSE, appPage.getRequestType());
// Revert flows
BrowserFlowTest.revertFlows(testRealm(), "browser - identity first");
}
// Re-authentication with user form separate to the password form. The username form is shown due the user linked with "github"
@Test
@AuthServerContainerExclude(REMOTE)
public void identityFirstFormReauthenticationWithGithubLink() {
// Set identity-first as realm flow
setupIdentityFirstFlow();
// Add fake federated link to the user
UserResource user = ApiUtil.findUserByUsernameId(testRealm(), "test-user@localhost");
FederatedIdentityRepresentation fedLink = FederatedIdentityBuilder.create()
.identityProvider("github")
.userId("123")
.userName("test")
.build();
user.addFederatedIdentity("github", fedLink);
// Login user
loginPage.open();
loginUsernameOnlyPage.assertCurrent();
loginUsernameOnlyPage.login("test-user@localhost");
passwordPage.assertCurrent();
passwordPage.login("password");
Assert.assertEquals(AppPage.RequestType.AUTH_RESPONSE, appPage.getRequestType());
// See that user can re-authenticate with the github link present on the page as user has link to github social provider
setTimeOffset(10);
oauth.maxAge("1");
loginPage.open();
// Username input hidden as well as register and rememberMe. Info message should be present
loginPage.assertCurrent();
assertUsernameFieldAndOtherFields(false);
assertInfoMessageAboutReAuthenticate(true);
// Check there is NO password field
Assert.assertThat(true, is(driver.findElements(By.id("password")).isEmpty()));
// Github present, Google hidden
assertSocialButtonsPresent(true, false);
// Confirm login with password
loginUsernameOnlyPage.clickSubmitButton();
// Login with password. Info message should not be there anymore
passwordPage.assertCurrent();
passwordPage.login("password");
assertInfoMessageAboutReAuthenticate(false);
Assert.assertEquals(AppPage.RequestType.AUTH_RESPONSE, appPage.getRequestType());
// Remove link and flow
user.removeFederatedIdentity("github");
BrowserFlowTest.revertFlows(testRealm(), "browser - identity first");
}
private void setupIdentityFirstFlow() {
String newFlowAlias = "browser - identity first";
testingClient.server("test").run(session -> FlowUtil.inCurrentRealm(session).copyBrowserFlow(newFlowAlias));
testingClient.server("test").run(session -> FlowUtil.inCurrentRealm(session)
.selectFlow(newFlowAlias)
.inForms(forms -> forms
.clear()
.addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.REQUIRED, UsernameFormFactory.PROVIDER_ID)
.addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.REQUIRED, PasswordFormFactory.PROVIDER_ID)
).defineAsBrowserFlow() // Activate this new flow
);
}
private void assertUsernameFieldAndOtherFields(boolean expectPresent) {
Assert.assertThat(expectPresent, is(loginPage.isUsernameInputPresent()));
Assert.assertThat(expectPresent, is(loginPage.isRegisterLinkPresent()));
Assert.assertThat(expectPresent, is(loginPage.isRememberMeCheckboxPresent()));
}
private void assertSocialButtonsPresent(boolean expectGithubPresent, boolean expectGooglePresent) {
Assert.assertThat(expectGithubPresent, is(loginPage.isSocialButtonPresent("github")));
Assert.assertThat(expectGooglePresent, is(loginPage.isSocialButtonPresent("google")));
}
private void assertInfoMessageAboutReAuthenticate(boolean expectPresent) {
Matcher<String> expectedInfo = expectPresent ? is("Please re-authenticate to continue") : Matchers.nullValue(String.class);
Assert.assertThat(loginPage.getInfoMessage(), expectedInfo);
}
}

View file

@ -17,6 +17,7 @@
package org.keycloak.testsuite.oauth;
import org.jboss.arquillian.graphene.page.Page;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
@ -37,6 +38,7 @@ import org.keycloak.testsuite.AbstractKeycloakTest;
import org.keycloak.testsuite.Assert;
import org.keycloak.testsuite.AssertEvents;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.pages.LoginPage;
import org.keycloak.testsuite.util.*;
import java.util.List;
@ -62,6 +64,9 @@ public class LogoutTest extends AbstractKeycloakTest {
@Rule
public AssertEvents events = new AssertEvents(this);
@Page
protected LoginPage loginPage;
@Override
public void beforeAbstractKeycloakTest() throws Exception {
super.beforeAbstractKeycloakTest();
@ -160,7 +165,8 @@ public class LogoutTest extends AbstractKeycloakTest {
setTimeOffset(2);
oauth.fillLoginForm("test-user@localhost", "password");
WaitUtils.waitForPageToLoad();
loginPage.login("password");
Assert.assertFalse(loginPage.isCurrent());

View file

@ -753,7 +753,8 @@ public class RefreshTokenTest extends AbstractKeycloakTest {
setTimeOffset(2);
// Continue with login
oauth.fillLoginForm("test-user@localhost", "password");
WaitUtils.waitForPageToLoad();
loginPage.login("password");
assertFalse(loginPage.isCurrent());
@ -786,7 +787,8 @@ public class RefreshTokenTest extends AbstractKeycloakTest {
setTimeOffset(2);
// Continue with login
oauth.fillLoginForm("test-user@localhost", "password");
WaitUtils.waitForPageToLoad();
loginPage.login("password");
assertFalse(loginPage.isCurrent());
@ -822,7 +824,8 @@ public class RefreshTokenTest extends AbstractKeycloakTest {
setTimeOffset(2);
// Continue with login
oauth.fillLoginForm("test-user@localhost", "password");
WaitUtils.waitForPageToLoad();
loginPage.login("password");
assertFalse(loginPage.isCurrent());
@ -1500,6 +1503,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest {
driver.navigate().to(loginFormUri);
loginPage.assertCurrent();
Assert.assertEquals("test-user@localhost", loginPage.getAttemptedUsername());
return refreshToken;
}

View file

@ -27,6 +27,7 @@ import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.message.BasicNameValuePair;
import org.jboss.arquillian.graphene.page.Page;
import org.junit.Rule;
import org.junit.Test;
import org.keycloak.OAuth2Constants;
@ -51,10 +52,12 @@ import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.oidc.OIDCScopeTest;
import org.keycloak.testsuite.oidc.AbstractOIDCScopeTest;
import org.keycloak.testsuite.pages.LoginPage;
import org.keycloak.testsuite.util.KeycloakModelUtils;
import org.keycloak.testsuite.util.OAuthClient;
import org.keycloak.testsuite.util.OAuthClient.AccessTokenResponse;
import org.keycloak.testsuite.util.TokenSignatureUtil;
import org.keycloak.testsuite.util.WaitUtils;
import org.keycloak.util.BasicAuthHelper;
import org.keycloak.util.JsonSerialization;
@ -80,6 +83,9 @@ public class TokenIntrospectionTest extends AbstractTestRealmKeycloakTest {
@Rule
public AssertEvents events = new AssertEvents(this);
@Page
protected LoginPage loginPage;
@Override
public void configureTestRealm(RealmRepresentation testRealm) {
ClientRepresentation confApp = KeycloakModelUtils.createClient(testRealm, "confidential-cli");
@ -227,7 +233,8 @@ public class TokenIntrospectionTest extends AbstractTestRealmKeycloakTest {
setTimeOffset(2);
oauth.fillLoginForm("test-user@localhost", "password");
WaitUtils.waitForPageToLoad();
loginPage.login("password");
events.expectLogin().assertEvent();
Assert.assertFalse(loginPage.isCurrent());

View file

@ -101,6 +101,7 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.hamcrest.CoreMatchers.is;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
@ -222,8 +223,11 @@ public class OIDCAdvancedRequestParamsTest extends AbstractTestRealmKeycloakTest
// Now open login form with maxAge=1
oauth.maxAge("1");
// Assert I need to login again through the login form
oauth.doLogin("test-user@localhost", "password");
// Assert I need to login again through the login form. But username field is not present
oauth.openLoginForm();
loginPage.assertCurrent();
Assert.assertThat(false, is(loginPage.isUsernameInputPresent()));
loginPage.login("password");
loginEvent = events.expectLogin().assertEvent();
idToken = sendTokenRequestAndGetIDToken(loginEvent);
@ -399,9 +403,9 @@ public class OIDCAdvancedRequestParamsTest extends AbstractTestRealmKeycloakTest
// Assert need to re-authenticate with prompt=login
driver.navigate().to(oauth.getLoginFormUrl() + "&prompt=login");
loginPage.assertCurrent();
loginPage.login("test-user@localhost", "password");
Assert.assertThat(false, is(loginPage.isUsernameInputPresent()));
loginPage.login("password");
Assert.assertEquals(AppPage.RequestType.AUTH_RESPONSE, appPage.getRequestType());
loginEvent = events.expectLogin().detail(Details.USERNAME, "test-user@localhost").assertEvent();
@ -416,31 +420,6 @@ public class OIDCAdvancedRequestParamsTest extends AbstractTestRealmKeycloakTest
}
@Test
public void promptLoginDifferentUser() throws Exception {
String sss = oauth.getLoginFormUrl();
System.out.println(sss);
// Login user
loginPage.open();
loginPage.login("test-user@localhost", "password");
Assert.assertEquals(AppPage.RequestType.AUTH_RESPONSE, appPage.getRequestType());
EventRepresentation loginEvent = events.expectLogin().detail(Details.USERNAME, "test-user@localhost").assertEvent();
IDToken idToken = sendTokenRequestAndGetIDToken(loginEvent);
// Assert need to re-authenticate with prompt=login
driver.navigate().to(oauth.getLoginFormUrl() + "&prompt=login");
// Authenticate as different user
loginPage.assertCurrent();
loginPage.login("john-doh@localhost", "password");
errorPage.assertCurrent();
Assert.assertTrue(errorPage.getError().startsWith("You are already authenticated as different user"));
}
// prompt=consent
@Test
public void promptConsent() {

View file

@ -18,6 +18,7 @@ package org.keycloak.testsuite.oidc;
import org.hamcrest.CoreMatchers;
import org.hamcrest.Matchers;
import org.jboss.arquillian.graphene.page.Page;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
@ -41,6 +42,7 @@ import org.keycloak.representations.AccessToken;
import org.keycloak.representations.idm.ClientScopeRepresentation;
import org.keycloak.representations.idm.ProtocolMapperRepresentation;
import org.keycloak.representations.idm.RoleRepresentation;
import org.keycloak.testsuite.pages.LoginPage;
import org.keycloak.testsuite.util.KeycloakModelUtils;
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.protocol.oidc.OIDCLoginProtocolService;
@ -59,6 +61,7 @@ import org.keycloak.testsuite.util.OAuthClient;
import org.keycloak.testsuite.util.RealmBuilder;
import org.keycloak.testsuite.util.TokenSignatureUtil;
import org.keycloak.testsuite.util.UserInfoClientUtil;
import org.keycloak.testsuite.util.WaitUtils;
import org.keycloak.util.BasicAuthHelper;
import org.keycloak.util.JsonSerialization;
import org.keycloak.utils.MediaType;
@ -95,6 +98,9 @@ public class UserInfoTest extends AbstractKeycloakTest {
@Rule
public AssertEvents events = new AssertEvents(this);
@Page
protected LoginPage loginPage;
@Override
public void beforeAbstractKeycloakTest() throws Exception {
super.beforeAbstractKeycloakTest();
@ -380,7 +386,8 @@ public class UserInfoTest extends AbstractKeycloakTest {
setTimeOffset(2);
oauth.fillLoginForm("test-user@localhost", "password");
WaitUtils.waitForPageToLoad();
loginPage.login("password");
events.expectLogin().assertEvent();
Assert.assertFalse(loginPage.isCurrent());

View file

@ -8,34 +8,28 @@
<#if realm.password>
<form id="kc-form-login" onsubmit="login.disabled = true; return true;" action="${url.loginAction}"
method="post">
<div class="${properties.kcFormGroupClass!}">
<label for="username"
class="${properties.kcLabelClass!}"><#if !realm.loginWithEmailAllowed>${msg("username")}<#elseif !realm.registrationEmailAsUsername>${msg("usernameOrEmail")}<#else>${msg("email")}</#if></label>
<#if !usernameHidden??>
<div class="${properties.kcFormGroupClass!}">
<label for="username"
class="${properties.kcLabelClass!}"><#if !realm.loginWithEmailAllowed>${msg("username")}<#elseif !realm.registrationEmailAsUsername>${msg("usernameOrEmail")}<#else>${msg("email")}</#if></label>
<#if usernameEditDisabled??>
<input tabindex="1" id="username"
aria-invalid="<#if message?has_content && message.type = 'error'>true</#if>"
class="${properties.kcInputClass!}" name="username"
value="${(login.username!'')}"
type="text" disabled/>
<#else>
<input tabindex="1" id="username"
aria-invalid="<#if messagesPerField.existsError('username')>true</#if>"
class="${properties.kcInputClass!}" name="username"
value="${(login.username!'')}"
type="text" autofocus autocomplete="off"/>
</#if>
<#if messagesPerField.existsError('username')>
<span id="input-error-username" class="${properties.kcInputErrorMessageClass!}" aria-live="polite">
${kcSanitize(messagesPerField.get('username'))?no_esc}
</span>
</#if>
</div>
<#if messagesPerField.existsError('username')>
<span id="input-error-username" class="${properties.kcInputErrorMessageClass!}" aria-live="polite">
${kcSanitize(messagesPerField.get('username'))?no_esc}
</span>
</#if>
</div>
</#if>
<div class="${properties.kcFormGroupClass!} ${properties.kcFormSettingClass!}">
<div id="kc-form-options">
<#if realm.rememberMe && !usernameEditDisabled??>
<#if realm.rememberMe && !usernameHidden??>
<div class="checkbox">
<label>
<#if login.rememberMe??>

View file

@ -7,12 +7,10 @@
<div id="kc-form-wrapper">
<#if realm.password>
<form id="kc-form-login" onsubmit="login.disabled = true; return true;" action="${url.loginAction}" method="post">
<div class="${properties.kcFormGroupClass!}">
<label for="username" class="${properties.kcLabelClass!}"><#if !realm.loginWithEmailAllowed>${msg("username")}<#elseif !realm.registrationEmailAsUsername>${msg("usernameOrEmail")}<#else>${msg("email")}</#if></label>
<#if !usernameHidden??>
<div class="${properties.kcFormGroupClass!}">
<label for="username" class="${properties.kcLabelClass!}"><#if !realm.loginWithEmailAllowed>${msg("username")}<#elseif !realm.registrationEmailAsUsername>${msg("usernameOrEmail")}<#else>${msg("email")}</#if></label>
<#if usernameEditDisabled??>
<input tabindex="1" id="username" class="${properties.kcInputClass!}" name="username" value="${(login.username!'')}" type="text" disabled />
<#else>
<input tabindex="1" id="username" class="${properties.kcInputClass!}" name="username" value="${(login.username!'')}" type="text" autofocus autocomplete="off"
aria-invalid="<#if messagesPerField.existsError('username','password')>true</#if>"
/>
@ -22,8 +20,9 @@
${kcSanitize(messagesPerField.getFirstError('username','password'))?no_esc}
</span>
</#if>
</#if>
</div>
</div>
</#if>
<div class="${properties.kcFormGroupClass!}">
<label for="password" class="${properties.kcLabelClass!}">${msg("password")}</label>
@ -31,11 +30,18 @@
<input tabindex="2" id="password" class="${properties.kcInputClass!}" name="password" type="password" autocomplete="off"
aria-invalid="<#if messagesPerField.existsError('username','password')>true</#if>"
/>
<#if usernameHidden?? && messagesPerField.existsError('username','password')>
<span id="input-error" class="${properties.kcInputErrorMessageClass!}" aria-live="polite">
${kcSanitize(messagesPerField.getFirstError('username','password'))?no_esc}
</span>
</#if>
</div>
<div class="${properties.kcFormGroupClass!} ${properties.kcFormSettingClass!}">
<div id="kc-form-options">
<#if realm.rememberMe && !usernameEditDisabled??>
<#if realm.rememberMe && !usernameHidden??>
<div class="checkbox">
<label>
<#if login.rememberMe??>

View file

@ -33,6 +33,7 @@ loginTotpTitle=Mobile Authenticator Setup
loginProfileTitle=Update Account Information
loginIdpReviewProfileTitle=Update Account Information
loginTimeout=Your login attempt timed out. Login will start from the beginning.
reauthenticate=Please re-authenticate to continue
oauthGrantTitle=Grant Access to {0}
oauthGrantTitleHtml={0}
oauthGrantInformation=Make sure you trust {0} by learning how {0} will handle your data.