KEYCLOAK-12183 Refactor login screens. Introduce try-another-way link. Not show many credentials of same type in credential selector (#6591)
This commit is contained in:
parent
221aad9877
commit
8d49409de1
36 changed files with 1101 additions and 512 deletions
|
@ -57,6 +57,7 @@ import javax.persistence.criteria.Root;
|
|||
import javax.persistence.criteria.Subquery;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
|
@ -896,7 +897,9 @@ public class JpaUserProvider implements UserProvider, UserCredentialStore {
|
|||
if (userEntity != null) {
|
||||
|
||||
// user already in persistence context, no need to execute a query
|
||||
results = userEntity.getCredentials().stream().filter(it -> type.equals(it.getType())).collect(Collectors.toList());
|
||||
results = userEntity.getCredentials().stream().filter(it -> type.equals(it.getType()))
|
||||
.sorted(Comparator.comparingInt(CredentialEntity::getPriority))
|
||||
.collect(Collectors.toList());
|
||||
List<CredentialModel> rtn = new LinkedList<>();
|
||||
for (CredentialEntity entity : results) {
|
||||
rtn.add(toModel(entity));
|
||||
|
|
|
@ -50,19 +50,6 @@ public interface AuthenticationFlowContext extends AbstractAuthenticationFlowCon
|
|||
*/
|
||||
void setUser(UserModel user);
|
||||
|
||||
/**
|
||||
* Gets the credential currently selected in this flow
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
String getSelectedCredentialId();
|
||||
|
||||
/**
|
||||
* Sets a selected credential for this flow
|
||||
* @param credentialModel
|
||||
*/
|
||||
void setSelectedCredentialId(String credentialModel);
|
||||
|
||||
List<AuthenticationSelectionOption> getAuthenticationSelections();
|
||||
|
||||
void setAuthenticationSelections(List<AuthenticationSelectionOption> credentialAuthExecMap);
|
||||
|
|
|
@ -1,100 +1,41 @@
|
|||
package org.keycloak.authentication;
|
||||
|
||||
import org.keycloak.credential.CredentialModel;
|
||||
import org.keycloak.models.AuthenticationExecutionModel;
|
||||
import org.keycloak.models.AuthenticationFlowModel;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
|
||||
public class AuthenticationSelectionOption {
|
||||
|
||||
private final KeycloakSession session;
|
||||
private final AuthenticationExecutionModel authExec;
|
||||
private final CredentialModel credential;
|
||||
private final AuthenticationFlowModel authFlow;
|
||||
private boolean showCredentialName = true;
|
||||
private boolean showCredentialType = true;
|
||||
|
||||
public AuthenticationSelectionOption(AuthenticationExecutionModel authExec) {
|
||||
public AuthenticationSelectionOption(KeycloakSession session, AuthenticationExecutionModel authExec) {
|
||||
this.session = session;
|
||||
this.authExec = authExec;
|
||||
this.credential = new CredentialModel();
|
||||
this.authFlow = null;
|
||||
}
|
||||
|
||||
public AuthenticationSelectionOption(AuthenticationExecutionModel authExec, CredentialModel credential) {
|
||||
this.authExec = authExec;
|
||||
//Allow themes to get all credential information, but not secret data
|
||||
this.credential = credential.shallowClone();
|
||||
this.credential.setSecretData("");
|
||||
this.authFlow = null;
|
||||
}
|
||||
|
||||
public AuthenticationSelectionOption(AuthenticationExecutionModel authExec, AuthenticationFlowModel authFlow) {
|
||||
this.authExec = authExec;
|
||||
this.credential = new CredentialModel();
|
||||
this.authFlow = authFlow;
|
||||
}
|
||||
|
||||
public void setShowCredentialName(boolean showCredentialName) {
|
||||
this.showCredentialName = showCredentialName;
|
||||
}
|
||||
public void setShowCredentialType(boolean showCredentialType) {
|
||||
this.showCredentialType = showCredentialType;
|
||||
}
|
||||
|
||||
public boolean showCredentialName(){
|
||||
if (credential.getId() == null) {
|
||||
return false;
|
||||
}
|
||||
return showCredentialName;
|
||||
}
|
||||
|
||||
public boolean showCredentialType(){
|
||||
return showCredentialType;
|
||||
}
|
||||
|
||||
public AuthenticationExecutionModel getAuthenticationExecution() {
|
||||
return authExec;
|
||||
}
|
||||
|
||||
public String getCredentialId(){
|
||||
return credential.getId();
|
||||
}
|
||||
|
||||
public String getAuthExecId(){
|
||||
return authExec.getId();
|
||||
}
|
||||
|
||||
public String getCredentialName() {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
if (showCredentialName()) {
|
||||
if (showCredentialType()) {
|
||||
sb.append(" - ");
|
||||
}
|
||||
if (credential.getUserLabel() == null || credential.getUserLabel().isEmpty()) {
|
||||
sb.append(credential.getId());
|
||||
} else {
|
||||
sb.append(credential.getUserLabel());
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
public String getAuthExecName() {
|
||||
if (authFlow != null) {
|
||||
String authFlowLabel = authFlow.getAlias();
|
||||
if (authFlowLabel == null || authFlowLabel.isEmpty()) {
|
||||
authFlowLabel = authFlow.getId();
|
||||
}
|
||||
return authFlowLabel;
|
||||
}
|
||||
return authExec.getAuthenticator();
|
||||
}
|
||||
|
||||
public String getId() {
|
||||
if (getCredentialId() == null) {
|
||||
return getAuthExecId() + "|";
|
||||
}
|
||||
return getAuthExecId() + "|" + getCredentialId();
|
||||
public String getAuthExecDisplayName() {
|
||||
// TODO: Retrieve the displayName for the authenticator from the AuthenticationFactory
|
||||
// TODO: Retrieve icon CSS style
|
||||
// TODO: Should be addressed as part of https://issues.redhat.com/browse/KEYCLOAK-12185
|
||||
return getAuthExecName();
|
||||
}
|
||||
|
||||
public CredentialModel getCredential(){
|
||||
return credential;
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return " authSelection - " + authExec.getAuthenticator();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,7 +24,7 @@ public enum LoginFormsPages {
|
|||
|
||||
LOGIN, LOGIN_USERNAME, LOGIN_PASSWORD, LOGIN_TOTP, LOGIN_CONFIG_TOTP, LOGIN_WEBAUTHN, LOGIN_VERIFY_EMAIL,
|
||||
LOGIN_IDP_LINK_CONFIRM, LOGIN_IDP_LINK_EMAIL,
|
||||
OAUTH_GRANT, LOGIN_RESET_PASSWORD, LOGIN_UPDATE_PASSWORD, REGISTER, INFO, ERROR, LOGIN_UPDATE_PROFILE,
|
||||
OAUTH_GRANT, LOGIN_RESET_PASSWORD, LOGIN_UPDATE_PASSWORD, LOGIN_SELECT_AUTHENTICATOR, REGISTER, INFO, ERROR, LOGIN_UPDATE_PROFILE,
|
||||
LOGIN_PAGE_EXPIRED, CODE, X509_CONFIRM, SAML_POST_FORM;
|
||||
|
||||
}
|
||||
|
|
|
@ -84,6 +84,8 @@ public interface LoginFormsProvider extends Provider {
|
|||
|
||||
Response createOAuthGrant();
|
||||
|
||||
Response createSelectAuthenticator();
|
||||
|
||||
Response createCode();
|
||||
|
||||
Response createX509ConfirmPage();
|
||||
|
|
|
@ -280,7 +280,6 @@ public class AuthenticationProcessor {
|
|||
List<AuthenticationExecutionModel> currentExecutions;
|
||||
FormMessage errorMessage;
|
||||
FormMessage successMessage;
|
||||
String selectedCredentialId;
|
||||
List<AuthenticationSelectionOption> authenticationSelections;
|
||||
|
||||
private Result(AuthenticationExecutionModel execution, Authenticator authenticator, List<AuthenticationExecutionModel> currentExecutions) {
|
||||
|
@ -399,16 +398,6 @@ public class AuthenticationProcessor {
|
|||
setAutheticatedUser(user);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getSelectedCredentialId() {
|
||||
return selectedCredentialId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setSelectedCredentialId(String selectedCredentialId) {
|
||||
this.selectedCredentialId = selectedCredentialId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<AuthenticationSelectionOption> getAuthenticationSelections() {
|
||||
return authenticationSelections;
|
||||
|
|
|
@ -0,0 +1,245 @@
|
|||
/*
|
||||
* Copyright 2019 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package org.keycloak.authentication;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import javax.ws.rs.core.MultivaluedHashMap;
|
||||
import javax.ws.rs.core.MultivaluedMap;
|
||||
|
||||
import org.jboss.logging.Logger;
|
||||
import org.keycloak.credential.CredentialModel;
|
||||
import org.keycloak.models.AuthenticationExecutionModel;
|
||||
import org.keycloak.models.AuthenticationFlowModel;
|
||||
import org.keycloak.models.RealmModel;
|
||||
|
||||
/**
|
||||
* Resolves set of AuthenticationSelectionOptions
|
||||
*
|
||||
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
|
||||
*/
|
||||
class AuthenticationSelectionResolver {
|
||||
|
||||
private static final Logger logger = Logger.getLogger(AuthenticationSelectionResolver.class);
|
||||
|
||||
/**
|
||||
* This method creates the list of authenticators that is presented to the user. For a required execution, this is
|
||||
* only the credentials associated to the authenticator, and for an alternative execution, this is all other alternative
|
||||
* executions in the flow, including the credentials.
|
||||
* <p>
|
||||
* In both cases, the credentials take precedence, with the order selected by the user (or his administrator).
|
||||
* <p>
|
||||
* The implementation needs to take various subflows into account.
|
||||
*
|
||||
* For example during configuration of the authentication flow like this:
|
||||
* - WebAuthn: ALTERNATIVE
|
||||
* - Password-and-OTP subflow: ALTERNATIVE
|
||||
* - Password REQUIRED
|
||||
* - OTP REQUIRED
|
||||
* The user can authenticate with: WebAuthn OR (Password AND OTP). In this case, the user should be able to choose between WebAuthn and Password
|
||||
* even if those mechanisms are in different subflows
|
||||
*
|
||||
* @param model The current execution model
|
||||
* @return an ordered list of the authentication selection options to present the user.
|
||||
*/
|
||||
static List<AuthenticationSelectionOption> createAuthenticationSelectionList(AuthenticationProcessor processor, AuthenticationExecutionModel model) {
|
||||
List<AuthenticationSelectionOption> authenticationSelectionList = new ArrayList<>();
|
||||
|
||||
if (processor.getAuthenticationSession() != null) {
|
||||
Map<String, AuthenticationExecutionModel> typeAuthExecMap = new HashMap<>();
|
||||
List<AuthenticationExecutionModel> nonCredentialExecutions = new ArrayList<>();
|
||||
|
||||
String topFlowId = getFlowIdOfTheHighestUsefulFlow(processor, model);
|
||||
|
||||
if (topFlowId == null) {
|
||||
addSimpleAuthenticationExecution(processor, model, typeAuthExecMap, nonCredentialExecutions);
|
||||
} else {
|
||||
addAllExecutionsFromSubflow(processor, topFlowId, typeAuthExecMap, nonCredentialExecutions);
|
||||
}
|
||||
|
||||
//add credential authenticators in order
|
||||
if (processor.getAuthenticationSession().getAuthenticatedUser() != null) {
|
||||
List<CredentialModel> credentials = processor.getSession().userCredentialManager()
|
||||
.getStoredCredentials(processor.getRealm(), processor.getAuthenticationSession().getAuthenticatedUser())
|
||||
.stream()
|
||||
.filter(credential -> typeAuthExecMap.containsKey(credential.getType()))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
authenticationSelectionList = credentials.stream()
|
||||
.map(CredentialModel::getType)
|
||||
.distinct()
|
||||
.map(credentialType -> new AuthenticationSelectionOption(processor.getSession(), typeAuthExecMap.get(credentialType)))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
//add all other authenticators
|
||||
for (AuthenticationExecutionModel exec : nonCredentialExecutions) {
|
||||
authenticationSelectionList.add(new AuthenticationSelectionOption(processor.getSession(), exec));
|
||||
}
|
||||
}
|
||||
|
||||
logger.debugf("Selections when trying execution '%s' : %s", model.getAuthenticator(), authenticationSelectionList);
|
||||
|
||||
return authenticationSelectionList;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Return the flowId of the "highest" subflow, which we need to take into account when creating list of authentication mechanisms
|
||||
* shown to the user.
|
||||
*
|
||||
* For example during configuration of the authentication flow like this:
|
||||
* - WebAuthn: ALTERNATIVE
|
||||
* - Password-and-OTP subflow: ALTERNATIVE
|
||||
* - Password REQUIRED
|
||||
* - OTP REQUIRED
|
||||
*
|
||||
* and assuming that "execution" parameter is PasswordForm, we also need to take the higher subflow into account as user
|
||||
* should be able to choose among WebAuthn and Password
|
||||
*
|
||||
* @param processor
|
||||
* @param execution
|
||||
* @return
|
||||
*/
|
||||
private static String getFlowIdOfTheHighestUsefulFlow(AuthenticationProcessor processor, AuthenticationExecutionModel execution) {
|
||||
String flowId = null;
|
||||
RealmModel realm = processor.getRealm();
|
||||
|
||||
while (true) {
|
||||
if (execution.isAlternative()) {
|
||||
//Consider parent flow as we need to get all alternative executions to be able to list their credentials
|
||||
flowId = execution.getParentFlow();
|
||||
} else if (execution.isRequired() || execution.isConditional()) {
|
||||
if (execution.isAuthenticatorFlow()) {
|
||||
flowId = execution.getFlowId();
|
||||
}
|
||||
|
||||
// Find the corresponding execution. If it is 1st REQUIRED execution in the particular subflow, we need to consider parent flow as well
|
||||
List<AuthenticationExecutionModel> executions = realm.getAuthenticationExecutions(execution.getParentFlow());
|
||||
int executionIndex = executions.indexOf(execution);
|
||||
if (executionIndex != 0) {
|
||||
return flowId;
|
||||
} else {
|
||||
flowId = execution.getParentFlow();
|
||||
}
|
||||
}
|
||||
|
||||
AuthenticationFlowModel flow = realm.getAuthenticationFlowById(flowId);
|
||||
if (flow.isTopLevel()) {
|
||||
return flowId;
|
||||
}
|
||||
execution = realm.getAuthenticationExecutionByFlowId(flowId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Process single authenticaion execution, which does NOT point to authentication flow.
|
||||
// Fill the typeAuthExecMap and nonCredentialExecutions accordingly
|
||||
private static void addSimpleAuthenticationExecution(AuthenticationProcessor processor, AuthenticationExecutionModel execution, Map<String, AuthenticationExecutionModel> typeAuthExecMap, List<AuthenticationExecutionModel> nonCredentialExecutions) {
|
||||
// Don't add already processed executions
|
||||
if (DefaultAuthenticationFlow.isProcessed(processor, execution)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Authenticator localAuthenticator = processor.getSession().getProvider(Authenticator.class, execution.getAuthenticator());
|
||||
if (!(localAuthenticator instanceof CredentialValidator)) {
|
||||
nonCredentialExecutions.add(execution);
|
||||
} else {
|
||||
CredentialValidator<?> cv = (CredentialValidator<?>) localAuthenticator;
|
||||
typeAuthExecMap.put(cv.getType(processor.getSession()), execution);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Fill the typeAuthExecMap and nonCredentialExecutions collections with all available authentication mechanisms for the particular subflow with
|
||||
* given flowId
|
||||
*
|
||||
* Return true if at least something was added to any of the list
|
||||
*/
|
||||
private static boolean addAllExecutionsFromSubflow(AuthenticationProcessor processor, String flowId, Map<String, AuthenticationExecutionModel> typeAuthExecMap, List<AuthenticationExecutionModel> nonCredentialExecutions) {
|
||||
AuthenticationFlowModel flowModel = processor.getRealm().getAuthenticationFlowById(flowId);
|
||||
if (flowModel == null) {
|
||||
throw new AuthenticationFlowException("Flow not found", AuthenticationFlowError.INTERNAL_ERROR);
|
||||
}
|
||||
|
||||
DefaultAuthenticationFlow flow = new DefaultAuthenticationFlow(processor, flowModel);
|
||||
|
||||
logger.debugf("Going through the flow '%s' for adding executions", flowModel.getAlias());
|
||||
|
||||
List<AuthenticationExecutionModel> executions = processor.getRealm().getAuthenticationExecutions(flowId);
|
||||
|
||||
List<AuthenticationExecutionModel> requiredList = new ArrayList<>();
|
||||
List<AuthenticationExecutionModel> alternativeList = new ArrayList<>();
|
||||
flow.fillListsOfExecutions(executions, requiredList, alternativeList);
|
||||
|
||||
// If requiredList is not empty, we're going to collect just very first execution from the flow
|
||||
if (!requiredList.isEmpty()) {
|
||||
AuthenticationExecutionModel requiredExecution = requiredList.stream().filter(ex -> {
|
||||
|
||||
if (ex.isRequired()) return true;
|
||||
|
||||
// For conditional execution, we must check if condition is true. Otherwise return false, which means trying next
|
||||
// requiredExecution in the list
|
||||
return !flow.isConditionalSubflowDisabled(ex);
|
||||
|
||||
}).findFirst().orElse(null);
|
||||
|
||||
// Not requiredExecution found. Returning false as we did not add any authenticator
|
||||
if (requiredExecution == null) return false;
|
||||
|
||||
// Don't add already processed executions
|
||||
if (flow.isProcessed(requiredExecution)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Recursively add credentials from required execution
|
||||
if (requiredExecution.isAuthenticatorFlow()) {
|
||||
return addAllExecutionsFromSubflow(processor, requiredExecution.getFlowId(), typeAuthExecMap, nonCredentialExecutions);
|
||||
} else {
|
||||
addSimpleAuthenticationExecution(processor, requiredExecution, typeAuthExecMap, nonCredentialExecutions);
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
// We're going through all the alternatives
|
||||
boolean anyAdded = false;
|
||||
|
||||
for (AuthenticationExecutionModel execution : alternativeList) {
|
||||
// Don't add already processed executions
|
||||
if (flow.isProcessed(execution)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!execution.isAuthenticatorFlow()) {
|
||||
addSimpleAuthenticationExecution(processor, execution, typeAuthExecMap, nonCredentialExecutions);
|
||||
anyAdded = true;
|
||||
} else {
|
||||
anyAdded |= addAllExecutionsFromSubflow(processor, execution.getFlowId(), typeAuthExecMap, nonCredentialExecutions);
|
||||
}
|
||||
}
|
||||
|
||||
return anyAdded;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
|
@ -20,7 +20,6 @@ package org.keycloak.authentication;
|
|||
import org.jboss.logging.Logger;
|
||||
import org.keycloak.OAuth2Constants;
|
||||
import org.keycloak.authentication.authenticators.conditional.ConditionalAuthenticator;
|
||||
import org.keycloak.credential.CredentialModel;
|
||||
import org.keycloak.models.AuthenticationExecutionModel;
|
||||
import org.keycloak.models.AuthenticationFlowModel;
|
||||
import org.keycloak.models.Constants;
|
||||
|
@ -32,15 +31,11 @@ import org.keycloak.services.util.AuthenticationFlowURLHelper;
|
|||
import org.keycloak.sessions.AuthenticationSessionModel;
|
||||
import org.keycloak.sessions.CommonClientSessionModel;
|
||||
|
||||
import javax.ws.rs.core.MultivaluedHashMap;
|
||||
import javax.ws.rs.core.MultivaluedMap;
|
||||
import javax.ws.rs.core.Response;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Map.Entry;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
|
@ -62,6 +57,10 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
|
|||
}
|
||||
|
||||
protected boolean isProcessed(AuthenticationExecutionModel model) {
|
||||
return isProcessed(processor, model);
|
||||
}
|
||||
|
||||
protected static boolean isProcessed(AuthenticationProcessor processor, AuthenticationExecutionModel model) {
|
||||
if (model.isDisabled()) return true;
|
||||
AuthenticationSessionModel.ExecutionStatus status = processor.getAuthenticationSession().getExecutionStatus().get(model.getId());
|
||||
if (status == null) return false;
|
||||
|
@ -97,12 +96,11 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
|
|||
}
|
||||
AuthenticationExecutionModel model = processor.getRealm().getAuthenticationExecutionById(actionExecution);
|
||||
if (model == null) {
|
||||
throw new AuthenticationFlowException("action is not in current execution", AuthenticationFlowError.INTERNAL_ERROR);
|
||||
throw new AuthenticationFlowException("Execution not found", AuthenticationFlowError.INTERNAL_ERROR);
|
||||
}
|
||||
|
||||
MultivaluedMap<String, String> inputData = processor.getRequest().getDecodedFormParameters();
|
||||
String authExecId = inputData.getFirst(Constants.AUTHENTICATION_EXECUTION);
|
||||
String selectedCredentialId = inputData.getFirst(Constants.CREDENTIAL_ID);
|
||||
|
||||
//check if the user has selected the "back" option
|
||||
if (inputData.containsKey("back")) {
|
||||
|
@ -118,18 +116,29 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
|
|||
|
||||
recursiveClearExecutionStatusOfAllExecutionsAfterOurExecutionInclusive(lastActionExecution);
|
||||
|
||||
Response response = processSingleFlowExecutionModel(lastActionExecution, null, false);
|
||||
Response response = processSingleFlowExecutionModel(lastActionExecution, false);
|
||||
if (response == null) {
|
||||
processor.getAuthenticationSession().removeAuthNote(AuthenticationProcessor.CURRENT_AUTHENTICATION_EXECUTION);
|
||||
return processFlow();
|
||||
} else return response;
|
||||
} else {
|
||||
// This normally shouldn't happen as "back" button shouldn't be available on the form. If it is still triggered, we show "pageExpired" page
|
||||
new AuthenticationFlowURLHelper(processor.getSession(), processor.getRealm(), processor.getUriInfo())
|
||||
return new AuthenticationFlowURLHelper(processor.getSession(), processor.getRealm(), processor.getUriInfo())
|
||||
.showPageExpired(authSession);
|
||||
}
|
||||
}
|
||||
|
||||
// User clicked on "try another way" link
|
||||
if (inputData.containsKey("tryAnotherWay")) {
|
||||
logger.info("User clicked on try another way");
|
||||
|
||||
List<AuthenticationSelectionOption> selectionOptions = createAuthenticationSelectionList(model);
|
||||
|
||||
AuthenticationProcessor.Result result = processor.createAuthenticatorContext(model, null, null);
|
||||
result.setAuthenticationSelections(selectionOptions);
|
||||
return result.form().createSelectAuthenticator();
|
||||
}
|
||||
|
||||
// check if the user has switched to a new authentication execution, and if so switch to it.
|
||||
if (authExecId != null && !authExecId.isEmpty()) {
|
||||
|
||||
|
@ -149,14 +158,13 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
|
|||
new AuthenticationFlowHistoryHelper(processor).pushExecution(selectionOptions.get(0).getAuthExecId());
|
||||
}
|
||||
|
||||
Response response = processSingleFlowExecutionModel(model, selectedCredentialId, false);
|
||||
Response response = processSingleFlowExecutionModel(model, false);
|
||||
if (response == null) {
|
||||
processor.getAuthenticationSession().removeAuthNote(AuthenticationProcessor.CURRENT_AUTHENTICATION_EXECUTION);
|
||||
checkAndValidateParentFlow(model);
|
||||
return processFlow();
|
||||
return continueAuthenticationAfterSuccessfulAction(model);
|
||||
} else return response;
|
||||
}
|
||||
//handle case where execution is a flow
|
||||
|
||||
//handle case where execution is a flow - This can happen during user registration for example
|
||||
if (model.isAuthenticatorFlow()) {
|
||||
logger.debug("execution is flow");
|
||||
AuthenticationFlow authenticationFlow = processor.createFlowExecution(model.getFlowId(), model);
|
||||
|
@ -169,24 +177,50 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
|
|||
return flowChallenge;
|
||||
}
|
||||
}
|
||||
|
||||
//handle normal execution case
|
||||
AuthenticatorFactory factory = getAuthenticatorFactory(model);
|
||||
Authenticator authenticator = createAuthenticator(factory);
|
||||
AuthenticationProcessor.Result result = processor.createAuthenticatorContext(model, authenticator, executions);
|
||||
result.setAuthenticationSelections(createAuthenticationSelectionList(model));
|
||||
|
||||
result.setSelectedCredentialId(selectedCredentialId);
|
||||
|
||||
logger.debugv("action: {0}", model.getAuthenticator());
|
||||
authenticator.action(result);
|
||||
Response response = processResult(result, true);
|
||||
if (response == null) {
|
||||
processor.getAuthenticationSession().removeAuthNote(AuthenticationProcessor.CURRENT_AUTHENTICATION_EXECUTION);
|
||||
checkAndValidateParentFlow(model);
|
||||
return processFlow();
|
||||
return continueAuthenticationAfterSuccessfulAction(model);
|
||||
} else return response;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Called after "actionExecutionModel" execution is finished (Either successful or attempted). Find the next appropriate authentication
|
||||
* flow where the authentication should continue and continue with authentication process.
|
||||
*
|
||||
* @param actionExecutionModel
|
||||
* @return Response if some more forms should be displayed during authentication. Null otherwise.
|
||||
*/
|
||||
private Response continueAuthenticationAfterSuccessfulAction(AuthenticationExecutionModel actionExecutionModel) {
|
||||
processor.getAuthenticationSession().removeAuthNote(AuthenticationProcessor.CURRENT_AUTHENTICATION_EXECUTION);
|
||||
|
||||
String firstUnfinishedParentFlowId = checkAndValidateParentFlow(actionExecutionModel);
|
||||
AuthenticationExecutionModel parentFlowExecution = processor.getRealm().getAuthenticationExecutionByFlowId(firstUnfinishedParentFlowId);
|
||||
|
||||
if (parentFlowExecution == null) {
|
||||
// This means that 1st unfinished ancestor flow is the top flow. We can just process it from the start
|
||||
return processFlow();
|
||||
} else {
|
||||
Response response = processSingleFlowExecutionModel(parentFlowExecution, false);
|
||||
if (response == null) {
|
||||
processor.getAuthenticationSession().removeAuthNote(AuthenticationProcessor.CURRENT_AUTHENTICATION_EXECUTION);
|
||||
return processFlow();
|
||||
} else {
|
||||
return response;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Clear execution status of targetExecution and also clear execution status of all the executions, which were triggered after this execution.
|
||||
* This covers also "flow" executions and executions, which were set automatically
|
||||
|
@ -261,37 +295,37 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
|
|||
* This method makes sure that the parent flow's corresponding execution is considered successful if its contained
|
||||
* executions are successful.
|
||||
* The purpose is for when an execution is validated through an action, to make sure its parent flow can be successful
|
||||
* when re-evaluation the flow tree.
|
||||
* when re-evaluation the flow tree. If the flow is successful, we will recursively check it's parent flow as well
|
||||
*
|
||||
* @param model An execution model.
|
||||
* @return flowId of the 1st ancestor flow, which is not yet successfully finished and may require some further processing
|
||||
*/
|
||||
private void checkAndValidateParentFlow(AuthenticationExecutionModel model) {
|
||||
private String checkAndValidateParentFlow(AuthenticationExecutionModel model) {
|
||||
while (true) {
|
||||
List<AuthenticationExecutionModel> localExecutions = processor.getRealm().getAuthenticationExecutions(model.getParentFlow());
|
||||
AuthenticationExecutionModel parentFlowModel = processor.getRealm().getAuthenticationExecutionByFlowId(model.getParentFlow());
|
||||
if (parentFlowModel != null &&
|
||||
AuthenticationExecutionModel parentFlowExecutionModel = processor.getRealm().getAuthenticationExecutionByFlowId(model.getParentFlow());
|
||||
if (parentFlowExecutionModel != null &&
|
||||
((model.isRequired() && localExecutions.stream().allMatch(processor::isSuccessful)) ||
|
||||
(model.isAlternative() && localExecutions.stream().anyMatch(processor::isSuccessful)))) {
|
||||
processor.getAuthenticationSession().setExecutionStatus(parentFlowModel.getId(), AuthenticationSessionModel.ExecutionStatus.SUCCESS);
|
||||
processor.getAuthenticationSession().setExecutionStatus(parentFlowExecutionModel.getId(), AuthenticationSessionModel.ExecutionStatus.SUCCESS);
|
||||
|
||||
// Flow is successfully finished. Recursively check whether it's parent flow is now successful as well
|
||||
model = parentFlowExecutionModel;
|
||||
} else {
|
||||
return model.getParentFlow();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Response processFlow() {
|
||||
logger.debug("processFlow");
|
||||
logger.debugf("processFlow: %s", flow.getAlias());
|
||||
|
||||
//separate flow elements into required and alternative elements
|
||||
List<AuthenticationExecutionModel> requiredList = new ArrayList<>();
|
||||
List<AuthenticationExecutionModel> alternativeList = new ArrayList<>();
|
||||
|
||||
for (AuthenticationExecutionModel execution : executions) {
|
||||
if (isConditionalAuthenticator(execution)) {
|
||||
continue;
|
||||
} else if (execution.isRequired() || execution.isConditional()) {
|
||||
requiredList.add(execution);
|
||||
} else if (execution.isAlternative()) {
|
||||
alternativeList.add(execution);
|
||||
}
|
||||
}
|
||||
fillListsOfExecutions(executions, requiredList, alternativeList);
|
||||
|
||||
//handle required elements : all required elements need to be executed
|
||||
boolean requiredElementsSuccessful = true;
|
||||
|
@ -303,11 +337,16 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
|
|||
requiredIListIterator.remove();
|
||||
continue;
|
||||
}
|
||||
Response response = processSingleFlowExecutionModel(required, null, true);
|
||||
Response response = processSingleFlowExecutionModel(required, true);
|
||||
requiredElementsSuccessful &= processor.isSuccessful(required) || isSetupRequired(required);
|
||||
if (response != null) {
|
||||
return response;
|
||||
}
|
||||
// Some required elements were not successful and did not return response.
|
||||
// We can break as we know that the whole subflow would be considered unsuccessful as well
|
||||
if (!requiredElementsSuccessful) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
//Evaluate alternative elements only if there are no required elements. This may also occur if there was only condition elements
|
||||
|
@ -321,7 +360,7 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
|
|||
//handle alternative elements: the first alternative element to be satisfied is enough
|
||||
for (AuthenticationExecutionModel alternative : alternativeList) {
|
||||
try {
|
||||
Response response = processSingleFlowExecutionModel(alternative, null, true);
|
||||
Response response = processSingleFlowExecutionModel(alternative, true);
|
||||
if (response != null) {
|
||||
return response;
|
||||
}
|
||||
|
@ -341,12 +380,38 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
|
|||
return null;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Just iterates over executionsToProcess and fill "requiredList" and "alternativeList" according to it
|
||||
*/
|
||||
void fillListsOfExecutions(List<AuthenticationExecutionModel> executionsToProcess, List<AuthenticationExecutionModel> requiredList, List<AuthenticationExecutionModel> alternativeList) {
|
||||
for (AuthenticationExecutionModel execution : executionsToProcess) {
|
||||
if (isConditionalAuthenticator(execution)) {
|
||||
continue;
|
||||
} else if (execution.isRequired() || execution.isConditional()) {
|
||||
requiredList.add(execution);
|
||||
} else if (execution.isAlternative()) {
|
||||
alternativeList.add(execution);
|
||||
}
|
||||
}
|
||||
|
||||
if (!requiredList.isEmpty() && !alternativeList.isEmpty()) {
|
||||
List<String> alternativeIds = alternativeList.stream()
|
||||
.map(AuthenticationExecutionModel::getAuthenticator)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
logger.warnf("REQUIRED and ALTERNATIVE elements at same level! Those alternative executions will be ignored: %s", alternativeIds);
|
||||
alternativeList.clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Checks if the conditional subflow passed in parameter is disabled.
|
||||
* @param model
|
||||
* @return
|
||||
*/
|
||||
private boolean isConditionalSubflowDisabled(AuthenticationExecutionModel model) {
|
||||
boolean isConditionalSubflowDisabled(AuthenticationExecutionModel model) {
|
||||
if (model == null || !model.isAuthenticatorFlow() || !model.isConditional()) {
|
||||
return false;
|
||||
};
|
||||
|
@ -395,7 +460,8 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
|
|||
return AuthenticationSessionModel.ExecutionStatus.SETUP_REQUIRED.equals(processor.getAuthenticationSession().getExecutionStatus().get(model.getId()));
|
||||
}
|
||||
|
||||
private Response processSingleFlowExecutionModel(AuthenticationExecutionModel model, String selectedCredentialId, boolean calledFromFlow) {
|
||||
|
||||
private Response processSingleFlowExecutionModel(AuthenticationExecutionModel model, boolean calledFromFlow) {
|
||||
logger.debugv("check execution: {0} requirement: {1}", model.getAuthenticator(), model.getRequirement());
|
||||
|
||||
if (isProcessed(model)) {
|
||||
|
@ -429,7 +495,7 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
|
|||
//If executions are alternative, get the actual execution to show based on user preference
|
||||
List<AuthenticationSelectionOption> selectionOptions = createAuthenticationSelectionList(model);
|
||||
if (!selectionOptions.isEmpty() && calledFromFlow) {
|
||||
List<AuthenticationSelectionOption> finalSelectionOptions = selectionOptions.stream().filter(aso -> !aso.getAuthenticationExecution().isAuthenticatorFlow() && !isProcessed(aso.getAuthenticationExecution())).collect(Collectors.toList());;
|
||||
List<AuthenticationSelectionOption> finalSelectionOptions = selectionOptions.stream().filter(aso -> !aso.getAuthenticationExecution().isAuthenticatorFlow() && !isProcessed(aso.getAuthenticationExecution())).collect(Collectors.toList());
|
||||
if (finalSelectionOptions.isEmpty()) {
|
||||
//move to next
|
||||
return null;
|
||||
|
@ -443,11 +509,7 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
|
|||
}
|
||||
AuthenticationProcessor.Result context = processor.createAuthenticatorContext(model, authenticator, executions);
|
||||
context.setAuthenticationSelections(selectionOptions);
|
||||
if (selectedCredentialId != null) {
|
||||
context.setSelectedCredentialId(selectedCredentialId);
|
||||
} else if (!selectionOptions.isEmpty()) {
|
||||
context.setSelectedCredentialId(selectionOptions.get(0).getCredentialId());
|
||||
}
|
||||
|
||||
if (authenticator.requiresUser()) {
|
||||
if (authUser == null) {
|
||||
throw new AuthenticationFlowException("authenticator: " + factory.getId(), AuthenticationFlowError.UNKNOWN_USER);
|
||||
|
@ -481,72 +543,7 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
|
|||
* @return an ordered list of the authentication selection options to present the user.
|
||||
*/
|
||||
private List<AuthenticationSelectionOption> createAuthenticationSelectionList(AuthenticationExecutionModel model) {
|
||||
List<AuthenticationSelectionOption> authenticationSelectionList = new ArrayList<>();
|
||||
if (processor.getAuthenticationSession() != null) {
|
||||
Map<String, AuthenticationExecutionModel> typeAuthExecMap = new HashMap<>();
|
||||
List<AuthenticationExecutionModel> nonCredentialExecutions = new ArrayList<>();
|
||||
if (model.isAlternative()) {
|
||||
//get all alternative executions to be able to list their credentials
|
||||
List<AuthenticationExecutionModel> alternativeExecutions = processor.getRealm().getAuthenticationExecutions(model.getParentFlow())
|
||||
.stream().filter(AuthenticationExecutionModel::isAlternative).collect(Collectors.toList());
|
||||
for (AuthenticationExecutionModel execution : alternativeExecutions) {
|
||||
if (!execution.isAuthenticatorFlow()) {
|
||||
Authenticator localAuthenticator = processor.getSession().getProvider(Authenticator.class, execution.getAuthenticator());
|
||||
if (!(localAuthenticator instanceof CredentialValidator)) {
|
||||
nonCredentialExecutions.add(execution);
|
||||
continue;
|
||||
}
|
||||
CredentialValidator<?> cv = (CredentialValidator<?>) localAuthenticator;
|
||||
typeAuthExecMap.put(cv.getType(processor.getSession()), execution);
|
||||
} else {
|
||||
nonCredentialExecutions.add(execution);
|
||||
}
|
||||
}
|
||||
} else if (model.isRequired() && !model.isAuthenticatorFlow()) {
|
||||
//only get current credentials
|
||||
Authenticator authenticator = processor.getSession().getProvider(Authenticator.class, model.getAuthenticator());
|
||||
if (authenticator instanceof CredentialValidator) {
|
||||
typeAuthExecMap.put(((CredentialValidator<?>) authenticator).getType(processor.getSession()), model);
|
||||
}
|
||||
}
|
||||
//add credential authenticators in order
|
||||
if (processor.getAuthenticationSession().getAuthenticatedUser() != null) {
|
||||
List<CredentialModel> credentials = processor.getSession().userCredentialManager()
|
||||
.getStoredCredentials(processor.getRealm(), processor.getAuthenticationSession().getAuthenticatedUser())
|
||||
.stream()
|
||||
.filter(credential -> typeAuthExecMap.containsKey(credential.getType()))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
MultivaluedMap<String, AuthenticationSelectionOption> countAuthSelections = new MultivaluedHashMap<>();
|
||||
|
||||
for (CredentialModel credential : credentials) {
|
||||
AuthenticationSelectionOption authSel = new AuthenticationSelectionOption(typeAuthExecMap.get(credential.getType()), credential);
|
||||
authenticationSelectionList.add(authSel);
|
||||
countAuthSelections.add(credential.getType(), authSel);
|
||||
}
|
||||
for (Entry<String, List<AuthenticationSelectionOption>> entry : countAuthSelections.entrySet()) {
|
||||
if (entry.getValue().size() == 1) {
|
||||
entry.getValue().get(0).setShowCredentialName(false);
|
||||
}
|
||||
}
|
||||
//don't show credential type if there's only a single type in the list
|
||||
if (countAuthSelections.keySet().size() == 1 && nonCredentialExecutions.isEmpty()) {
|
||||
for (AuthenticationSelectionOption so : authenticationSelectionList) {
|
||||
so.setShowCredentialType(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
//add all other authenticators (including flows)
|
||||
for (AuthenticationExecutionModel exec : nonCredentialExecutions) {
|
||||
if (exec.isAuthenticatorFlow()) {
|
||||
authenticationSelectionList.add(new AuthenticationSelectionOption(exec,
|
||||
processor.getRealm().getAuthenticationFlowById(exec.getFlowId())));
|
||||
} else {
|
||||
authenticationSelectionList.add(new AuthenticationSelectionOption(exec));
|
||||
}
|
||||
}
|
||||
}
|
||||
return authenticationSelectionList;
|
||||
return AuthenticationSelectionResolver.createAuthenticationSelectionList(processor, model);
|
||||
}
|
||||
|
||||
|
||||
|
@ -585,9 +582,6 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
|
|||
return sendChallenge(result, execution);
|
||||
case ATTEMPTED:
|
||||
logger.debugv("authenticator ATTEMPTED: {0}", execution.getAuthenticator());
|
||||
if (execution.isRequired()) {
|
||||
throw new AuthenticationFlowException(AuthenticationFlowError.INVALID_CREDENTIALS);
|
||||
}
|
||||
processor.getAuthenticationSession().setExecutionStatus(execution.getId(), AuthenticationSessionModel.ExecutionStatus.ATTEMPTED);
|
||||
return null;
|
||||
case FLOW_RESET:
|
||||
|
|
|
@ -26,6 +26,7 @@ import org.keycloak.authentication.RequiredActionProvider;
|
|||
import org.keycloak.authentication.requiredactions.UpdateTotp;
|
||||
import org.keycloak.credential.CredentialProvider;
|
||||
import org.keycloak.credential.OTPCredentialProvider;
|
||||
import org.keycloak.credential.OTPCredentialProviderFactory;
|
||||
import org.keycloak.events.Errors;
|
||||
import org.keycloak.forms.login.LoginFormsProvider;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
|
@ -44,6 +45,14 @@ import java.util.List;
|
|||
* @version $Revision: 1 $
|
||||
*/
|
||||
public class OTPFormAuthenticator extends AbstractUsernameFormAuthenticator implements Authenticator, CredentialValidator<OTPCredentialProvider> {
|
||||
|
||||
// Freemarker attribute where selected OTP credential will be stored
|
||||
public static final String SELECTED_OTP_CREDENTIAL_ID = "selectedOtpCredentialId";
|
||||
|
||||
// Label to be shown in the UI for the "unnamed" OTP credential, which doesn't have userLabel
|
||||
public static final String UNNAMED = "unnamed";
|
||||
|
||||
|
||||
@Override
|
||||
public void action(AuthenticationFlowContext context) {
|
||||
validateOTP(context);
|
||||
|
@ -61,14 +70,14 @@ public class OTPFormAuthenticator extends AbstractUsernameFormAuthenticator impl
|
|||
MultivaluedMap<String, String> inputData = context.getHttpRequest().getDecodedFormParameters();
|
||||
|
||||
String otp = inputData.getFirst("otp");
|
||||
String credentialId = context.getSelectedCredentialId();
|
||||
|
||||
//TODO this is lazy for when there is no clearly defined credentialId available (for example direct grant or console OTP), replace with getting the credential from the name
|
||||
String credentialId = inputData.getFirst("selectedCredentialId");
|
||||
|
||||
if (credentialId == null || credentialId.isEmpty()) {
|
||||
credentialId = getCredentialProvider(context.getSession())
|
||||
.getDefaultCredential(context.getSession(), context.getRealm(), context.getUser()).getId();
|
||||
context.setSelectedCredentialId(credentialId);
|
||||
}
|
||||
context.form().setAttribute(SELECTED_OTP_CREDENTIAL_ID, credentialId);
|
||||
|
||||
UserModel userModel = context.getUser();
|
||||
if (!enabledUser(context, userModel)) {
|
||||
|
@ -131,7 +140,7 @@ public class OTPFormAuthenticator extends AbstractUsernameFormAuthenticator impl
|
|||
|
||||
@Override
|
||||
public OTPCredentialProvider getCredentialProvider(KeycloakSession session) {
|
||||
return (OTPCredentialProvider)session.getProvider(CredentialProvider.class, "keycloak-otp");
|
||||
return (OTPCredentialProvider)session.getProvider(CredentialProvider.class, OTPCredentialProviderFactory.PROVIDER_ID);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -18,6 +18,9 @@
|
|||
package org.keycloak.authentication.authenticators.browser;
|
||||
|
||||
import org.keycloak.authentication.AuthenticationFlowContext;
|
||||
import org.keycloak.authentication.CredentialValidator;
|
||||
import org.keycloak.credential.CredentialProvider;
|
||||
import org.keycloak.credential.PasswordCredentialProvider;
|
||||
import org.keycloak.forms.login.LoginFormsProvider;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.RealmModel;
|
||||
|
@ -27,7 +30,7 @@ import org.keycloak.services.messages.Messages;
|
|||
import javax.ws.rs.core.MultivaluedMap;
|
||||
import javax.ws.rs.core.Response;
|
||||
|
||||
public class PasswordForm extends UsernamePasswordForm {
|
||||
public class PasswordForm extends UsernamePasswordForm implements CredentialValidator<PasswordCredentialProvider> {
|
||||
|
||||
protected boolean validateForm(AuthenticationFlowContext context, MultivaluedMap<String, String> formData) {
|
||||
return validatePassword(context, context.getUser(), formData, false);
|
||||
|
@ -59,4 +62,9 @@ public class PasswordForm extends UsernamePasswordForm {
|
|||
protected String getDefaultChallengeMessage(AuthenticationFlowContext context) {
|
||||
return Messages.INVALID_PASSWORD;
|
||||
}
|
||||
|
||||
@Override
|
||||
public PasswordCredentialProvider getCredentialProvider(KeycloakSession session) {
|
||||
return (PasswordCredentialProvider)session.getProvider(CredentialProvider.class, "keycloak-password");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,9 +20,6 @@ package org.keycloak.authentication.authenticators.browser;
|
|||
import org.jboss.resteasy.specimpl.MultivaluedMapImpl;
|
||||
import org.keycloak.authentication.AuthenticationFlowContext;
|
||||
import org.keycloak.authentication.Authenticator;
|
||||
import org.keycloak.authentication.CredentialValidator;
|
||||
import org.keycloak.credential.CredentialProvider;
|
||||
import org.keycloak.credential.PasswordCredentialProvider;
|
||||
import org.keycloak.forms.login.LoginFormsProvider;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.RealmModel;
|
||||
|
@ -30,7 +27,6 @@ import org.keycloak.models.UserModel;
|
|||
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
|
||||
import org.keycloak.services.ServicesLogger;
|
||||
import org.keycloak.services.managers.AuthenticationManager;
|
||||
import org.keycloak.services.messages.Messages;
|
||||
|
||||
import javax.ws.rs.core.MultivaluedMap;
|
||||
import javax.ws.rs.core.Response;
|
||||
|
@ -39,7 +35,7 @@ import javax.ws.rs.core.Response;
|
|||
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
|
||||
* @version $Revision: 1 $
|
||||
*/
|
||||
public class UsernamePasswordForm extends AbstractUsernameFormAuthenticator implements Authenticator, CredentialValidator<PasswordCredentialProvider> {
|
||||
public class UsernamePasswordForm extends AbstractUsernameFormAuthenticator implements Authenticator {
|
||||
protected static ServicesLogger log = ServicesLogger.LOGGER;
|
||||
|
||||
@Override
|
||||
|
@ -108,13 +104,4 @@ public class UsernamePasswordForm extends AbstractUsernameFormAuthenticator impl
|
|||
|
||||
}
|
||||
|
||||
@Override
|
||||
public PasswordCredentialProvider getCredentialProvider(KeycloakSession session) {
|
||||
return (PasswordCredentialProvider)session.getProvider(CredentialProvider.class, "keycloak-password");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getDefaultChallengeMessage(AuthenticationFlowContext context){
|
||||
return Messages.INVALID_USER;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -28,12 +28,17 @@ import org.keycloak.WebAuthnConstants;
|
|||
import org.keycloak.authentication.AuthenticationFlowContext;
|
||||
import org.keycloak.authentication.AuthenticationFlowError;
|
||||
import org.keycloak.authentication.Authenticator;
|
||||
import org.keycloak.authentication.CredentialValidator;
|
||||
import org.keycloak.authentication.RequiredActionFactory;
|
||||
import org.keycloak.authentication.RequiredActionProvider;
|
||||
import org.keycloak.authentication.requiredactions.WebAuthnRegisterFactory;
|
||||
import org.keycloak.common.util.Base64Url;
|
||||
import org.keycloak.common.util.UriUtils;
|
||||
import org.keycloak.credential.CredentialProvider;
|
||||
import org.keycloak.credential.OTPCredentialProvider;
|
||||
import org.keycloak.credential.WebAuthnCredentialModelInput;
|
||||
import org.keycloak.credential.WebAuthnCredentialProvider;
|
||||
import org.keycloak.credential.WebAuthnCredentialProviderFactory;
|
||||
import org.keycloak.events.Errors;
|
||||
import org.keycloak.forms.login.LoginFormsProvider;
|
||||
import org.keycloak.forms.login.freemarker.model.WebAuthnAuthenticatorsBean;
|
||||
|
@ -47,7 +52,7 @@ import javax.ws.rs.core.Response;
|
|||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
public class WebAuthnAuthenticator implements Authenticator {
|
||||
public class WebAuthnAuthenticator implements Authenticator, CredentialValidator<WebAuthnCredentialProvider> {
|
||||
|
||||
private static final Logger logger = Logger.getLogger(WebAuthnAuthenticator.class);
|
||||
private KeycloakSession session;
|
||||
|
@ -204,6 +209,11 @@ public class WebAuthnAuthenticator implements Authenticator {
|
|||
// NOP
|
||||
}
|
||||
|
||||
@Override
|
||||
public WebAuthnCredentialProvider getCredentialProvider(KeycloakSession session) {
|
||||
return (WebAuthnCredentialProvider)session.getProvider(CredentialProvider.class, WebAuthnCredentialProviderFactory.PROVIDER_ID);
|
||||
}
|
||||
|
||||
private static final String ERR_LABEL = "web_authn_authentication_error";
|
||||
private static final String ERR_DETAIL_LABEL = "web_authn_authentication_error_detail";
|
||||
private static final String ERR_NO_AUTHENTICATORS_REGISTERED = "No WebAuthn Authenticator registered.";
|
||||
|
|
|
@ -59,12 +59,11 @@ public class ValidateOTP extends AbstractDirectGrantAuthenticator implements Cre
|
|||
MultivaluedMap<String, String> inputData = context.getHttpRequest().getDecodedFormParameters();
|
||||
|
||||
String otp = inputData.getFirst("otp");
|
||||
String credentialId = context.getSelectedCredentialId();
|
||||
if (credentialId == null || credentialId.isEmpty()) {
|
||||
credentialId = getCredentialProvider(context.getSession())
|
||||
|
||||
// Always use default OTP credential in case of direct grant authentication
|
||||
String credentialId = getCredentialProvider(context.getSession())
|
||||
.getDefaultCredential(context.getSession(), context.getRealm(), context.getUser()).getId();
|
||||
context.setSelectedCredentialId(credentialId);
|
||||
}
|
||||
|
||||
if (otp == null) {
|
||||
if (context.getUser() != null) {
|
||||
context.getEvent().user(context.getUser());
|
||||
|
|
|
@ -19,6 +19,7 @@ package org.keycloak.forms.login.freemarker;
|
|||
import org.jboss.logging.Logger;
|
||||
import org.keycloak.OAuth2Constants;
|
||||
import org.keycloak.authentication.AuthenticationFlowContext;
|
||||
import org.keycloak.authentication.authenticators.browser.OTPFormAuthenticator;
|
||||
import org.keycloak.authentication.requiredactions.util.UpdateProfileContext;
|
||||
import org.keycloak.authentication.requiredactions.util.UserUpdateProfileContext;
|
||||
import org.keycloak.broker.provider.BrokeredIdentityContext;
|
||||
|
@ -37,6 +38,7 @@ import org.keycloak.forms.login.freemarker.model.RegisterBean;
|
|||
import org.keycloak.forms.login.freemarker.model.RequiredActionUrlFormatterMethod;
|
||||
import org.keycloak.forms.login.freemarker.model.SAMLPostFormBean;
|
||||
import org.keycloak.forms.login.freemarker.model.TotpBean;
|
||||
import org.keycloak.forms.login.freemarker.model.TotpLoginBean;
|
||||
import org.keycloak.forms.login.freemarker.model.UrlBean;
|
||||
import org.keycloak.forms.login.freemarker.model.X509ConfirmBean;
|
||||
import org.keycloak.models.ClientModel;
|
||||
|
@ -214,6 +216,9 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider {
|
|||
attributes.put("brokerContext", brokerContext);
|
||||
attributes.put("idpAlias", idpAlias);
|
||||
break;
|
||||
case LOGIN_TOTP:
|
||||
attributes.put("otpLogin", new TotpLoginBean(session, realm, user, (String) this.attributes.get(OTPFormAuthenticator.SELECTED_OTP_CREDENTIAL_ID)));
|
||||
break;
|
||||
case REGISTER:
|
||||
attributes.put("register", new RegisterBean(formData));
|
||||
break;
|
||||
|
@ -404,7 +409,7 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider {
|
|||
|
||||
attributes.put("url", new UrlBean(realm, theme, baseUri, this.actionUri));
|
||||
attributes.put("requiredActionUrl", new RequiredActionUrlFormatterMethod(realm, baseUri));
|
||||
attributes.put("auth", new AuthenticationContextBean(context, actionUri));
|
||||
attributes.put("auth", new AuthenticationContextBean(context, actionUri, page));
|
||||
attributes.put(Constants.EXECUTION, execution);
|
||||
|
||||
if (realm.isInternationalizationEnabled()) {
|
||||
|
@ -547,6 +552,11 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider {
|
|||
return createResponse(LoginFormsPages.OAUTH_GRANT);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Response createSelectAuthenticator() {
|
||||
return createResponse(LoginFormsPages.LOGIN_SELECT_AUTHENTICATOR);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Response createCode() {
|
||||
return createResponse(LoginFormsPages.CODE);
|
||||
|
|
|
@ -50,6 +50,8 @@ public class Templates {
|
|||
return "login-reset-password.ftl";
|
||||
case LOGIN_UPDATE_PASSWORD:
|
||||
return "login-update-password.ftl";
|
||||
case LOGIN_SELECT_AUTHENTICATOR:
|
||||
return "select-authenticator.ftl";
|
||||
case REGISTER:
|
||||
return "register.ftl";
|
||||
case INFO:
|
||||
|
|
|
@ -24,6 +24,7 @@ import java.util.List;
|
|||
|
||||
import org.keycloak.authentication.AuthenticationFlowContext;
|
||||
import org.keycloak.authentication.AuthenticationSelectionOption;
|
||||
import org.keycloak.forms.login.LoginFormsPages;
|
||||
import org.keycloak.services.util.AuthenticationFlowHistoryHelper;
|
||||
|
||||
/**
|
||||
|
@ -33,10 +34,12 @@ public class AuthenticationContextBean {
|
|||
|
||||
private final AuthenticationFlowContext context;
|
||||
private final URI actionUri;
|
||||
private final LoginFormsPages page;
|
||||
|
||||
public AuthenticationContextBean(AuthenticationFlowContext context, URI actionUri) {
|
||||
public AuthenticationContextBean(AuthenticationFlowContext context, URI actionUri, LoginFormsPages page) {
|
||||
this.context = context;
|
||||
this.actionUri = actionUri;
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
|
||||
|
@ -44,10 +47,6 @@ public class AuthenticationContextBean {
|
|||
return context==null ? Collections.emptyList() : context.getAuthenticationSelections();
|
||||
}
|
||||
|
||||
public String getSelectedCredential() {
|
||||
return context==null ? null : context.getSelectedCredentialId();
|
||||
}
|
||||
|
||||
public boolean showBackButton() {
|
||||
if (context == null) {
|
||||
return false;
|
||||
|
@ -55,4 +54,9 @@ public class AuthenticationContextBean {
|
|||
|
||||
return actionUri != null && new AuthenticationFlowHistoryHelper(context.getAuthenticationSession(), context.getFlowPath()).hasAnyExecution();
|
||||
}
|
||||
|
||||
|
||||
public boolean showTryAnotherWayLink() {
|
||||
return getAuthenticationSelections().size() > 1 && page != LoginFormsPages.LOGIN_SELECT_AUTHENTICATOR;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -27,6 +27,8 @@ import org.keycloak.utils.TotpUtils;
|
|||
import javax.ws.rs.core.UriBuilder;
|
||||
|
||||
/**
|
||||
* Used for UpdateTotp required action
|
||||
*
|
||||
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
|
||||
*/
|
||||
public class TotpBean {
|
||||
|
|
|
@ -0,0 +1,93 @@
|
|||
/*
|
||||
* Copyright 2019 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.model;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.keycloak.authentication.authenticators.browser.OTPFormAuthenticator;
|
||||
import org.keycloak.credential.CredentialModel;
|
||||
import org.keycloak.credential.CredentialProvider;
|
||||
import org.keycloak.credential.OTPCredentialProvider;
|
||||
import org.keycloak.credential.OTPCredentialProviderFactory;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.UserModel;
|
||||
import org.keycloak.models.credential.OTPCredentialModel;
|
||||
|
||||
/**
|
||||
* Used for TOTP login
|
||||
*
|
||||
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
|
||||
*/
|
||||
public class TotpLoginBean {
|
||||
|
||||
private final String selectedCredentialId;
|
||||
private final List<OTPCredential> userOtpCredentials;
|
||||
|
||||
public TotpLoginBean(KeycloakSession session, RealmModel realm, UserModel user, String selectedCredentialId) {
|
||||
List<CredentialModel> userOtpCredentials = session.userCredentialManager()
|
||||
.getStoredCredentialsByType(realm, user, OTPCredentialModel.TYPE);
|
||||
|
||||
this.userOtpCredentials = userOtpCredentials.stream()
|
||||
.map(OTPCredential::new)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// This means user did not yet manually selected any OTP credential through the UI. So just go with the default one with biggest priority
|
||||
if (selectedCredentialId == null || selectedCredentialId.isEmpty()) {
|
||||
OTPCredentialProvider otpCredentialProvider = (OTPCredentialProvider)session.getProvider(CredentialProvider.class, OTPCredentialProviderFactory.PROVIDER_ID);
|
||||
OTPCredentialModel otpCredential = otpCredentialProvider
|
||||
.getDefaultCredential(session, realm, user);
|
||||
|
||||
selectedCredentialId = otpCredential==null ? null : otpCredential.getId();
|
||||
}
|
||||
|
||||
this.selectedCredentialId = selectedCredentialId;
|
||||
}
|
||||
|
||||
|
||||
public List<OTPCredential> getUserOtpCredentials() {
|
||||
return userOtpCredentials;
|
||||
}
|
||||
|
||||
public String getSelectedCredentialId() {
|
||||
return selectedCredentialId;
|
||||
}
|
||||
|
||||
|
||||
public static class OTPCredential {
|
||||
|
||||
private final String id;
|
||||
private final String userLabel;
|
||||
|
||||
public OTPCredential(CredentialModel credentialModel) {
|
||||
this.id = credentialModel.getId();
|
||||
// TODO: "Unnamed" OTP credentials should be displayed in the UI in gray
|
||||
this.userLabel = credentialModel.getUserLabel() == null || credentialModel.getUserLabel().isEmpty() ? OTPFormAuthenticator.UNNAMED : credentialModel.getUserLabel();
|
||||
}
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getUserLabel() {
|
||||
return userLabel;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -32,9 +32,6 @@ public class OneTimeCode extends Authenticate {
|
|||
@FindBy(id = "otp")
|
||||
private WebElement otpInputField;
|
||||
|
||||
@FindBy(id = "authenticators-choice")
|
||||
private WebElement authenticatorSelector;
|
||||
|
||||
@FindBy(xpath = ".//label[@for='otp']")
|
||||
private WebElement otpInputLabel;
|
||||
|
||||
|
@ -62,11 +59,6 @@ public class OneTimeCode extends Authenticate {
|
|||
return loginErrorMessage != null ? loginErrorMessage.getText() : null;
|
||||
}
|
||||
|
||||
public void selectFactor(String name) {
|
||||
Select select = new Select(authenticatorSelector);
|
||||
select.selectByVisibleText(name);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isCurrent() {
|
||||
return URLUtils.currentUrlStartsWith(toString() + "?") && isOtpLabelPresent();
|
||||
|
|
|
@ -1,62 +0,0 @@
|
|||
package org.keycloak.testsuite.pages;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.junit.Assert;
|
||||
import org.openqa.selenium.By;
|
||||
import org.openqa.selenium.NoSuchElementException;
|
||||
import org.openqa.selenium.WebElement;
|
||||
import org.openqa.selenium.support.FindBy;
|
||||
import org.openqa.selenium.support.ui.Select;
|
||||
|
||||
/**
|
||||
* Login page with the list of credentials, which are available to the user (Password, OTP, WebAuthn...)
|
||||
*
|
||||
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
|
||||
*/
|
||||
public abstract class CredentialsComboboxPage extends LanguageComboboxAwarePage {
|
||||
|
||||
@FindBy(id = "authenticators-choice")
|
||||
private WebElement credentialsCombobox;
|
||||
|
||||
|
||||
// If false, we don't expect that credentials combobox is available. If true, we expect that it is available on the page
|
||||
public void assertCredentialsComboboxAvailability(boolean expectedAvailability) {
|
||||
try {
|
||||
driver.findElement(By.id("authenticators-choice"));
|
||||
Assert.assertTrue(expectedAvailability);
|
||||
} catch (NoSuchElementException nse) {
|
||||
Assert.assertFalse(expectedAvailability);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public List<String> getAvailableCredentials() {
|
||||
return new Select(credentialsCombobox).getOptions()
|
||||
.stream()
|
||||
.map(WebElement::getText)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
|
||||
public String getSelectedCredential() {
|
||||
return new Select(credentialsCombobox).getOptions()
|
||||
.stream()
|
||||
.filter(webElement -> webElement.getAttribute("selected") != null)
|
||||
.findFirst()
|
||||
.orElseThrow(() -> {
|
||||
|
||||
return new AssertionError("Selected credential not found");
|
||||
|
||||
})
|
||||
.getText();
|
||||
}
|
||||
|
||||
|
||||
public void selectCredential(String credentialName) {
|
||||
new Select(credentialsCombobox).selectByVisibleText(credentialName);
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -26,6 +26,8 @@ import org.openqa.selenium.WebElement;
|
|||
import org.openqa.selenium.support.FindBy;
|
||||
|
||||
/**
|
||||
* Provides some generic utils available on most of login pages (Language combobox, Link "Try another way" etc)
|
||||
*
|
||||
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
|
||||
*/
|
||||
public abstract class LanguageComboboxAwarePage extends AbstractPage {
|
||||
|
@ -39,6 +41,9 @@ public abstract class LanguageComboboxAwarePage extends AbstractPage {
|
|||
@FindBy(id = "kc-back")
|
||||
private WebElement backButton;
|
||||
|
||||
@FindBy(id = "try-another-way")
|
||||
private WebElement tryAnotherWayLink;
|
||||
|
||||
public String getLanguageDropdownText() {
|
||||
return languageText.getText();
|
||||
}
|
||||
|
@ -65,4 +70,19 @@ public abstract class LanguageComboboxAwarePage extends AbstractPage {
|
|||
public void clickBackButton() {
|
||||
backButton.click();
|
||||
}
|
||||
|
||||
|
||||
// If false, we don't expect form "Try another way" link available on the page. If true, we expect that it is available on the page
|
||||
public void assertTryAnotherWayLinkAvailability(boolean expectedAvailability) {
|
||||
try {
|
||||
driver.findElement(By.id("try-another-way"));
|
||||
Assert.assertTrue(expectedAvailability);
|
||||
} catch (NoSuchElementException nse) {
|
||||
Assert.assertFalse(expectedAvailability);
|
||||
}
|
||||
}
|
||||
|
||||
public void clickTryAnotherWayLink() {
|
||||
tryAnotherWayLink.click();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,14 +16,20 @@
|
|||
*/
|
||||
package org.keycloak.testsuite.pages;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.junit.Assert;
|
||||
import org.openqa.selenium.By;
|
||||
import org.openqa.selenium.NoSuchElementException;
|
||||
import org.openqa.selenium.WebElement;
|
||||
import org.openqa.selenium.support.FindBy;
|
||||
import org.openqa.selenium.support.ui.Select;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
|
||||
*/
|
||||
public class LoginTotpPage extends CredentialsComboboxPage {
|
||||
public class LoginTotpPage extends LanguageComboboxAwarePage {
|
||||
|
||||
@FindBy(id = "otp")
|
||||
private WebElement otpInput;
|
||||
|
@ -37,6 +43,9 @@ public class LoginTotpPage extends CredentialsComboboxPage {
|
|||
@FindBy(className = "alert-error")
|
||||
private WebElement loginErrorMessage;
|
||||
|
||||
@FindBy(id = "selected-credential-id")
|
||||
private WebElement selectedCredentialCombobox;
|
||||
|
||||
public void login(String totp) {
|
||||
otpInput.clear();
|
||||
if (totp != null) otpInput.sendKeys(totp);
|
||||
|
@ -64,4 +73,42 @@ public class LoginTotpPage extends CredentialsComboboxPage {
|
|||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
|
||||
// If false, we don't expect that credentials combobox is available. If true, we expect that it is available on the page
|
||||
public void assertOtpCredentialSelectorAvailability(boolean expectedAvailability) {
|
||||
try {
|
||||
driver.findElement(By.id("selected-credential-id"));
|
||||
Assert.assertTrue(expectedAvailability);
|
||||
} catch (NoSuchElementException nse) {
|
||||
Assert.assertFalse(expectedAvailability);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public List<String> getAvailableOtpCredentials() {
|
||||
return new Select(selectedCredentialCombobox).getOptions()
|
||||
.stream()
|
||||
.map(WebElement::getText)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
|
||||
public String getSelectedOtpCredential() {
|
||||
return new Select(selectedCredentialCombobox).getOptions()
|
||||
.stream()
|
||||
.filter(webElement -> webElement.getAttribute("selected") != null)
|
||||
.findFirst()
|
||||
.orElseThrow(() -> {
|
||||
|
||||
return new AssertionError("Selected OTP credential not found");
|
||||
|
||||
})
|
||||
.getText();
|
||||
}
|
||||
|
||||
|
||||
public void selectOtpCredential(String credentialName) {
|
||||
new Select(selectedCredentialCombobox).selectByVisibleText(credentialName);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -13,7 +13,7 @@ import org.openqa.selenium.support.FindBy;
|
|||
*
|
||||
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
|
||||
*/
|
||||
public class PasswordPage extends CredentialsComboboxPage {
|
||||
public class PasswordPage extends LanguageComboboxAwarePage {
|
||||
|
||||
@ArquillianResource
|
||||
protected OAuthClient oauth;
|
||||
|
|
|
@ -0,0 +1,76 @@
|
|||
package org.keycloak.testsuite.pages;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.junit.Assert;
|
||||
import org.keycloak.testsuite.util.DroneUtils;
|
||||
import org.openqa.selenium.By;
|
||||
import org.openqa.selenium.NoSuchElementException;
|
||||
import org.openqa.selenium.WebElement;
|
||||
import org.openqa.selenium.support.FindBy;
|
||||
import org.openqa.selenium.support.ui.Select;
|
||||
|
||||
/**
|
||||
* Login page with the list of authentication mechanisms, which are available to the user (Password, OTP, WebAuthn...)
|
||||
*
|
||||
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
|
||||
*/
|
||||
public class SelectAuthenticatorPage extends LanguageComboboxAwarePage {
|
||||
|
||||
@FindBy(id = "authenticators-choice")
|
||||
private WebElement authenticatorsSelect;
|
||||
|
||||
|
||||
public List<String> getAvailableLoginMethods() {
|
||||
return new Select(authenticatorsSelect).getOptions()
|
||||
.stream()
|
||||
.map(WebElement::getText)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
|
||||
public String getSelectedLoginMethod() {
|
||||
return new Select(authenticatorsSelect).getOptions()
|
||||
.stream()
|
||||
.filter(webElement -> webElement.getAttribute("selected") != null)
|
||||
.findFirst()
|
||||
.orElseThrow(() -> {
|
||||
|
||||
return new AssertionError("Selected login method not found");
|
||||
|
||||
})
|
||||
.getText();
|
||||
}
|
||||
|
||||
|
||||
public void selectLoginMethod(String loginMethod) {
|
||||
new Select(authenticatorsSelect).selectByVisibleText(loginMethod);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public boolean isCurrent() {
|
||||
// Check the title
|
||||
if (!DroneUtils.getCurrentDriver().getTitle().startsWith("Log in to ") && !DroneUtils.getCurrentDriver().getTitle().startsWith("Anmeldung bei ")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check the authenticators-choice available
|
||||
try {
|
||||
driver.findElement(By.id("authenticators-choice"));
|
||||
} catch (NoSuchElementException nfe) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void open() throws Exception {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -10,6 +10,7 @@ import org.keycloak.representations.idm.UserRepresentation;
|
|||
import org.keycloak.testsuite.Assert;
|
||||
import org.keycloak.testsuite.admin.ApiUtil;
|
||||
import org.keycloak.testsuite.pages.PasswordPage;
|
||||
import org.keycloak.testsuite.pages.SelectAuthenticatorPage;
|
||||
import org.keycloak.testsuite.util.UserBuilder;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
@ -29,6 +30,9 @@ public class KcOidcFirstBrokerLoginNewAuthTest extends AbstractInitializedBaseBr
|
|||
@Page
|
||||
PasswordPage passwordPage;
|
||||
|
||||
@Page
|
||||
protected SelectAuthenticatorPage selectAuthenticatorPage;
|
||||
|
||||
@Override
|
||||
protected BrokerConfiguration getBrokerConfiguration() {
|
||||
return KcOidcBrokerConfiguration.INSTANCE;
|
||||
|
@ -104,7 +108,7 @@ public class KcOidcFirstBrokerLoginNewAuthTest extends AbstractInitializedBaseBr
|
|||
|
||||
// Assert that user can't see credentials combobox. Password is the only available credentials.
|
||||
Assert.assertTrue(passwordPage.isCurrent("consumer"));
|
||||
passwordPage.assertCredentialsComboboxAvailability(false);
|
||||
passwordPage.assertTryAnotherWayLinkAvailability(false);
|
||||
|
||||
// Login with password
|
||||
Assert.assertTrue(passwordPage.isCurrent("consumer"));
|
||||
|
@ -128,11 +132,19 @@ public class KcOidcFirstBrokerLoginNewAuthTest extends AbstractInitializedBaseBr
|
|||
|
||||
loginWithBrokerAndConfirmLinkAccount();
|
||||
|
||||
// Assert that user can see credentials combobox. Password and OTP are available credentials. Password should be selected.
|
||||
// Assert that user can choose between Password and OTP as available credentials. Password should be selected by default.
|
||||
Assert.assertTrue(passwordPage.isCurrent("consumer"));
|
||||
passwordPage.assertCredentialsComboboxAvailability(true);
|
||||
Assert.assertNames(passwordPage.getAvailableCredentials(), "Password", "OTP");
|
||||
Assert.assertEquals("Password", passwordPage.getSelectedCredential());
|
||||
passwordPage.assertTryAnotherWayLinkAvailability(true);
|
||||
|
||||
// Just click "Try another way" to verify that both Password and OTP are available. But go back to Password then
|
||||
passwordPage.clickTryAnotherWayLink();
|
||||
selectAuthenticatorPage.assertCurrent();
|
||||
Assert.assertNames(selectAuthenticatorPage.getAvailableLoginMethods(), "Password", "OTP");
|
||||
|
||||
// TODO: This is limitation of select, that it can't select the already present value. Should be improved when we change to select cart
|
||||
selectAuthenticatorPage.selectLoginMethod("OTP");
|
||||
loginTotpPage.clickTryAnotherWayLink();
|
||||
selectAuthenticatorPage.selectLoginMethod("Password");
|
||||
|
||||
// Login with password
|
||||
Assert.assertTrue(passwordPage.isCurrent("consumer"));
|
||||
|
@ -141,6 +153,7 @@ public class KcOidcFirstBrokerLoginNewAuthTest extends AbstractInitializedBaseBr
|
|||
assertUserAuthenticatedInConsumer(consumerRealmUserId);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Tests the firstBrokerLogin flow configured to re-authenticate with PasswordForm OR TOTP.
|
||||
* TOTP is configured for the user and he selects it to authenticate. Password is not used.
|
||||
|
@ -157,12 +170,14 @@ public class KcOidcFirstBrokerLoginNewAuthTest extends AbstractInitializedBaseBr
|
|||
|
||||
// Assert that user can see credentials combobox. Password and OTP are available credentials. Password should be selected.
|
||||
Assert.assertTrue(passwordPage.isCurrent("consumer"));
|
||||
passwordPage.assertCredentialsComboboxAvailability(true);
|
||||
passwordPage.assertTryAnotherWayLinkAvailability(true);
|
||||
|
||||
// Click "Try another way", Select OTP and assert OTP form present
|
||||
passwordPage.clickTryAnotherWayLink();
|
||||
selectAuthenticatorPage.assertCurrent();
|
||||
selectAuthenticatorPage.selectLoginMethod("OTP");
|
||||
|
||||
// Select OTP and assert
|
||||
passwordPage.selectCredential("OTP");
|
||||
loginTotpPage.assertCurrent();
|
||||
Assert.assertEquals("OTP", loginTotpPage.getSelectedCredential());
|
||||
|
||||
// Login with OTP now
|
||||
loginTotpPage.login(totp.generateTOTP(totpSecret));
|
||||
|
|
|
@ -6,7 +6,9 @@ import org.jboss.arquillian.test.api.ArquillianResource;
|
|||
import org.junit.Assert;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.keycloak.admin.client.resource.RealmResource;
|
||||
import org.keycloak.authentication.AuthenticationFlow;
|
||||
import org.keycloak.authentication.authenticators.browser.OTPFormAuthenticator;
|
||||
import org.keycloak.authentication.authenticators.browser.OTPFormAuthenticatorFactory;
|
||||
import org.keycloak.authentication.authenticators.browser.PasswordFormFactory;
|
||||
import org.keycloak.authentication.authenticators.browser.UsernameFormFactory;
|
||||
|
@ -45,6 +47,7 @@ import org.keycloak.testsuite.util.OAuthClient;
|
|||
import org.keycloak.testsuite.authentication.ConditionalUserAttributeValueFactory;
|
||||
import org.keycloak.testsuite.authentication.SetUserAttributeAuthenticatorFactory;
|
||||
import org.keycloak.testsuite.util.URLUtils;
|
||||
import org.keycloak.testsuite.util.WaitUtils;
|
||||
import org.openqa.selenium.By;
|
||||
import org.openqa.selenium.WebDriver;
|
||||
import org.openqa.selenium.WebElement;
|
||||
|
@ -137,7 +140,7 @@ public class BrowserFlowTest extends AbstractTestRealmKeycloakTest {
|
|||
Assert.assertFalse(loginPage.isCurrent());
|
||||
Assert.assertFalse(oneTimeCodePage.isOtpLabelPresent());
|
||||
Assert.assertFalse(loginTotpPage.isCurrent());
|
||||
loginTotpPage.assertCredentialsComboboxAvailability(false);
|
||||
loginTotpPage.assertOtpCredentialSelectorAvailability(false);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -145,9 +148,10 @@ public class BrowserFlowTest extends AbstractTestRealmKeycloakTest {
|
|||
provideUsernamePassword("user-with-one-configured-otp");
|
||||
Assert.assertTrue(oneTimeCodePage.isOtpLabelPresent());
|
||||
loginTotpPage.assertCurrent();
|
||||
loginTotpPage.assertCredentialsComboboxAvailability(false);
|
||||
loginTotpPage.assertOtpCredentialSelectorAvailability(false);
|
||||
|
||||
oneTimeCodePage.sendCode("123456");
|
||||
// Use 7 digits instead 6 to have 100% probability of failure
|
||||
oneTimeCodePage.sendCode("1234567");
|
||||
Assert.assertEquals(INVALID_AUTH_CODE, oneTimeCodePage.getError());
|
||||
Assert.assertTrue(oneTimeCodePage.isOtpLabelPresent());
|
||||
}
|
||||
|
@ -157,7 +161,7 @@ public class BrowserFlowTest extends AbstractTestRealmKeycloakTest {
|
|||
provideUsernamePassword("user-with-one-configured-otp");
|
||||
Assert.assertTrue(oneTimeCodePage.isOtpLabelPresent());
|
||||
loginTotpPage.assertCurrent();
|
||||
loginTotpPage.assertCredentialsComboboxAvailability(false);
|
||||
loginTotpPage.assertOtpCredentialSelectorAvailability(false);
|
||||
|
||||
oneTimeCodePage.sendCode(getOtpCode("DJmQfC73VGFhw7D4QJ8A"));
|
||||
Assert.assertFalse(loginPage.isCurrent());
|
||||
|
@ -194,24 +198,24 @@ public class BrowserFlowTest extends AbstractTestRealmKeycloakTest {
|
|||
provideUsernamePassword("user-with-two-configured-otp");
|
||||
Assert.assertTrue(oneTimeCodePage.isOtpLabelPresent());
|
||||
loginTotpPage.assertCurrent();
|
||||
loginTotpPage.assertCredentialsComboboxAvailability(true);
|
||||
loginTotpPage.assertOtpCredentialSelectorAvailability(true);
|
||||
|
||||
// Check that selected credential is "first"
|
||||
Assert.assertEquals("first", loginTotpPage.getSelectedCredential());
|
||||
Assert.assertEquals("first", loginTotpPage.getSelectedOtpCredential());
|
||||
|
||||
// Select "second" factor but try to connect with the OTP code from the "first" one
|
||||
oneTimeCodePage.selectFactor("second");
|
||||
oneTimeCodePage.sendCode(getOtpCode(firstKey));
|
||||
// Select "second" factor (which is unnamed as it doesn't have userLabel) but try to connect with the OTP code from the "first" one
|
||||
loginTotpPage.selectOtpCredential(OTPFormAuthenticator.UNNAMED);
|
||||
loginTotpPage.login(getOtpCode(firstKey));
|
||||
Assert.assertEquals(INVALID_AUTH_CODE, oneTimeCodePage.getError());
|
||||
|
||||
// Select "first" factor but try to connect with the OTP code from the "second" one
|
||||
oneTimeCodePage.selectFactor("first");
|
||||
oneTimeCodePage.sendCode(getOtpCode(secondKey));
|
||||
loginTotpPage.selectOtpCredential("first");
|
||||
loginTotpPage.login(getOtpCode(secondKey));
|
||||
Assert.assertEquals(INVALID_AUTH_CODE, oneTimeCodePage.getError());
|
||||
|
||||
// Select "second" factor and try to connect with its OTP code
|
||||
oneTimeCodePage.selectFactor("second");
|
||||
oneTimeCodePage.sendCode(getOtpCode(secondKey));
|
||||
loginTotpPage.selectOtpCredential(OTPFormAuthenticator.UNNAMED);
|
||||
loginTotpPage.login(getOtpCode(secondKey));
|
||||
Assert.assertFalse(oneTimeCodePage.isOtpLabelPresent());
|
||||
}
|
||||
|
||||
|
@ -220,12 +224,12 @@ public class BrowserFlowTest extends AbstractTestRealmKeycloakTest {
|
|||
provideUsernamePassword(username);
|
||||
Assert.assertTrue(oneTimeCodePage.isOtpLabelPresent());
|
||||
loginTotpPage.assertCurrent();
|
||||
loginTotpPage.assertCredentialsComboboxAvailability(true);
|
||||
loginTotpPage.assertOtpCredentialSelectorAvailability(true);
|
||||
|
||||
// Check that preferred credential is selected
|
||||
Assert.assertEquals(orderedCredentials.get(0), loginTotpPage.getSelectedCredential());
|
||||
Assert.assertEquals(orderedCredentials.get(0), loginTotpPage.getSelectedOtpCredential());
|
||||
// Check credentials order
|
||||
List<String> creds = loginTotpPage.getAvailableCredentials();
|
||||
List<String> creds = loginTotpPage.getAvailableOtpCredentials();
|
||||
Assert.assertEquals(2, creds.size());
|
||||
Assert.assertEquals(orderedCredentials, creds);
|
||||
}
|
||||
|
@ -236,7 +240,7 @@ public class BrowserFlowTest extends AbstractTestRealmKeycloakTest {
|
|||
int idxFirst = 0; // Credentials order is: first, password, second
|
||||
|
||||
// Priority tells: first then second
|
||||
testCredentialsOrder(username, Arrays.asList("first", "second"));
|
||||
testCredentialsOrder(username, Arrays.asList("first", OTPFormAuthenticator.UNNAMED));
|
||||
|
||||
try {
|
||||
// Move first credential in last position
|
||||
|
@ -247,66 +251,13 @@ public class BrowserFlowTest extends AbstractTestRealmKeycloakTest {
|
|||
});
|
||||
|
||||
// Priority tells: second then first
|
||||
testCredentialsOrder(username, Arrays.asList("second", "first"));
|
||||
testCredentialsOrder(username, Arrays.asList(OTPFormAuthenticator.UNNAMED, "first"));
|
||||
} finally {
|
||||
// Restore default testrealm.json
|
||||
importTestRealm(null);
|
||||
}
|
||||
}
|
||||
|
||||
// In a sub-flow with alternative credential executors, check which credentials are available and in which order
|
||||
@Test
|
||||
@AuthServerContainerExclude(REMOTE)
|
||||
public void testAlternativeCredentials() {
|
||||
try {
|
||||
configureBrowserFlowWithAlternativeCredentials();
|
||||
|
||||
// test-user has not other credential than his password. No combobox is displayed
|
||||
loginUsernameOnlyPage.open();
|
||||
loginUsernameOnlyPage.login("test-user@localhost");
|
||||
loginTotpPage.assertCredentialsComboboxAvailability(false);
|
||||
|
||||
// A user with only one other credential than his password: the combobox should
|
||||
// let him choose between his password and his OTP credentials
|
||||
loginUsernameOnlyPage.open();
|
||||
loginUsernameOnlyPage.login("user-with-one-configured-otp");
|
||||
loginTotpPage.assertCredentialsComboboxAvailability(true);
|
||||
Assert.assertEquals(Arrays.asList("Password", "OTP"), loginTotpPage.getAvailableCredentials());
|
||||
|
||||
// A user with two other credentials than his password: the combobox should
|
||||
// let him choose between his 3 credentials in the order of his preferences
|
||||
loginUsernameOnlyPage.open();
|
||||
loginUsernameOnlyPage.login("user-with-two-configured-otp");
|
||||
loginTotpPage.assertCredentialsComboboxAvailability(true);
|
||||
Assert.assertEquals("OTP - first", loginTotpPage.getSelectedCredential());
|
||||
Assert.assertEquals(Arrays.asList("OTP - first", "Password", "OTP - second"), loginTotpPage.getAvailableCredentials());
|
||||
} finally {
|
||||
revertFlows("browser - alternative");
|
||||
}
|
||||
}
|
||||
|
||||
private void configureBrowserFlowWithAlternativeCredentials() {
|
||||
configureBrowserFlowWithAlternativeCredentials(testingClient);
|
||||
}
|
||||
|
||||
static void configureBrowserFlowWithAlternativeCredentials(KeycloakTestingClient testingClient) {
|
||||
final String newFlowAlias = "browser - alternative";
|
||||
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)
|
||||
.addSubFlowExecution(Requirement.CONDITIONAL, altSubFlow -> altSubFlow
|
||||
// Add authenticators to this flow: 1 conditional authenticator and 2 basic authenticator executions
|
||||
.addAuthenticatorExecution(Requirement.REQUIRED, ConditionalUserConfiguredAuthenticatorFactory.PROVIDER_ID)
|
||||
.addAuthenticatorExecution(Requirement.ALTERNATIVE, PasswordFormFactory.PROVIDER_ID)
|
||||
.addAuthenticatorExecution(Requirement.ALTERNATIVE, OTPFormAuthenticatorFactory.PROVIDER_ID)
|
||||
)
|
||||
)
|
||||
.defineAsBrowserFlow()
|
||||
);
|
||||
}
|
||||
|
||||
// In a form waiting for a username only, provides a username and check if password is requested in the following execution of the flow
|
||||
private boolean needsPassword(String username) {
|
||||
|
@ -442,7 +393,7 @@ public class BrowserFlowTest extends AbstractTestRealmKeycloakTest {
|
|||
provideUsernamePassword("user-with-two-configured-otp");
|
||||
Assert.assertTrue(oneTimeCodePage.isOtpLabelPresent());
|
||||
loginTotpPage.assertCurrent();
|
||||
loginTotpPage.assertCredentialsComboboxAvailability(true);
|
||||
loginTotpPage.assertOtpCredentialSelectorAvailability(true);
|
||||
|
||||
// user-with-one-configured-otp has not configured role. He should not be asked for an OTP code
|
||||
provideUsernamePassword("user-with-one-configured-otp");
|
||||
|
@ -541,49 +492,6 @@ public class BrowserFlowTest extends AbstractTestRealmKeycloakTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@AuthServerContainerExclude(REMOTE)
|
||||
public void testBackButtonFromAlternativeSubflow() {
|
||||
final String newFlowAlias = "browser - back button subflow";
|
||||
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)
|
||||
.addSubFlowExecution(Requirement.REQUIRED, reqSubFlow -> reqSubFlow
|
||||
// Add authenticators to this flow: 1 PASSWORD, 2 Another subflow with having only OTP as child
|
||||
.addAuthenticatorExecution(Requirement.ALTERNATIVE, PasswordFormFactory.PROVIDER_ID)
|
||||
.addSubFlowExecution("otp subflow", AuthenticationFlow.BASIC_FLOW, Requirement.ALTERNATIVE, altSubFlow -> altSubFlow
|
||||
.addAuthenticatorExecution(Requirement.REQUIRED, OTPFormAuthenticatorFactory.PROVIDER_ID)
|
||||
)
|
||||
)
|
||||
)
|
||||
.defineAsBrowserFlow()
|
||||
);
|
||||
|
||||
try {
|
||||
// Provide username, should be on password page
|
||||
needsPassword("user-with-one-configured-otp");
|
||||
|
||||
// Select the OTP subflow. The credential selection won't be on the page due it's subflow
|
||||
passwordPage.selectCredential("otp subflow");
|
||||
loginTotpPage.assertCurrent();
|
||||
loginTotpPage.assertCredentialsComboboxAvailability(false);
|
||||
|
||||
// Click "back". Should be on password page
|
||||
loginTotpPage.clickBackButton();
|
||||
passwordPage.assertCurrent();
|
||||
passwordPage.login("password");
|
||||
|
||||
Assert.assertFalse(passwordPage.isCurrent());
|
||||
Assert.assertFalse(loginPage.isCurrent());
|
||||
events.expectLogin().user(testRealm().users().search("user-with-one-configured-otp").get(0).getId())
|
||||
.detail(Details.USERNAME, "user-with-one-configured-otp").assertEvent();
|
||||
} finally {
|
||||
revertFlows("browser - back button subflow");
|
||||
}
|
||||
}
|
||||
|
||||
// Configure a flow with a conditional sub flow with a condition where a specific role is required
|
||||
private void configureBrowserFlowOTPNeedsRole(String requiredRole) {
|
||||
|
@ -731,9 +639,9 @@ public class BrowserFlowTest extends AbstractTestRealmKeycloakTest {
|
|||
// Assert on password page now
|
||||
Assert.assertTrue(oneTimeCodePage.isOtpLabelPresent());
|
||||
loginTotpPage.assertCurrent();
|
||||
loginTotpPage.assertCredentialsComboboxAvailability(false);
|
||||
loginTotpPage.assertOtpCredentialSelectorAvailability(false);
|
||||
|
||||
oneTimeCodePage.sendCode(getOtpCode("DJmQfC73VGFhw7D4QJ8A"));
|
||||
loginTotpPage.login(getOtpCode("DJmQfC73VGFhw7D4QJ8A"));
|
||||
Assert.assertFalse(loginTotpPage.isCurrent());
|
||||
events.expectLogin().user(testRealm().users().search("user-with-one-configured-otp").get(0).getId())
|
||||
.detail(Details.USERNAME, "user-with-one-configured-otp").assertEvent();
|
||||
|
@ -797,9 +705,9 @@ public class BrowserFlowTest extends AbstractTestRealmKeycloakTest {
|
|||
// Assert on otp page now
|
||||
Assert.assertTrue(oneTimeCodePage.isOtpLabelPresent());
|
||||
loginTotpPage.assertCurrent();
|
||||
loginTotpPage.assertCredentialsComboboxAvailability(true);
|
||||
loginTotpPage.assertOtpCredentialSelectorAvailability(true);
|
||||
|
||||
oneTimeCodePage.sendCode(getOtpCode("DJmQfC73VGFhw7D4QJ8A"));
|
||||
loginTotpPage.login(getOtpCode("DJmQfC73VGFhw7D4QJ8A"));
|
||||
Assert.assertFalse(loginTotpPage.isCurrent());
|
||||
events.expectLogin().user(userId).detail(Details.USERNAME, "user-with-two-configured-otp").assertEvent();
|
||||
} finally {
|
||||
|
@ -1125,7 +1033,7 @@ public class BrowserFlowTest extends AbstractTestRealmKeycloakTest {
|
|||
UserRepresentation user = testRealm().users().search("test-user@localhost").get(0);
|
||||
Assert.assertNotNull(user);
|
||||
|
||||
configureBrowserFlowWithAlternativeCredentials();
|
||||
MultiFactorAuthenticationTest.configureBrowserFlowWithAlternativeCredentials(testingClient);
|
||||
try {
|
||||
RealmRepresentation realm = testRealm().toRepresentation();
|
||||
realm.setLoginWithEmailAllowed(false);
|
||||
|
@ -1249,12 +1157,16 @@ public class BrowserFlowTest extends AbstractTestRealmKeycloakTest {
|
|||
|
||||
|
||||
private void revertFlows(String flowToDeleteAlias) {
|
||||
List<AuthenticationFlowRepresentation> flows = testRealm().flows().getFlows();
|
||||
revertFlows(testRealm(), flowToDeleteAlias);
|
||||
}
|
||||
|
||||
static void revertFlows(RealmResource realmResource, String flowToDeleteAlias) {
|
||||
List<AuthenticationFlowRepresentation> flows = realmResource.flows().getFlows();
|
||||
|
||||
// Set default browser flow
|
||||
RealmRepresentation realm = testRealm().toRepresentation();
|
||||
RealmRepresentation realm = realmResource.toRepresentation();
|
||||
realm.setBrowserFlow(DefaultAuthenticationFlows.BROWSER_FLOW);
|
||||
testRealm().update(realm);
|
||||
realmResource.update(realm);
|
||||
|
||||
AuthenticationFlowRepresentation flowRepresentation = AbstractAuthenticationTest.findFlowByAlias(flowToDeleteAlias, flows);
|
||||
|
||||
|
@ -1264,6 +1176,7 @@ public class BrowserFlowTest extends AbstractTestRealmKeycloakTest {
|
|||
throw new IllegalArgumentException("The flow with alias " + flowToDeleteAlias + " did not exists");
|
||||
}
|
||||
|
||||
testRealm().flows().deleteFlow(flowRepresentation.getId());
|
||||
realmResource.flows().deleteFlow(flowRepresentation.getId());
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -0,0 +1,291 @@
|
|||
/*
|
||||
* Copyright 2019 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.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
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.Assert;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.keycloak.authentication.AuthenticationFlow;
|
||||
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.browser.WebAuthnAuthenticatorFactory;
|
||||
import org.keycloak.events.Details;
|
||||
import org.keycloak.models.AuthenticationExecutionModel;
|
||||
import org.keycloak.models.utils.TimeBasedOTP;
|
||||
import org.keycloak.representations.idm.RealmRepresentation;
|
||||
import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
|
||||
import org.keycloak.testsuite.AssertEvents;
|
||||
import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude;
|
||||
import org.keycloak.testsuite.client.KeycloakTestingClient;
|
||||
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.pages.SelectAuthenticatorPage;
|
||||
import org.keycloak.testsuite.util.FlowUtil;
|
||||
import org.keycloak.testsuite.util.OAuthClient;
|
||||
import org.keycloak.testsuite.util.WaitUtils;
|
||||
import org.openqa.selenium.WebDriver;
|
||||
|
||||
import static org.keycloak.testsuite.admin.AbstractAdminTest.loadJson;
|
||||
import static org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude.AuthServer.REMOTE;
|
||||
|
||||
/**
|
||||
* Test various scenarios for multi-factor login. Test that "Try another way" link works as expected
|
||||
* and users are able to choose between various alternative authenticators for the particular factor (1st factor, 2nd factor)
|
||||
*
|
||||
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
|
||||
*/
|
||||
@AuthServerContainerExclude(REMOTE)
|
||||
public class MultiFactorAuthenticationTest 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 SelectAuthenticatorPage selectAuthenticatorPage;
|
||||
|
||||
@Rule
|
||||
public AssertEvents events = new AssertEvents(this);
|
||||
|
||||
@Override
|
||||
public void configureTestRealm(RealmRepresentation testRealm) {
|
||||
}
|
||||
|
||||
private RealmRepresentation loadTestRealm() {
|
||||
RealmRepresentation res = loadJson(getClass().getResourceAsStream("/testrealm.json"), RealmRepresentation.class);
|
||||
res.setBrowserFlow("browser");
|
||||
return res;
|
||||
}
|
||||
|
||||
private void importTestRealm(Consumer<RealmRepresentation> realmUpdater) {
|
||||
RealmRepresentation realm = loadTestRealm();
|
||||
if (realmUpdater != null) {
|
||||
realmUpdater.accept(realm);
|
||||
}
|
||||
importRealm(realm);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addTestRealms(List<RealmRepresentation> testRealms) {
|
||||
log.debug("Adding test realm for import from testrealm.json");
|
||||
testRealms.add(loadTestRealm());
|
||||
}
|
||||
|
||||
|
||||
// In a sub-flow with alternative credential executors, check which credentials are available and in which order
|
||||
// This also tests "try another way" link
|
||||
@Test
|
||||
public void testAlternativeCredentials() {
|
||||
try {
|
||||
configureBrowserFlowWithAlternativeCredentials();
|
||||
|
||||
// test-user has not other credential than his password. No try-another-way link is displayed
|
||||
loginUsernameOnlyPage.open();
|
||||
loginUsernameOnlyPage.login("test-user@localhost");
|
||||
passwordPage.assertCurrent();
|
||||
loginTotpPage.assertTryAnotherWayLinkAvailability(false);
|
||||
|
||||
// A user with only one other credential than his password: the try-another-way link should be accessible
|
||||
// and he should be able to choose between his password and his OTP credentials
|
||||
loginUsernameOnlyPage.open();
|
||||
loginUsernameOnlyPage.login("user-with-one-configured-otp");
|
||||
passwordPage.assertCurrent();
|
||||
passwordPage.assertTryAnotherWayLinkAvailability(true);
|
||||
passwordPage.clickTryAnotherWayLink();
|
||||
|
||||
selectAuthenticatorPage.assertCurrent();
|
||||
Assert.assertEquals(Arrays.asList("Password", "OTP"), selectAuthenticatorPage.getAvailableLoginMethods());
|
||||
|
||||
// Select OTP and see that just single OTP is available for this user
|
||||
selectAuthenticatorPage.selectLoginMethod("OTP");
|
||||
loginTotpPage.assertCurrent();
|
||||
loginTotpPage.assertTryAnotherWayLinkAvailability(true);
|
||||
loginTotpPage.assertOtpCredentialSelectorAvailability(false);
|
||||
|
||||
// A user with two OTP credentials and password credential: He should be able to choose just between the password and OTP similarly
|
||||
// like user with user-with-one-configured-otp. However OTP is preferred credential for him, so OTP mechanism will take preference
|
||||
loginUsernameOnlyPage.open();
|
||||
loginUsernameOnlyPage.login("user-with-two-configured-otp");
|
||||
loginTotpPage.assertCurrent();
|
||||
loginTotpPage.assertTryAnotherWayLinkAvailability(true);
|
||||
|
||||
// More OTP credentials should be available for this user
|
||||
loginTotpPage.assertOtpCredentialSelectorAvailability(true);
|
||||
|
||||
loginTotpPage.clickTryAnotherWayLink();
|
||||
|
||||
selectAuthenticatorPage.assertCurrent();
|
||||
Assert.assertEquals(Arrays.asList("OTP", "Password"), selectAuthenticatorPage.getAvailableLoginMethods());
|
||||
} finally {
|
||||
BrowserFlowTest.revertFlows(testRealm(), "browser - alternative");
|
||||
}
|
||||
}
|
||||
|
||||
private void configureBrowserFlowWithAlternativeCredentials() {
|
||||
configureBrowserFlowWithAlternativeCredentials(testingClient);
|
||||
}
|
||||
|
||||
static void configureBrowserFlowWithAlternativeCredentials(KeycloakTestingClient testingClient) {
|
||||
final String newFlowAlias = "browser - alternative";
|
||||
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)
|
||||
.addSubFlowExecution(AuthenticationExecutionModel.Requirement.REQUIRED, altSubFlow -> altSubFlow
|
||||
// Add 2 basic authenticator executions
|
||||
.addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.ALTERNATIVE, PasswordFormFactory.PROVIDER_ID)
|
||||
.addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.ALTERNATIVE, OTPFormAuthenticatorFactory.PROVIDER_ID)
|
||||
)
|
||||
)
|
||||
.defineAsBrowserFlow()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
public void testAlternativeMechanismsInDifferentSubflows() {
|
||||
final String newFlowAlias = "browser - alternative mechanisms";
|
||||
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)
|
||||
.addSubFlowExecution(AuthenticationExecutionModel.Requirement.REQUIRED, reqSubFlow -> reqSubFlow
|
||||
// Add authenticators to this flow: 1 PASSWORD, 2 Another subflow with having only OTP as child
|
||||
.addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.ALTERNATIVE, PasswordFormFactory.PROVIDER_ID)
|
||||
.addSubFlowExecution("otp subflow", AuthenticationFlow.BASIC_FLOW, AuthenticationExecutionModel.Requirement.ALTERNATIVE, altSubFlow -> altSubFlow
|
||||
.addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.REQUIRED, OTPFormAuthenticatorFactory.PROVIDER_ID)
|
||||
)
|
||||
)
|
||||
)
|
||||
.defineAsBrowserFlow()
|
||||
);
|
||||
|
||||
try {
|
||||
// Provide username, should be on password page with the link "Try another way" available
|
||||
loginUsernameOnlyPage.open();
|
||||
loginUsernameOnlyPage.login("user-with-one-configured-otp");
|
||||
passwordPage.assertCurrent();
|
||||
passwordPage.assertTryAnotherWayLinkAvailability(true);
|
||||
|
||||
// Click "Try another way" . Ability to have both password and OTP should be possible even if OTP is in different subflow
|
||||
passwordPage.clickTryAnotherWayLink();
|
||||
selectAuthenticatorPage.assertCurrent();
|
||||
Assert.assertEquals(Arrays.asList("Password", "OTP"), selectAuthenticatorPage.getAvailableLoginMethods());
|
||||
selectAuthenticatorPage.selectLoginMethod("OTP");
|
||||
|
||||
// Should be on the OTP now. Click "Try another way" again. Should see again both Password and OTP
|
||||
loginTotpPage.assertCurrent();
|
||||
loginTotpPage.assertTryAnotherWayLinkAvailability(true);
|
||||
|
||||
loginTotpPage.clickTryAnotherWayLink();
|
||||
selectAuthenticatorPage.assertCurrent();
|
||||
Assert.assertEquals(Arrays.asList("Password", "OTP"), selectAuthenticatorPage.getAvailableLoginMethods());
|
||||
|
||||
selectAuthenticatorPage.selectLoginMethod("Password");
|
||||
passwordPage.assertCurrent();
|
||||
passwordPage.login("password");
|
||||
|
||||
Assert.assertFalse(passwordPage.isCurrent());
|
||||
Assert.assertFalse(loginPage.isCurrent());
|
||||
events.expectLogin().user(testRealm().users().search("user-with-one-configured-otp").get(0).getId())
|
||||
.detail(Details.USERNAME, "user-with-one-configured-otp").assertEvent();
|
||||
} finally {
|
||||
BrowserFlowTest.revertFlows(testRealm(),"browser - alternative mechanisms");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Test for the case when user can authenticate either with: WebAuthn OR (Password AND OTP)
|
||||
// WebAuthn is not enabled for the user, so he needs to use password AND OTP
|
||||
@Test
|
||||
public void testAlternativeMechanismsInDifferentSubflows_firstMechanismUnavailable() {
|
||||
final String newFlowAlias = "browser - alternative mechanisms";
|
||||
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)
|
||||
.addSubFlowExecution(AuthenticationExecutionModel.Requirement.REQUIRED, reqSubFlow -> reqSubFlow
|
||||
// Add authenticators to this flow: 1 WebAuthn, 2 Another subflow with having Password AND OTP as children
|
||||
.addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.ALTERNATIVE, WebAuthnAuthenticatorFactory.PROVIDER_ID)
|
||||
.addSubFlowExecution("password and otp subflow", AuthenticationFlow.BASIC_FLOW, AuthenticationExecutionModel.Requirement.ALTERNATIVE, altSubFlow -> altSubFlow
|
||||
.addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.REQUIRED, PasswordFormFactory.PROVIDER_ID)
|
||||
.addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.REQUIRED, OTPFormAuthenticatorFactory.PROVIDER_ID)
|
||||
)
|
||||
)
|
||||
)
|
||||
.defineAsBrowserFlow()
|
||||
);
|
||||
|
||||
try {
|
||||
// Provide username, should be on password page without the link "Try another way" available
|
||||
loginUsernameOnlyPage.open();
|
||||
loginUsernameOnlyPage.login("user-with-one-configured-otp");
|
||||
passwordPage.assertCurrent();
|
||||
passwordPage.assertTryAnotherWayLinkAvailability(false);
|
||||
|
||||
// Login with password. Should be on the OTP page without try-another-way link available
|
||||
passwordPage.login("password");
|
||||
loginTotpPage.assertCurrent();
|
||||
loginTotpPage.assertTryAnotherWayLinkAvailability(false);
|
||||
|
||||
// Successfully login with OTP
|
||||
loginTotpPage.login(new TimeBasedOTP().generateTOTP("DJmQfC73VGFhw7D4QJ8A"));
|
||||
Assert.assertFalse(loginTotpPage.isCurrent());
|
||||
events.expectLogin().user(testRealm().users().search("user-with-one-configured-otp").get(0).getId())
|
||||
.detail(Details.USERNAME, "user-with-one-configured-otp").assertEvent();
|
||||
} finally {
|
||||
BrowserFlowTest.revertFlows(testRealm(),"browser - alternative mechanisms");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -127,7 +127,7 @@ public class ResetCredentialsAlternativeFlowsTest extends AbstractTestRealmKeycl
|
|||
@Test
|
||||
public void testBackButtonWhenSwitchToResetCredentialsFlowFromAlternativeBrowserFlow() {
|
||||
try {
|
||||
BrowserFlowTest.configureBrowserFlowWithAlternativeCredentials(testingClient);
|
||||
MultiFactorAuthenticationTest.configureBrowserFlowWithAlternativeCredentials(testingClient);
|
||||
|
||||
// Provide username and then click "Forget password"
|
||||
provideUsernameAndClickResetPassword("login-test");
|
||||
|
@ -154,7 +154,7 @@ public class ResetCredentialsAlternativeFlowsTest extends AbstractTestRealmKeycl
|
|||
@Test
|
||||
public void testNotExistingUserProvidedInResetCredentialsFlow() {
|
||||
try {
|
||||
BrowserFlowTest.configureBrowserFlowWithAlternativeCredentials(testingClient);
|
||||
MultiFactorAuthenticationTest.configureBrowserFlowWithAlternativeCredentials(testingClient);
|
||||
|
||||
// Provide username and then click "Forget password"
|
||||
provideUsernameAndClickResetPassword("login-test");
|
||||
|
@ -181,7 +181,7 @@ public class ResetCredentialsAlternativeFlowsTest extends AbstractTestRealmKeycl
|
|||
@Test
|
||||
public void testDifferentUserProvidedInResetCredentialsFlow() {
|
||||
try {
|
||||
BrowserFlowTest.configureBrowserFlowWithAlternativeCredentials(testingClient);
|
||||
MultiFactorAuthenticationTest.configureBrowserFlowWithAlternativeCredentials(testingClient);
|
||||
|
||||
// Provide username and then click "Forget password"
|
||||
provideUsernameAndClickResetPassword("login-test");
|
||||
|
@ -207,7 +207,7 @@ public class ResetCredentialsAlternativeFlowsTest extends AbstractTestRealmKeycl
|
|||
@Test
|
||||
public void testSameUserProvidedInResetCredentialsFlow() {
|
||||
try {
|
||||
BrowserFlowTest.configureBrowserFlowWithAlternativeCredentials(testingClient);
|
||||
MultiFactorAuthenticationTest.configureBrowserFlowWithAlternativeCredentials(testingClient);
|
||||
|
||||
// Provide username and then click "Forget password"
|
||||
provideUsernameAndClickResetPassword("login-test");
|
||||
|
@ -234,7 +234,7 @@ public class ResetCredentialsAlternativeFlowsTest extends AbstractTestRealmKeycl
|
|||
@Test
|
||||
public void testResetCredentialsFlowWithUsernameProvidedFromBrowserFlow() throws Exception {
|
||||
try {
|
||||
BrowserFlowTest.configureBrowserFlowWithAlternativeCredentials(testingClient);
|
||||
MultiFactorAuthenticationTest.configureBrowserFlowWithAlternativeCredentials(testingClient);
|
||||
final String newFlowAlias = "resetcred - alternative";
|
||||
// Configure reset-credentials flow without ResetCredentialsChooseUser authenticator
|
||||
configureResetCredentialsRemoveExecutionsAndBindTheFlow(
|
||||
|
|
|
@ -146,6 +146,7 @@
|
|||
"credentials" : [
|
||||
{
|
||||
"id" : "first",
|
||||
"userLabel" : "first",
|
||||
"type" : "otp",
|
||||
"secretData" : "{\"value\":\"DJmQfC73VGFhw7D4QJ8A\"}",
|
||||
"credentialData" : "{\"digits\":6,\"counter\":0,\"period\":30,\"algorithm\":\"HmacSHA1\",\"subType\":\"totp\"}"
|
||||
|
|
|
@ -1,9 +1,25 @@
|
|||
<#import "select.ftl" as layout>
|
||||
<#import "template.ftl" as layout>
|
||||
<@layout.registrationLayout; section>
|
||||
<#if section = "header">
|
||||
${msg("doLogIn")}
|
||||
<#elseif section = "form">
|
||||
<form id="kc-otp-login-form" class="${properties.kcFormClass!}" action="${url.loginAction}" method="post">
|
||||
|
||||
<#if otpLogin.userOtpCredentials?size gt 1>
|
||||
<div class="${properties.kcFormGroupClass!}">
|
||||
<div class="${properties.kcLabelWrapperClass!}">
|
||||
<label for="selected-credential-id" class="${properties.kcLabelClass!}">${msg("loginCredential")}</label>
|
||||
</div>
|
||||
<div class="${properties.kcInputWrapperClass!}">
|
||||
<select id="selected-credential-id" name="selectedCredentialId" class="form-control" size="1">
|
||||
<#list otpLogin.userOtpCredentials as otpCredential>
|
||||
<option value="${otpCredential.id}" <#if otpCredential.id == otpLogin.selectedCredentialId>selected</#if>>${otpCredential.userLabel}</option>
|
||||
</#list>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</#if>
|
||||
|
||||
<div class="${properties.kcFormGroupClass!}">
|
||||
<div class="${properties.kcLabelWrapperClass!}">
|
||||
<label for="otp" class="${properties.kcLabelClass!}">${msg("loginOtpOneTime")}</label>
|
||||
|
@ -22,7 +38,6 @@
|
|||
</div>
|
||||
|
||||
<div id="kc-form-buttons" class="${properties.kcFormButtonsClass!}">
|
||||
<input type="hidden" id="id-hidden-input" name="credentialId" <#if auth.selectedCredential?has_content>value="${auth.selectedCredential}"</#if>/>
|
||||
<input class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}"
|
||||
name="login" id="kc-login" type="submit" value="${msg("doLogIn")}"/>
|
||||
</div>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
<#import "select.ftl" as layout>
|
||||
<#import "template.ftl" as layout>
|
||||
<@layout.registrationLayout displayInfo=social.displayInfo displayWide=(realm.password && social.providers??); section>
|
||||
<#if section = "header">
|
||||
${msg("doLogIn")}
|
||||
|
@ -23,7 +23,6 @@
|
|||
</div>
|
||||
|
||||
<div id="kc-form-buttons" class="${properties.kcFormGroupClass!}">
|
||||
<input type="hidden" id="id-hidden-input" name="credentialId" <#if auth.selectedCredential?has_content>value="${auth.selectedCredential}"</#if>/>
|
||||
<input tabindex="4" class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}" name="login" id="kc-login" type="submit" value="${msg("doLogIn")}"/>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
@ -12,6 +12,7 @@ doDecline=Decline
|
|||
doForgotPassword=Forgot Password?
|
||||
doClickHere=Click here
|
||||
doImpersonate=Impersonate
|
||||
doTryAnotherWay=Try Another Way
|
||||
kerberosNotConfigured=Kerberos Not Configured
|
||||
kerberosNotConfiguredTitle=Kerberos Not Configured
|
||||
bypassKerberosDetail=Either you are not logged in by Kerberos or your browser is not set up for Kerberos login. Please click continue to login in through other means
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
<#import "template.ftl" as layout>
|
||||
<@layout.registrationLayout displayInfo=true; section>
|
||||
<#if section = "header">
|
||||
<script type="text/javascript">
|
||||
// Fill up the two hidden and submit the form
|
||||
function fillAndSubmit() {
|
||||
document.getElementById('authexec-hidden-input').value = document.getElementById('authenticators-choice').value;
|
||||
document.getElementById('kc-select-credential-form').submit();
|
||||
}
|
||||
<#if auth.authenticationSelections?size gt 1>
|
||||
// We bind the action to the select
|
||||
window.addEventListener('load', function() {
|
||||
document.getElementById('authenticators-choice').addEventListener('change', fillAndSubmit);
|
||||
});
|
||||
</#if>
|
||||
</script>
|
||||
<#elseif section = "form">
|
||||
<form id="kc-select-credential-form" class="${properties.kcFormClass!}" action="${url.loginAction}" method="post">
|
||||
<div class="${properties.kcFormGroupClass!}">
|
||||
<div class="${properties.kcLabelWrapperClass!}">
|
||||
<label for="authenticators-choice" class="${properties.kcLabelClass!}">${msg("loginCredential")}</label>
|
||||
</div>
|
||||
<div class="${properties.kcInputWrapperClass!}">
|
||||
<select id="authenticators-choice" class="form-control" size="1">
|
||||
<#list auth.authenticationSelections as authenticationSelection>
|
||||
<option value="${authenticationSelection.authExecId}" <#if authenticationSelection.authExecId == execution>selected</#if>>${msg('${authenticationSelection.authExecDisplayName}')}</option>
|
||||
</#list>
|
||||
</select>
|
||||
<input type="hidden" id="authexec-hidden-input" name="authenticationExecution" />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</#if>
|
||||
</@layout.registrationLayout>
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
<#macro registrationLayout bodyClass="" displayInfo=false displayMessage=true displayWide=false>
|
||||
<#import "template.ftl" as layout>
|
||||
<@layout.registrationLayout; section>
|
||||
<#if section = "header">
|
||||
<script type="text/javascript">
|
||||
// Fill up the two hidden and submit the form
|
||||
function fillAndSubmit() {
|
||||
var selectValue = document.getElementById('authenticators-choice').value;
|
||||
if (selectValue != '') {
|
||||
var split = selectValue.split("|");
|
||||
document.getElementById('authexec-hidden-input').value = split[0];
|
||||
document.getElementById('credentialId-hidden-input').value = split[1];
|
||||
document.getElementById('kc-select-credential-form').submit();
|
||||
}
|
||||
}
|
||||
<#if auth.authenticationSelections?size gt 1>
|
||||
// We bind the action to the select
|
||||
window.addEventListener('load', function() {
|
||||
document.getElementById('authenticators-choice').addEventListener('change', fillAndSubmit);
|
||||
});
|
||||
</#if>
|
||||
</script>
|
||||
<#nested "header">
|
||||
<#elseif section = "form">
|
||||
<#if auth.authenticationSelections?size gt 1>
|
||||
<form id="kc-select-credential-form" class="${properties.kcFormClass!}" action="${url.loginAction}" method="post">
|
||||
<div class="${properties.kcFormGroupClass!}">
|
||||
<div class="${properties.kcLabelWrapperClass!}">
|
||||
<label for="authenticators-choice" class="${properties.kcLabelClass!}">${msg("loginCredential")}</label>
|
||||
</div>
|
||||
<div class="${properties.kcInputWrapperClass!}">
|
||||
<select id="authenticators-choice" class="form-control" size="1">
|
||||
<#list auth.authenticationSelections as authenticationSelection>
|
||||
<#if authenticationSelection.credentialId?has_content>
|
||||
<option value="${authenticationSelection.id}" <#if auth.selectedCredential?has_content && authenticationSelection.credentialId == auth.selectedCredential>selected</#if>><#if authenticationSelection.showCredentialType()>${msg('${authenticationSelection.authExecName}')}</#if>${authenticationSelection.credentialName}</option>
|
||||
<#else >
|
||||
<option value="${authenticationSelection.id}" <#if authenticationSelection.authExecId == execution>selected</#if>>${msg('${authenticationSelection.authExecName}')}</option>
|
||||
</#if>
|
||||
</#list>
|
||||
</select>
|
||||
<input type="hidden" id="authexec-hidden-input" name="authenticationExecution" />
|
||||
<input type="hidden" id="credentialId-hidden-input" name="credentialId" <#if auth.selectedCredential?has_content>value="${auth.selectedCredential}"</#if>/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</#if>
|
||||
<#nested "form">
|
||||
</#if>
|
||||
</@layout.registrationLayout>
|
||||
</#macro>
|
|
@ -82,6 +82,17 @@
|
|||
</form>
|
||||
</#if>
|
||||
|
||||
<#if auth?has_content && auth.showTryAnotherWayLink() >
|
||||
<form id="kc-select-try-another-way-form" action="${url.loginAction}" method="post" <#if displayWide>class="${properties.kcContentWrapperClass!}"</#if>>
|
||||
<div <#if displayWide>class="${properties.kcFormSocialAccountContentClass!} ${properties.kcFormSocialAccountClass!}"</#if>>
|
||||
<div class="${properties.kcFormGroupClass!}">
|
||||
<input type="hidden" name="tryAnotherWay" value="on" />
|
||||
<a href="#" id="try-another-way" onclick="document.forms['kc-select-try-another-way-form'].submit();return false;">${msg("doTryAnotherWay")}</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</#if>
|
||||
|
||||
<#if displayInfo>
|
||||
<div id="kc-info" class="${properties.kcSignUpClass!}">
|
||||
<div id="kc-info-wrapper" class="${properties.kcInfoAreaWrapperClass!}">
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
<#import "select.ftl" as layout>
|
||||
<#import "template.ftl" as layout>
|
||||
<@layout.registrationLayout; section>
|
||||
<#if section = "title">
|
||||
title
|
||||
|
|
Loading…
Reference in a new issue