diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalBlockRoleAuthenticatorFactory.java b/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalBlockRoleAuthenticatorFactory.java index 1c39a87304a7..46f752c20314 100644 --- a/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalBlockRoleAuthenticatorFactory.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalBlockRoleAuthenticatorFactory.java @@ -14,7 +14,7 @@ import java.util.List; public class ConditionalBlockRoleAuthenticatorFactory implements ConditionalBlockAuthenticatorFactory { - private static final String PROVIDER_ID = "conditional-user-role"; + public static final String PROVIDER_ID = "conditional-user-role"; protected static final String CONDITIONAL_USER_ROLE = "condUserRole"; private static List commonConfig; diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalBlockUserConfiguredAuthenticatorFactory.java b/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalBlockUserConfiguredAuthenticatorFactory.java index 8886acbe0f98..babecf245c47 100644 --- a/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalBlockUserConfiguredAuthenticatorFactory.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalBlockUserConfiguredAuthenticatorFactory.java @@ -12,7 +12,7 @@ import org.keycloak.provider.ProviderConfigProperty; public class ConditionalBlockUserConfiguredAuthenticatorFactory implements ConditionalBlockAuthenticatorFactory { - private static final String PROVIDER_ID = "conditional-user-configured"; + public static final String PROVIDER_ID = "conditional-user-configured"; protected static final String CONDITIONAL_USER_ROLE = "condUserConfigured"; @Override diff --git a/testsuite/integration-arquillian/HOW-TO-RUN.md b/testsuite/integration-arquillian/HOW-TO-RUN.md index c80021b8aac8..f9858ca265d6 100644 --- a/testsuite/integration-arquillian/HOW-TO-RUN.md +++ b/testsuite/integration-arquillian/HOW-TO-RUN.md @@ -3,7 +3,7 @@ How To Run various testsuite configurations ## Base steps -It's recomended to build the workspace including distribution. +It's recommended to build the workspace including distribution. cd $KEYCLOAK_SOURCES diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcOidcFirstBrokerLoginNewAuthTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcOidcFirstBrokerLoginNewAuthTest.java index ac115d7a924f..de947d0f3796 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcOidcFirstBrokerLoginNewAuthTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcOidcFirstBrokerLoginNewAuthTest.java @@ -1,27 +1,21 @@ package org.keycloak.testsuite.broker; -import java.util.List; - import org.jboss.arquillian.graphene.page.Page; -import org.junit.Before; import org.junit.Test; import org.keycloak.admin.client.resource.RealmResource; import org.keycloak.admin.client.resource.UserResource; import org.keycloak.authentication.authenticators.broker.IdpAutoLinkAuthenticatorFactory; import org.keycloak.authentication.authenticators.browser.OTPFormAuthenticatorFactory; import org.keycloak.authentication.authenticators.browser.PasswordFormFactory; -import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.models.AuthenticationExecutionModel.Requirement; import org.keycloak.models.AuthenticationFlowModel; -import org.keycloak.models.IdentityProviderModel; -import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; -import org.keycloak.models.utils.DefaultAuthenticationFlows; import org.keycloak.representations.idm.UserRepresentation; -import org.keycloak.services.resources.admin.AuthenticationManagementResource; import org.keycloak.testsuite.Assert; import org.keycloak.testsuite.admin.ApiUtil; import org.keycloak.testsuite.pages.PasswordPage; import org.keycloak.testsuite.runonserver.RunOnServer; +import org.keycloak.testsuite.util.FlowUtil; import org.keycloak.testsuite.util.UserBuilder; import static org.junit.Assert.assertEquals; @@ -33,7 +27,7 @@ * * Especially for re-authentication of user, which is linking to IDP broker, it uses "Password Form" authenticator instead of default IdpUsernamePasswordForm. * It tests various variants with OTP( Conditional OTP, Password-or-OTP) . - * + *

* TODO: in latest master, the KcOidcBrokerTest is final class. This class will need to be changed to extend from AbstractBrokerTest * * @author Marek Posolda @@ -147,7 +141,7 @@ public void testReAuthenticateWithPasswordOrOTP_otpConfigured_passwordUsed() { // Create user and link him with TOTP String consumerRealmUserId = createUser("consumer"); - String totpSecret = addTOTPToUser("consumer"); + addTOTPToUser("consumer"); loginWithBrokerAndConfirmLinkAccount(); @@ -243,7 +237,6 @@ public void testBackButtonWithOTPEnabled() { } - // Add OTP to the user. Return TOTP secret private String addTOTPToUser(String username) { @@ -292,116 +285,43 @@ private void assertUserAuthenticatedInConsumer(String consumerRealmUserId) { // Configure the variant of firstBrokerLogin flow, which will use PasswordForm instead of IdpUsernamePasswordForm. // In other words, the form with password-only instead of username/password. private static RunOnServer configureBrokerFlowToReAuthenticationWithPasswordForm(String idpAlias, String newFlowAlias) { - return (session -> { - // Copy existing firstBrokerLogin flow - RealmModel appRealm = session.getContext().getRealm(); - AuthenticationFlowModel existingFBLFlow = appRealm.getFlowByAlias(DefaultAuthenticationFlows.FIRST_BROKER_LOGIN_FLOW); - - AuthenticationFlowModel newFBLFlow = AuthenticationManagementResource.copyFlow(appRealm, existingFBLFlow, newFlowAlias); - - // - AuthenticationFlowModel reauthenticateSubflow = appRealm.getFlowByAlias(newFlowAlias + " Verify Existing Account by Re-authentication"); - List executions = appRealm.getAuthenticationExecutions(reauthenticateSubflow.getId()); - - // Remove first execution (IdpUsernamePasswordForm) - appRealm.removeAuthenticatorExecution(executions.get(0)); - - // Increase priority of the second execution (Conditional OTP Subflow) - executions.get(1).setPriority(30); - appRealm.updateAuthenticatorExecution(executions.get(1)); - - // Add AutoLink Authenticator as first (It will automatically setup user to authentication context) - AuthenticationExecutionModel execution = new AuthenticationExecutionModel(); - execution.setRequirement(AuthenticationExecutionModel.Requirement.REQUIRED); - execution.setAuthenticatorFlow(false); - execution.setAuthenticator(IdpAutoLinkAuthenticatorFactory.PROVIDER_ID); - execution.setPriority(10); - execution.setParentFlow(reauthenticateSubflow.getId()); - execution = appRealm.addAuthenticatorExecution(execution); - - // Add PasswordForm execution - execution = new AuthenticationExecutionModel(); - execution.setRequirement(AuthenticationExecutionModel.Requirement.REQUIRED); - execution.setAuthenticatorFlow(false); - execution.setAuthenticator(PasswordFormFactory.PROVIDER_ID); - execution.setPriority(20); - execution.setParentFlow(reauthenticateSubflow.getId()); - execution = appRealm.addAuthenticatorExecution(execution); - - // Setup new FirstBrokerLogin to identity provider - IdentityProviderModel idp = appRealm.getIdentityProviderByAlias(idpAlias); - idp.setFirstBrokerLoginFlowId(newFBLFlow.getId()); - appRealm.updateIdentityProvider(idp); - }); + return session -> FlowUtil.inCurrentRealm(session) + .copyFirstBrokerLoginFlow(newFlowAlias) + .inVerifyExistingAccountByReAuthentication(subFlow -> subFlow + // Remove first execution (IdpUsernamePasswordForm) + .removeExecution(0) + // Edit new first execution (Conditional OTP Subflow) + .updateExecution(0, exec -> exec.setPriority(30)) + // Add AutoLink Authenticator as first (It will automatically setup user to authentication context) + .addAuthenticatorExecution(Requirement.REQUIRED, IdpAutoLinkAuthenticatorFactory.PROVIDER_ID, 10) + // Add PasswordForm execution + .addAuthenticatorExecution(Requirement.REQUIRED, PasswordFormFactory.PROVIDER_ID, 20) + ) + .usesInIdentityProvider(idpAlias); } - // Configure the variant of firstBrokerLogin flow, which will allow to reauthenticate user with password OR totp // TOTP will be available just if configured for the user private static RunOnServer configureBrokerFlowToReAuthenticationWithPasswordOrTotp(String idpAlias, String newFlowAlias) { - return (session -> { - // Copy existing firstBrokerLogin flow - RealmModel appRealm = session.getContext().getRealm(); - AuthenticationFlowModel existingFBLFlow = appRealm.getFlowByAlias(DefaultAuthenticationFlows.FIRST_BROKER_LOGIN_FLOW); - - AuthenticationFlowModel newFBLFlow = AuthenticationManagementResource.copyFlow(appRealm, existingFBLFlow, newFlowAlias); - - // - AuthenticationFlowModel reauthenticateSubflow = appRealm.getFlowByAlias(newFlowAlias + " Verify Existing Account by Re-authentication"); - List executions = appRealm.getAuthenticationExecutions(reauthenticateSubflow.getId()); - - // Remove both executions (IdpUsernamePasswordForm and Conditional OTP subflow) - appRealm.removeAuthenticatorExecution(executions.get(0)); - appRealm.removeAuthenticatorExecution(executions.get(1)); - - // Add AutoLink Authenticator as first (It will automatically setup user to authentication context) - AuthenticationExecutionModel execution1 = new AuthenticationExecutionModel(); - execution1.setRequirement(AuthenticationExecutionModel.Requirement.REQUIRED); - execution1.setAuthenticatorFlow(false); - execution1.setAuthenticator(IdpAutoLinkAuthenticatorFactory.PROVIDER_ID); - execution1.setPriority(10); - execution1.setParentFlow(reauthenticateSubflow.getId()); - execution1 = appRealm.addAuthenticatorExecution(execution1); - - // Add "Password-or-OTP" subflow - AuthenticationFlowModel passwordOrOtpFlow = new AuthenticationFlowModel(); - passwordOrOtpFlow.setTopLevel(false); - passwordOrOtpFlow.setBuiltIn(true); - passwordOrOtpFlow.setAlias("password or otp"); - passwordOrOtpFlow.setDescription("Flow to authenticate user with password or otp"); - passwordOrOtpFlow.setProviderId("basic-flow"); - passwordOrOtpFlow = appRealm.addAuthenticationFlow(passwordOrOtpFlow); - AuthenticationExecutionModel execution2 = new AuthenticationExecutionModel(); - execution2.setParentFlow(reauthenticateSubflow.getId()); - execution2.setRequirement(AuthenticationExecutionModel.Requirement.REQUIRED); - execution2.setFlowId(passwordOrOtpFlow.getId()); - execution2.setPriority(20); - execution2.setAuthenticatorFlow(true); - appRealm.addAuthenticatorExecution(execution2); - - // Add PasswordForm ALTERNATIVE execution - AuthenticationExecutionModel execution21 = new AuthenticationExecutionModel(); - execution21.setRequirement(AuthenticationExecutionModel.Requirement.ALTERNATIVE); - execution21.setAuthenticatorFlow(false); - execution21.setAuthenticator(PasswordFormFactory.PROVIDER_ID); - execution21.setPriority(10); - execution21.setParentFlow(passwordOrOtpFlow.getId()); - execution21 = appRealm.addAuthenticatorExecution(execution21); - - // Add OTPForm ALTERNATIVE execution - AuthenticationExecutionModel execution22 = new AuthenticationExecutionModel(); - execution22.setRequirement(AuthenticationExecutionModel.Requirement.ALTERNATIVE); - execution22.setAuthenticatorFlow(false); - execution22.setAuthenticator(OTPFormAuthenticatorFactory.PROVIDER_ID); - execution22.setPriority(20); - execution22.setParentFlow(passwordOrOtpFlow.getId()); - execution22 = appRealm.addAuthenticatorExecution(execution22); - - // Setup new FirstBrokerLogin to identity provider - IdentityProviderModel idp = appRealm.getIdentityProviderByAlias(idpAlias); - idp.setFirstBrokerLoginFlowId(newFBLFlow.getId()); - appRealm.updateIdentityProvider(idp); - }); + return session -> { + AuthenticationFlowModel flowModel = FlowUtil.createFlowModel("password or otp", "basic-flow", "Flow to authenticate user with password or otp", false, true); + FlowUtil.inCurrentRealm(session) + // Copy existing firstBrokerLogin flow + .copyFirstBrokerLoginFlow(newFlowAlias) + .inVerifyExistingAccountByReAuthentication(flowUtil -> flowUtil + .clear() + // Add AutoLink Authenticator as first (It will automatically setup user to authentication context) + .addAuthenticatorExecution(Requirement.REQUIRED, IdpAutoLinkAuthenticatorFactory.PROVIDER_ID) + // Add "Password-or-OTP" subflow + .addSubFlowExecution(flowModel, Requirement.REQUIRED, subFlow -> subFlow + // Add PasswordForm ALTERNATIVE execution + .addAuthenticatorExecution(Requirement.ALTERNATIVE, PasswordFormFactory.PROVIDER_ID) + // Add OTPForm ALTERNATIVE execution + .addAuthenticatorExecution(Requirement.ALTERNATIVE, OTPFormAuthenticatorFactory.PROVIDER_ID) + ) + ) + // Setup new FirstBrokerLogin to identity provider + .usesInIdentityProvider(idpAlias); + }; } - } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/BrowserFlowTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/BrowserFlowTest.java index 47f90794cbde..81ea37b67139 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/BrowserFlowTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/BrowserFlowTest.java @@ -9,22 +9,23 @@ import org.junit.Assert; import org.junit.Test; import org.keycloak.admin.client.resource.UserResource; -import org.keycloak.authentication.authenticators.broker.IdpAutoLinkAuthenticatorFactory; +import org.keycloak.authentication.authenticators.browser.CookieAuthenticatorFactory; import org.keycloak.authentication.authenticators.browser.OTPFormAuthenticatorFactory; import org.keycloak.authentication.authenticators.browser.PasswordFormFactory; -import org.keycloak.authentication.authenticators.browser.UsernameForm; import org.keycloak.authentication.authenticators.browser.UsernameFormFactory; -import org.keycloak.common.util.KeycloakUriBuilder; +import org.keycloak.authentication.authenticators.browser.UsernamePasswordFormFactory; +import org.keycloak.authentication.authenticators.conditional.ConditionalBlockRoleAuthenticatorFactory; +import org.keycloak.authentication.authenticators.conditional.ConditionalBlockUserConfiguredAuthenticatorFactory; import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.models.AuthenticationExecutionModel.Requirement; import org.keycloak.models.AuthenticationFlowModel; import org.keycloak.models.Constants; -import org.keycloak.models.IdentityProviderModel; import org.keycloak.models.RealmModel; import org.keycloak.models.utils.DefaultAuthenticationFlows; import org.keycloak.models.utils.TimeBasedOTP; import org.keycloak.representations.idm.IdentityProviderRepresentation; import org.keycloak.representations.idm.RealmRepresentation; -import org.keycloak.services.resources.admin.AuthenticationManagementResource; +import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.testsuite.AbstractTestRealmKeycloakTest; import org.keycloak.testsuite.ActionURIUtils; import org.keycloak.testsuite.auth.page.login.OneTimeCode; @@ -37,14 +38,14 @@ import org.keycloak.testsuite.pages.PasswordPage; import org.keycloak.testsuite.runonserver.RunOnServer; import org.keycloak.testsuite.runonserver.RunOnServerDeployment; +import org.keycloak.testsuite.util.FlowUtil; import org.keycloak.testsuite.util.OAuthClient; import org.keycloak.testsuite.util.URLUtils; -import org.keycloak.testsuite.util.WaitUtils; import org.openqa.selenium.WebDriver; -import java.net.URI; import java.util.Arrays; import java.util.List; +import java.util.function.Consumer; import static org.keycloak.testsuite.admin.AbstractAdminTest.loadJson; import static org.keycloak.testsuite.arquillian.DeploymentTargetModifier.AUTH_SERVER_CURRENT; @@ -92,12 +93,24 @@ public static WebArchive deploy() { "org.keycloak.testsuite.model"); } + private RealmRepresentation loadTestRealm() { + RealmRepresentation res = loadJson(getClass().getResourceAsStream("/testrealm.json"), RealmRepresentation.class); + res.setBrowserFlow("browser"); + return res; + } + + private void importTestRealm(Consumer realmUpdater) { + RealmRepresentation realm = loadTestRealm(); + if (realmUpdater != null) { + realmUpdater.accept(realm); + } + importRealm(realm); + } + @Override public void addTestRealms(List testRealms) { log.debug("Adding test realm for import from testrealm.json"); - RealmRepresentation testRealm = loadJson(getClass().getResourceAsStream("/testrealm.json"), RealmRepresentation.class); - testRealm.setBrowserFlow("browser"); - testRealms.add(testRealm); + testRealms.add(loadTestRealm()); } private void provideUsernamePassword(String user) { @@ -113,21 +126,25 @@ private void provideUsernamePassword(String user) { loginPage.login(user, "password"); } - private String getOtpCode(String key) throws InterruptedException { + private String getOtpCode(String key) { return new TimeBasedOTP().generateTOTP(key); } @Test - public void userWithoutAdditionalFactorConnection() { + public void testUserWithoutAdditionalFactorConnection() { provideUsernamePassword("test-user@localhost"); Assert.assertFalse(loginPage.isCurrent()); Assert.assertFalse(oneTimeCodePage.isOtpLabelPresent()); + Assert.assertFalse(loginTotpPage.isCurrent()); + loginTotpPage.assertCredentialsComboboxAvailability(false); } @Test - public void userWithOneAdditionalFactorOtpFails() { + public void testUserWithOneAdditionalFactorOtpFails() { provideUsernamePassword("user-with-one-configured-otp"); Assert.assertTrue(oneTimeCodePage.isOtpLabelPresent()); + loginTotpPage.assertCurrent(); + loginTotpPage.assertCredentialsComboboxAvailability(false); oneTimeCodePage.sendCode("123456"); Assert.assertEquals(INVALID_AUTH_CODE, oneTimeCodePage.getError()); @@ -135,9 +152,11 @@ public void userWithOneAdditionalFactorOtpFails() { } @Test - public void userWithOneAdditionalFactorOtpSuccess() throws InterruptedException { + public void testUserWithOneAdditionalFactorOtpSuccess() { provideUsernamePassword("user-with-one-configured-otp"); Assert.assertTrue(oneTimeCodePage.isOtpLabelPresent()); + loginTotpPage.assertCurrent(); + loginTotpPage.assertCredentialsComboboxAvailability(false); oneTimeCodePage.sendCode(getOtpCode("DJmQfC73VGFhw7D4QJ8A")); Assert.assertFalse(loginPage.isCurrent()); @@ -145,7 +164,7 @@ public void userWithOneAdditionalFactorOtpSuccess() throws InterruptedException } @Test - public void testBackButton() throws InterruptedException { + public void testBackButton() { provideUsernamePassword("user-with-one-configured-otp"); Assert.assertTrue(oneTimeCodePage.isOtpLabelPresent()); @@ -166,13 +185,18 @@ public void testBackButton() throws InterruptedException { } @Test - public void userWithTwoAdditionalFactors() throws InterruptedException { + public void testUserWithTwoAdditionalFactors() { final String firstKey = "DJmQfC73VGFhw7D4QJ8A"; final String secondKey = "ABCQfC73VGFhw7D4QJ8A"; // Provide username and password provideUsernamePassword("user-with-two-configured-otp"); Assert.assertTrue(oneTimeCodePage.isOtpLabelPresent()); + loginTotpPage.assertCurrent(); + loginTotpPage.assertCredentialsComboboxAvailability(true); + + // Check that selected credential is "first" + Assert.assertEquals("first", loginTotpPage.getSelectedCredential()); // Select "second" factor but try to connect with the OTP code from the "first" one oneTimeCodePage.selectFactor("second"); @@ -190,10 +214,252 @@ public void userWithTwoAdditionalFactors() throws InterruptedException { Assert.assertFalse(oneTimeCodePage.isOtpLabelPresent()); } + private void testCredentialsOrder(String username, List orderedCredentials) { + // Provide username and password + provideUsernamePassword(username); + Assert.assertTrue(oneTimeCodePage.isOtpLabelPresent()); + loginTotpPage.assertCurrent(); + loginTotpPage.assertCredentialsComboboxAvailability(true); + + // Check that preferred credential is selected + Assert.assertEquals(orderedCredentials.get(0), loginTotpPage.getSelectedCredential()); + // Check credentials order + List creds = loginTotpPage.getAvailableCredentials(); + Assert.assertEquals(2, creds.size()); + Assert.assertEquals(orderedCredentials, creds); + } + + @Test + public void testCredentialsOrder() { + String username = "user-with-two-configured-otp"; + int idxFirst = 0; // Credentials order is: first, password, second + + // Priority tells: first then second + testCredentialsOrder(username, Arrays.asList("first", "second")); + + try { + // Move first credential in last position + importTestRealm(realmRep -> { + UserRepresentation user = realmRep.getUsers().stream().filter(u -> username.equals(u.getUsername())).findFirst().get(); + // Move first OTP after second while priority are not used for import + user.getCredentials().add(user.getCredentials().remove(idxFirst)); + }); + + // Priority tells: second then first + testCredentialsOrder(username, Arrays.asList("second", "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 + public void testAlternativeCredentials() { + try { + testingClient.server("test").run(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 { + testingClient.server("test").run(setBrowserFlowToRealm()); + importTestRealm(null); + } + } + + private RunOnServer configureBrowserFlowWithAlternativeCredentials() { + final String newFlowAlias = "browser - alternative"; + return session -> + FlowUtil.inCurrentRealm(session) + .copyBrowserFlow(newFlowAlias) + .inForms(forms -> forms + .clear() + .addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.REQUIRED, UsernameFormFactory.PROVIDER_ID) + .addSubFlowExecution(Requirement.CONDITIONAL, altSubFlow -> altSubFlow + // Add authenticators to this flow: 1 conditional block and 2 basic authenticator executions + .addAuthenticatorExecution(Requirement.REQUIRED, ConditionalBlockUserConfiguredAuthenticatorFactory.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) { + // provides username + loginUsernameOnlyPage.open(); + loginUsernameOnlyPage.login(username); + + return passwordPage.isCurrent(); + } + + // A conditional block without conditional block should automatically be disabled + @Test + public void testFlowDisabledWhenConditionalBlockIsMissing() { + try { + testingClient.server("test").run(configureBrowserFlowWithConditionalSubFlowHavingConditionalBlock("browser - non missing conditional block", true)); + Assert.assertTrue(needsPassword("user-with-two-configured-otp")); + + testingClient.server("test").run(configureBrowserFlowWithConditionalSubFlowHavingConditionalBlock("browser - missing conditional block", false)); + // Flow is conditional but it is missing a conditional authentication executor + // The whole flow is disabled + Assert.assertFalse(needsPassword("user-with-two-configured-otp")); + } finally { + testingClient.server("test").run(setBrowserFlowToRealm()); + } + } + + private RunOnServer configureBrowserFlowWithConditionalSubFlowHavingConditionalBlock(String newFlowAlias, boolean conditionFlowHasConditionalBlock) { + return session -> + FlowUtil.inCurrentRealm(session) + .copyBrowserFlow(newFlowAlias) + .inForms(forms -> forms + .clear() + .addAuthenticatorExecution(Requirement.REQUIRED, UsernameFormFactory.PROVIDER_ID) + .addSubFlowExecution(Requirement.CONDITIONAL, subFlow -> { + if (conditionFlowHasConditionalBlock) { + // Add authenticators to this flow: 1 conditional block and a basic authenticator executions + subFlow.addAuthenticatorExecution(Requirement.REQUIRED, ConditionalBlockUserConfiguredAuthenticatorFactory.PROVIDER_ID); + } + // Update the browser forms only with a UsernameForm + subFlow.addAuthenticatorExecution(Requirement.REQUIRED, PasswordFormFactory.PROVIDER_ID); + })) + .defineAsBrowserFlow(); + } + + // Configure a conditional block in a non-conditional sub-flow + // In such case, the whole sub-flow should be disabled + @Test + public void testConditionalBlockInNonConditionalFlow() { + try { + testingClient.server("test").run(configureBrowserFlowWithConditionalBlockInNonConditionalFlow()); + + // provides username + loginUsernameOnlyPage.open(); + loginUsernameOnlyPage.login("user-with-two-configured-otp"); + + // if flow was conditional, the conditional block would disable the flow because no user have the expected role + // Here, the password form is shown: it shows that the executor of the conditional bloc has been disabled. Other + // executors of this flow are executed anyway + passwordPage.assertCurrent(); + } finally { + testingClient.server("test").run(setBrowserFlowToRealm()); + } + } + + private RunOnServer configureBrowserFlowWithConditionalBlockInNonConditionalFlow() { + String newFlowAlias = "browser - nonconditional"; + String requiredRole = "non-existing-role"; + return session -> + FlowUtil.inCurrentRealm(session) + .copyBrowserFlow(newFlowAlias) + .inForms(forms -> forms + .clear() + .addAuthenticatorExecution(Requirement.REQUIRED, UsernameFormFactory.PROVIDER_ID) + .addSubFlowExecution(Requirement.REQUIRED, subFlow -> subFlow + // Add authenticators to this flow: 1 conditional block and a basic authenticator executions + .addAuthenticatorExecution(Requirement.REQUIRED, ConditionalBlockRoleAuthenticatorFactory.PROVIDER_ID, + config -> config.getConfig().put("condUserRole", requiredRole)) + .addAuthenticatorExecution(Requirement.REQUIRED, PasswordFormFactory.PROVIDER_ID) + ) + ) + .defineAsBrowserFlow(); + + } + + // Check the ConditionalBlockRoleAuthenticator + // Configure a conditional subflow with the required role "user" and an OTP authenticator + // user-with-two-configured-otp has the "user" role and should be asked for an OTP code + // user-with-one-configured-otp does not have the role. He should not be asked for an OTP code + @Test + public void testConditionalBlockRoleAuthenticator() { + String requiredRole = "user"; + // A browser flow is configured with an OTPForm for users having the role "user" + testingClient.server("test").run(configureBrowserFlowOTPNeedsRole(requiredRole)); + + try { + // user-with-two-configured-otp has been configured with role "user". He should be asked for an OTP code + provideUsernamePassword("user-with-two-configured-otp"); + Assert.assertTrue(oneTimeCodePage.isOtpLabelPresent()); + loginTotpPage.assertCurrent(); + loginTotpPage.assertCredentialsComboboxAvailability(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"); + Assert.assertFalse(oneTimeCodePage.isOtpLabelPresent()); + Assert.assertFalse(loginTotpPage.isCurrent()); + } finally { + testingClient.server("test").run(setBrowserFlowToRealm()); + } + } + + // Configure a flow with a conditional sub flow with a condition where a specific role is required + private RunOnServer configureBrowserFlowOTPNeedsRole(String requiredRole) { + final String newFlowAlias = "browser - rule"; + return session -> + FlowUtil.inCurrentRealm(session) + .copyBrowserFlow(newFlowAlias) + .inForms(forms -> forms + .clear() + // Update the browser forms with a UsernamePasswordForm + .addAuthenticatorExecution(Requirement.REQUIRED, UsernamePasswordFormFactory.PROVIDER_ID) + .addSubFlowExecution(Requirement.CONDITIONAL, subFlow -> subFlow + .addAuthenticatorExecution(Requirement.REQUIRED, ConditionalBlockRoleAuthenticatorFactory.PROVIDER_ID, + config -> config.getConfig().put("condUserRole", requiredRole)) + .addAuthenticatorExecution(Requirement.REQUIRED, OTPFormAuthenticatorFactory.PROVIDER_ID) + ) + ) + .defineAsBrowserFlow(); + } + + @Test + public void testAlternativeSubFlowWithAlwaysValidatingExecutor() { + testingClient.server("test").run(configureBrowserFlowSubFlowWithAlwaysValidatingExecutor()); + try { + loginUsernameOnlyPage.open(); + loginUsernameOnlyPage.login("user-with-one-configured-otp"); + } finally { + testingClient.server("test").run(setBrowserFlowToRealm()); + } + } + + private RunOnServer configureBrowserFlowSubFlowWithAlwaysValidatingExecutor() { + return session -> FlowUtil.inCurrentRealm(session) + .copyBrowserFlow("Browser - altflow validatingexecutor") + .inForms(forms -> forms + .clear() + .addAuthenticatorExecution(Requirement.REQUIRED, UsernameFormFactory.PROVIDER_ID) + .addSubFlowExecution(Requirement.REQUIRED, subFlow -> subFlow + .addSubFlowExecution(Requirement.ALTERNATIVE, altFlow -> altFlow + .addAuthenticatorExecution(Requirement.REQUIRED, CookieAuthenticatorFactory.PROVIDER_ID) + .addAuthenticatorExecution(Requirement.REQUIRED, "identity-provider-redirector")) + ) + ) + .defineAsBrowserFlow(); + } @Test public void testSwitchExecutionNotAllowedWithRequiredPasswordAndAlternativeOTP() { - testingClient.server("test").run(configureBrowserFlowWithRequiredPasswordFormAndAlternativeOTP("browser - copy 1")); + String newFlowAlias = "browser - copy 1"; + testingClient.server("test").run(configureBrowserFlowWithRequiredPasswordFormAndAlternativeOTP(newFlowAlias)); try { loginUsernameOnlyPage.open(); @@ -203,7 +469,7 @@ public void testSwitchExecutionNotAllowedWithRequiredPasswordAndAlternativeOTP() // Assert on password page now passwordPage.assertCurrent(); - String otpAuthenticatorExecutionId = realmsResouce().realm("test").flows().getExecutions("browser - copy 1") + String otpAuthenticatorExecutionId = realmsResouce().realm("test").flows().getExecutions(newFlowAlias) .stream() .filter(execution -> OTPFormAuthenticatorFactory.PROVIDER_ID.equals(execution.getProviderId())) .findFirst() @@ -247,7 +513,7 @@ public void testSocialProvidersPresentOnLoginUsernameOnlyPageIfConfigured() { loginUsernameOnlyPage.findSocialButton(provider.id()); } - // Test cleanup - Return back to the initial state + // Test cleanup - Return back to the initial state } finally { // Drop the testing social providers previously created within the test for (IdentityProviderRepresentation providerRepresentation : adminClient.realm(testRealm).identityProviders().findAll()) { @@ -258,67 +524,34 @@ public void testSocialProvidersPresentOnLoginUsernameOnlyPageIfConfigured() { } } - // Configure the browser flow with those 3 authenticators at same level as subflows of the "Form": // UsernameForm: REQUIRED // PasswordForm: REQUIRED // OTPFormAuthenticator: ALTERNATIVE // In reality, the configuration of the flow like this doesn't have much sense, but nothing prevents administrator to configure it at this moment private static RunOnServer configureBrowserFlowWithRequiredPasswordFormAndAlternativeOTP(String newFlowAlias) { - return (session -> { - // Copy existing browser flow - RealmModel appRealm = session.getContext().getRealm(); - AuthenticationFlowModel existingBrowserFlow = appRealm.getFlowByAlias(DefaultAuthenticationFlows.BROWSER_FLOW); - - AuthenticationFlowModel newBrowserFlow = AuthenticationManagementResource.copyFlow(appRealm, existingBrowserFlow, newFlowAlias); - - // - AuthenticationFlowModel formSubflow = appRealm.getFlowByAlias(newFlowAlias + " forms"); - List executions = appRealm.getAuthenticationExecutions(formSubflow.getId()); - - // Remove all executions - for (AuthenticationExecutionModel authExecution : executions) { - appRealm.removeAuthenticatorExecution(authExecution); - } - - // Add REQUIRED UsernameForm Authenticator as first - AuthenticationExecutionModel execution1 = new AuthenticationExecutionModel(); - execution1.setRequirement(AuthenticationExecutionModel.Requirement.REQUIRED); - execution1.setAuthenticatorFlow(false); - execution1.setAuthenticator(UsernameFormFactory.PROVIDER_ID); - execution1.setPriority(10); - execution1.setParentFlow(formSubflow.getId()); - execution1 = appRealm.addAuthenticatorExecution(execution1); - - // Add REQUIRED PasswordForm Authenticator as second - AuthenticationExecutionModel execution2 = new AuthenticationExecutionModel(); - execution2.setRequirement(AuthenticationExecutionModel.Requirement.REQUIRED); - execution2.setAuthenticatorFlow(false); - execution2.setAuthenticator(PasswordFormFactory.PROVIDER_ID); - execution2.setPriority(20); - execution2.setParentFlow(formSubflow.getId()); - execution2 = appRealm.addAuthenticatorExecution(execution2); - - // Add ALTERNATIVE OTPFormAuthenticator as third - AuthenticationExecutionModel execution3 = new AuthenticationExecutionModel(); - execution3.setRequirement(AuthenticationExecutionModel.Requirement.ALTERNATIVE); - execution3.setAuthenticatorFlow(false); - execution3.setAuthenticator(OTPFormAuthenticatorFactory.PROVIDER_ID); - execution3.setPriority(30); - execution3.setParentFlow(formSubflow.getId()); - execution3 = appRealm.addAuthenticatorExecution(execution3); - - // Add OTPForm ALTERNATIVE execution - appRealm.setBrowserFlow(newBrowserFlow); - }); + return session -> + FlowUtil.inCurrentRealm(session) + .copyBrowserFlow(newFlowAlias) + .inForms(forms -> + forms + .clear() + // Add REQUIRED UsernameForm Authenticator as first + .addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.REQUIRED, UsernameFormFactory.PROVIDER_ID) + // Add REQUIRED PasswordForm Authenticator as second + .addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.REQUIRED, PasswordFormFactory.PROVIDER_ID) + // Add OTPForm ALTERNATIVE execution as third + .addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.ALTERNATIVE, OTPFormAuthenticatorFactory.PROVIDER_ID) + ) + // Activate this new flow + .defineAsBrowserFlow(); } - private static RunOnServer setBrowserFlowToRealm() { - return (session -> { + return session -> { RealmModel appRealm = session.getContext().getRealm(); AuthenticationFlowModel existingBrowserFlow = appRealm.getFlowByAlias(DefaultAuthenticationFlows.BROWSER_FLOW); appRealm.setBrowserFlow(existingBrowserFlow); - }); + }; } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/FlowUtil.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/FlowUtil.java new file mode 100644 index 000000000000..fe08fbb8a386 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/FlowUtil.java @@ -0,0 +1,246 @@ +package org.keycloak.testsuite.util; + +import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.models.AuthenticationExecutionModel.Requirement; +import org.keycloak.models.AuthenticationFlowModel; +import org.keycloak.models.AuthenticatorConfigModel; +import org.keycloak.models.IdentityProviderModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.utils.DefaultAuthenticationFlows; +import org.keycloak.services.resources.admin.AuthenticationManagementResource; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Random; +import java.util.UUID; +import java.util.function.Consumer; + +public class FlowUtil { + private RealmModel realm; + private AuthenticationFlowModel currentFlow; + private String flowAlias; + private int maxPriority = 0; + private Random rand = new Random(System.currentTimeMillis()); + private List executions = null; + + public class FlowUtilException extends RuntimeException { + private static final long serialVersionUID = 5118401044519260295L; + + public FlowUtilException(String message) { + super(message); + } + } + + public FlowUtil(RealmModel realm) { + this.realm = realm; + } + + public RealmModel getRealm() { + return realm; + } + + public AuthenticationFlowModel build() { + return currentFlow; + } + + public static FlowUtil inCurrentRealm(KeycloakSession session) { + return new FlowUtil(session.getContext().getRealm()); + } + + private FlowUtil newFlowUtil(AuthenticationFlowModel flowModel) { + FlowUtil subflow = new FlowUtil(realm); + subflow.currentFlow = flowModel; + return subflow; + } + + public FlowUtil copyBrowserFlow(String newFlowAlias) { + return copyFlow(DefaultAuthenticationFlows.BROWSER_FLOW, newFlowAlias); + } + + public FlowUtil copyFirstBrokerLoginFlow(String newFlowAlias) { + return copyFlow(DefaultAuthenticationFlows.FIRST_BROKER_LOGIN_FLOW, newFlowAlias); + } + + public FlowUtil copyFlow(String original, String newFlowAlias) { + flowAlias = newFlowAlias; + AuthenticationFlowModel existingBrowserFlow = realm.getFlowByAlias(original); + if (existingBrowserFlow == null) { + throw new FlowUtilException("Can't copy flow: " + original + " does not exist"); + } + currentFlow = AuthenticationManagementResource.copyFlow(realm, existingBrowserFlow, newFlowAlias); + + return this; + } + + public FlowUtil inForms(Consumer subFlowInitializer) { + return inFlow(flowAlias + " forms", subFlowInitializer); + } + + public FlowUtil inVerifyExistingAccountByReAuthentication(Consumer subFlowInitializer) { + return inFlow(flowAlias + " Verify Existing Account by Re-authentication", subFlowInitializer); + } + + public FlowUtil inFlow(String alias, Consumer subFlowInitializer) { + if (subFlowInitializer != null) { + AuthenticationFlowModel flow = realm.getFlowByAlias(alias); + if (flow == null) { + throw new FlowUtilException("Can't find flow by alias: " + alias); + } + FlowUtil subFlow = newFlowUtil(flow); + subFlowInitializer.accept(subFlow); + } + + return this; + } + + public FlowUtil clear() { + // Get executions from current flow + List executions = realm.getAuthenticationExecutions(currentFlow.getId()); + // Remove all executions + for (AuthenticationExecutionModel authExecution : executions) { + realm.removeAuthenticatorExecution(authExecution); + } + + return this; + } + + public FlowUtil addAuthenticatorExecution(Requirement requirement, String providerId) { + return addAuthenticatorExecution(requirement, providerId, null); + } + + public FlowUtil addAuthenticatorExecution(Requirement requirement, String providerId, int priority) { + return addAuthenticatorExecution(requirement, providerId, priority, null); + } + + public FlowUtil addAuthenticatorExecution(Requirement requirement, String providerId, Consumer configInitializer) { + return addAuthenticatorExecution(requirement, providerId, maxPriority + 10, configInitializer); + } + + public FlowUtil addAuthenticatorExecution(Requirement requirement, String providerId, int priority, Consumer configInitializer) { + maxPriority = Math.max(maxPriority, priority); + + AuthenticationExecutionModel execution = new AuthenticationExecutionModel(); + execution.setRequirement(requirement); + execution.setAuthenticatorFlow(false); + execution.setAuthenticator(providerId); + execution.setPriority(priority); + execution.setParentFlow(currentFlow.getId()); + if (configInitializer != null) { + AuthenticatorConfigModel authConfig = new AuthenticatorConfigModel(); + authConfig.setId(UUID.randomUUID().toString()); + // Caller is free to update this alias + authConfig.setAlias("cfg" + authConfig.getId().hashCode()); + authConfig.setConfig(new HashMap<>()); + configInitializer.accept(authConfig); + realm.addAuthenticatorConfig(authConfig); + + execution.setAuthenticatorConfig(authConfig.getId()); + } + realm.addAuthenticatorExecution(execution); + + return this; + } + + public FlowUtil defineAsBrowserFlow() { + realm.setBrowserFlow(currentFlow); + return this; + } + + public FlowUtil defineAsDirectGrantFlow() { + realm.setDirectGrantFlow(currentFlow); + return this; + } + + public FlowUtil defineAsFlow() { + realm.setResetCredentialsFlow(currentFlow); + return this; + } + + public FlowUtil usesInIdentityProvider(String idpAlias) { + // Setup new FirstBrokerLogin flow to identity provider + IdentityProviderModel idp = realm.getIdentityProviderByAlias(idpAlias); + idp.setFirstBrokerLoginFlowId(currentFlow.getId()); + realm.updateIdentityProvider(idp); + return this; + } + + public FlowUtil addSubFlowExecution(Requirement requirement, Consumer flowInitializer) { + return addSubFlowExecution("sf" + rand.nextInt(), "basic-flow", requirement, flowInitializer); + } + + public FlowUtil addSubFlowExecution(String alias, String providerId, Requirement requirement, Consumer flowInitializer) { + return addSubFlowExecution(alias, providerId, requirement, maxPriority + 10, flowInitializer); + } + + public FlowUtil addSubFlowExecution(String alias, String providerId, Requirement requirement, int priority, Consumer flowInitializer) { + AuthenticationFlowModel flowModel = createFlowModel(alias, providerId, null, false, false); + return addSubFlowExecution(flowModel, requirement, priority, flowInitializer); + } + + public static AuthenticationFlowModel createFlowModel(String alias, String providerId, String desc, boolean topLevel, boolean builtIn) { + AuthenticationFlowModel flowModel = new AuthenticationFlowModel(); + flowModel.setId(UUID.randomUUID().toString()); + flowModel.setAlias(alias); + flowModel.setDescription(desc); + flowModel.setProviderId(providerId); + flowModel.setTopLevel(topLevel); + flowModel.setBuiltIn(builtIn); + return flowModel; + } + + public FlowUtil addSubFlowExecution(AuthenticationFlowModel flowModel, Requirement requirement, Consumer flowInitializer) { + return addSubFlowExecution(flowModel, requirement, maxPriority + 10, flowInitializer); + } + + public FlowUtil addSubFlowExecution(AuthenticationFlowModel flowModel, Requirement requirement, int priority, Consumer flowInitializer) { + maxPriority = Math.max(maxPriority, priority); + + flowModel = realm.addAuthenticationFlow(flowModel); + + AuthenticationExecutionModel execution = new AuthenticationExecutionModel(); + execution.setRequirement(requirement); + execution.setAuthenticatorFlow(true); + execution.setPriority(priority); + execution.setFlowId(flowModel.getId()); + execution.setParentFlow(currentFlow.getId()); + realm.addAuthenticatorExecution(execution); + + if (flowInitializer != null) { + FlowUtil subflow = newFlowUtil(flowModel); + flowInitializer.accept(subflow); + } + + return this; + } + + private List getExecutions() { + if (executions == null) { + List execs = realm.getAuthenticationExecutions(currentFlow.getId()); + if (execs == null) { + throw new FlowUtilException("Can't get executions of unknown flow " + currentFlow.getId()); + } + executions = new ArrayList<>(execs); + } + return executions; + } + + public FlowUtil removeExecution(int index) { + List executions = getExecutions(); + realm.removeAuthenticatorExecution(executions.remove(0)); + + return this; + } + + public FlowUtil updateExecution(int index, Consumer updater) { + List executions = getExecutions(); + if (executions != null && updater != null) { + AuthenticationExecutionModel execution = executions.get(index); + updater.accept(execution); + realm.updateAuthenticatorExecution(execution); + } + + return this; + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/resources/testrealm.json b/testsuite/integration-arquillian/tests/base/src/test/resources/testrealm.json index 055595e32af4..a9b36cf37f09 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/resources/testrealm.json +++ b/testsuite/integration-arquillian/tests/base/src/test/resources/testrealm.json @@ -142,17 +142,18 @@ "username" : "user-with-two-configured-otp", "enabled": true, "email" : "otp2@redhat.com", + "realmRoles": ["user"], "credentials" : [ - { - "type" : "password", - "value" : "password" - }, { "id" : "first", "type" : "otp", "secretData" : "{\"value\":\"DJmQfC73VGFhw7D4QJ8A\"}", "credentialData" : "{\"digits\":6,\"counter\":0,\"period\":30,\"algorithm\":\"HmacSHA1\",\"subType\":\"totp\"}" }, + { + "type" : "password", + "value" : "password" + }, { "id" : "second", "type" : "otp",