diff --git a/docs/configuration.md b/docs/configuration.md index 391de98..02d3913 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -36,13 +36,13 @@ To configure click settings/gear icon (⚙) ![Authenticator configuration](images/authenticator-config.jpg) -| Option | JSON property name | Type | Description | -|-------------------------------------|--------------------------|-----------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| User attribute | `userAttribute` | `string` | The user attribute used to lookup the user's email address.

If set to `email` (default) the authenticator will use the default email property. In this case the authenticator will only forward the user if the email has been verified or 'Forward users with unverified email' option is enabled. For any other attribute, the authenticator will not validate if the email has been verified.

A common use case is to store a User Principal Name (UPN) in a custom attribute and forward users based on the UPN instead instead of their email address. | -| Forward users with unverified email | `forwardUnverifiedEmail` | `boolean` | If switched on (`true`), users with unverified email addresses will be forwarded to their home IdP.

If switched off (`false`, default), users with unverified email addresses will not be forwarded to their home IdP. | | -| Bypass login page | `bypassLoginPage` | `boolean` | If switched on (`true`), users will be forwarded to their home IdP without the need to reenter/confirm their email address on the login page iff email address is provided as an OICD [`login_hint` parameter](https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest) or SAML `subject/nameID`.

If switched off (`false`, default), users are only redirected after submitting/confirming their email address on the login page.

*Note: This will take SAML `ForceAuthn` and OIDC [`prompt=login|consent|select_account`](https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest) parameters into account. If one of these parameters is present, the login page will not be bypassed even if switched on.* | -| Forward to linked IdP | `forwardToLinkedIdp` | `boolean` | If switched on (`true`), federated users (with already linked IdPs) will be forwarded to a linked IdP even if no IdP has been configured for the user's email address. Federated users can also use their local username for login instead of their email address.

If switched off (`false`, default), users will only be forwarded to IdPs with matching email domains. | -| Forward to first matched IdP | `forwardToFirstMatch` | `boolean` | If switched on (`true`, default), users will be forwarded to the first IdP that matches the email domain, even if multiply IdPs may match.

If switched off (`false`), user will be shown all IdPs that match the email domain to choose one, iff multiple match.
The user will only be able to choose from IdPs that match the email domain. Please note that also IdPs that have [`Hide on Login Page`](https://www.keycloak.org/docs/latest/server_admin/#_general-idp-config) switched on will be shown.
If only one IdP matches, behavior is the same as if switched on. | +| Option | JSON property name | Type | Description | +|-------------------------------------|--------------------------|-----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| User attribute | `userAttribute` | `string` | The user attribute used to lookup the user's email address.

If set to `email` (default) the authenticator will use the default email property. In this case the authenticator will only forward the user if the email has been verified or 'Forward users with unverified email' option is enabled. For any other attribute, the authenticator will not validate if the email has been verified.

A common use case is to store a User Principal Name (UPN) in a custom attribute and forward users based on the UPN instead instead of their email address. | +| Forward users with unverified email | `forwardUnverifiedEmail` | `boolean` | If switched on (`true`), users with unverified email addresses will be forwarded to their home IdP.

If switched off (`false`, default), users with unverified email addresses will not be forwarded to their home IdP. | | +| Bypass login page | `bypassLoginPage` | `boolean` | If switched on (`true`), users will be forwarded to their home IdP without the need to reenter/confirm their email address on the login page iff email address is provided as an OICD [`login_hint` parameter](https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest) or SAML `subject/nameID`.

If switched off (`false`, default), users are only redirected after submitting/confirming their email address on the login page.

*Note: This will take SAML `ForceAuthn` and OIDC [`prompt=login|consent|select_account`](https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest), and `max_age` parameters into account. If one of these parameters is present, the login page will not be bypassed even if switched on.* | +| Forward to linked IdP | `forwardToLinkedIdp` | `boolean` | If switched on (`true`), federated users (with already linked IdPs) will be forwarded to a linked IdP even if no IdP has been configured for the user's email address. Federated users can also use their local username for login instead of their email address.

If switched off (`false`, default), users will only be forwarded to IdPs with matching email domains. | +| Forward to first matched IdP | `forwardToFirstMatch` | `boolean` | If switched on (`true`, default), users will be forwarded to the first IdP that matches the email domain, even if multiply IdPs may match.

If switched off (`false`), user will be shown all IdPs that match the email domain to choose one, iff multiple match.
The user will only be able to choose from IdPs that match the email domain. Please note that also IdPs that have [`Hide on Login Page`](https://www.keycloak.org/docs/latest/server_admin/#_general-idp-config) switched on will be shown.
If only one IdP matches, behavior is the same as if switched on. | ## Email domains diff --git a/src/main/java/de/sventorben/keycloak/authentication/hidpd/AuthenticationChallenge.java b/src/main/java/de/sventorben/keycloak/authentication/hidpd/AuthenticationChallenge.java index 5971a5d..6115639 100755 --- a/src/main/java/de/sventorben/keycloak/authentication/hidpd/AuthenticationChallenge.java +++ b/src/main/java/de/sventorben/keycloak/authentication/hidpd/AuthenticationChallenge.java @@ -15,12 +15,14 @@ final class AuthenticationChallenge { private final RememberMe rememberMe; private final LoginHint loginHint; private final LoginForm loginForm; + private final Reauthentication reauthentication; - AuthenticationChallenge(AuthenticationFlowContext context, RememberMe rememberMe, LoginHint loginHint, LoginForm loginForm) { + AuthenticationChallenge(AuthenticationFlowContext context, RememberMe rememberMe, LoginHint loginHint, LoginForm loginForm, Reauthentication reauthentication) { this.context = context; this.rememberMe = rememberMe; this.loginHint = loginHint; this.loginForm = loginForm; + this.reauthentication = reauthentication; } void forceChallenge() { @@ -29,15 +31,27 @@ void forceChallenge() { String rememberMeUsername = rememberMe.getUserName(); - if (loginHintUsername != null || rememberMeUsername != null) { - if (loginHintUsername != null) { - formData.add(AuthenticationManager.FORM_USERNAME, loginHintUsername); - } else { - formData.add(AuthenticationManager.FORM_USERNAME, rememberMeUsername); - formData.add("rememberMe", "on"); + if (reauthentication.required() && context.getUser() != null) { + String attribute = context.getAuthenticatorConfig().getConfig().getOrDefault("userAttribute", "username"); + formData.add(AuthenticationManager.FORM_USERNAME, context.getUser().getFirstAttribute(attribute)); + } else { + if (loginHintUsername != null || rememberMeUsername != null) { + if (loginHintUsername != null) { + formData.add(AuthenticationManager.FORM_USERNAME, loginHintUsername); + } else { + formData.add(AuthenticationManager.FORM_USERNAME, rememberMeUsername); + formData.add("rememberMe", "on"); + } } } - Response challengeResponse = loginForm.create(formData); + + Response challengeResponse; + if (reauthentication.required()) { + challengeResponse = loginForm.createWithSignInButtonOnly(formData); + } else { + challengeResponse = loginForm.create(formData); + } + context.challenge(challengeResponse); } diff --git a/src/main/java/de/sventorben/keycloak/authentication/hidpd/HomeIdpAuthenticationFlowContext.java b/src/main/java/de/sventorben/keycloak/authentication/hidpd/HomeIdpAuthenticationFlowContext.java index afaaee6..fcfe017 100755 --- a/src/main/java/de/sventorben/keycloak/authentication/hidpd/HomeIdpAuthenticationFlowContext.java +++ b/src/main/java/de/sventorben/keycloak/authentication/hidpd/HomeIdpAuthenticationFlowContext.java @@ -15,6 +15,7 @@ final class HomeIdpAuthenticationFlowContext { private Redirector redirector; private BaseUriLoginFormsProvider loginFormsProvider; private LoginForm loginForm; + private Reauthentication reauthentication; HomeIdpAuthenticationFlowContext(AuthenticationFlowContext context) { this.context = context; @@ -29,7 +30,7 @@ HomeIdpForwarderConfig config() { LoginPage loginPage() { if (loginPage == null) { - loginPage = new LoginPage(context, config()); + loginPage = new LoginPage(context, config(), reauthentication()); } return loginPage; } @@ -57,7 +58,7 @@ RememberMe rememberMe() { AuthenticationChallenge authenticationChallenge() { if (authenticationChallenge == null) { - authenticationChallenge = new AuthenticationChallenge(context, rememberMe(), loginHint(), loginForm()); + authenticationChallenge = new AuthenticationChallenge(context, rememberMe(), loginHint(), loginForm(), reauthentication()); } return authenticationChallenge; } @@ -82,4 +83,11 @@ BaseUriLoginFormsProvider loginFormsProvider() { } return loginFormsProvider; } + + Reauthentication reauthentication() { + if (reauthentication == null) { + reauthentication = new Reauthentication(context); + } + return reauthentication; + } } diff --git a/src/main/java/de/sventorben/keycloak/authentication/hidpd/HomeIdpDiscoveryAuthenticator.java b/src/main/java/de/sventorben/keycloak/authentication/hidpd/HomeIdpDiscoveryAuthenticator.java index d024712..c961f35 100755 --- a/src/main/java/de/sventorben/keycloak/authentication/hidpd/HomeIdpDiscoveryAuthenticator.java +++ b/src/main/java/de/sventorben/keycloak/authentication/hidpd/HomeIdpDiscoveryAuthenticator.java @@ -32,14 +32,10 @@ final class HomeIdpDiscoveryAuthenticator extends AbstractUsernameFormAuthentica @Override public void authenticate(AuthenticationFlowContext authenticationFlowContext) { HomeIdpAuthenticationFlowContext context = new HomeIdpAuthenticationFlowContext(authenticationFlowContext); - if (context.loginPage().shouldByPass()) { - String loginHint = trimToNull(context.loginHint().getFromSession()); - if (loginHint == null) { - loginHint = trimToNull(authenticationFlowContext.getAuthenticationSession().getAuthNote(ATTEMPTED_USERNAME)); - } - if (loginHint != null) { - String username = setUserInContext(authenticationFlowContext, loginHint); + String usernameHint = usernameHint(authenticationFlowContext, context); + if (usernameHint != null) { + String username = setUserInContext(authenticationFlowContext, usernameHint); final List homeIdps = context.discoverer(discovererConfig).discoverForUser(authenticationFlowContext, username); if (!homeIdps.isEmpty()) { context.rememberMe().remember(username); @@ -51,6 +47,14 @@ public void authenticate(AuthenticationFlowContext authenticationFlowContext) { context.authenticationChallenge().forceChallenge(); } + private String usernameHint(AuthenticationFlowContext authenticationFlowContext, HomeIdpAuthenticationFlowContext context) { + String usernameHint = trimToNull(context.loginHint().getFromSession()); + if (usernameHint == null) { + usernameHint = trimToNull(authenticationFlowContext.getAuthenticationSession().getAuthNote(ATTEMPTED_USERNAME)); + } + return usernameHint; + } + private void redirectOrChallenge(HomeIdpAuthenticationFlowContext context, String username, List homeIdps) { if (homeIdps.size() == 1 || context.config().forwardToFirstMatch()) { IdentityProviderModel homeIdp = homeIdps.get(0); @@ -70,13 +74,21 @@ public void action(AuthenticationFlowContext authenticationFlowContext) { return; } - String username = setUserInContext(authenticationFlowContext, formData.getFirst(AuthenticationManager.FORM_USERNAME)); + HomeIdpAuthenticationFlowContext context = new HomeIdpAuthenticationFlowContext(authenticationFlowContext); + + String tryUsername; + if (context.reauthentication().required() && authenticationFlowContext.getUser() != null) { + tryUsername = authenticationFlowContext.getUser().getUsername(); + } else { + tryUsername = formData.getFirst(AuthenticationManager.FORM_USERNAME); + } + + String username = setUserInContext(authenticationFlowContext, tryUsername); if (username == null) { LOG.debugf("No username in request"); return; } - HomeIdpAuthenticationFlowContext context = new HomeIdpAuthenticationFlowContext(authenticationFlowContext); final List homeIdps = context.discoverer(discovererConfig).discoverForUser(authenticationFlowContext, username); if (homeIdps.isEmpty()) { @@ -107,7 +119,7 @@ private String setUserInContext(AuthenticationFlowContext context, String userna return username; } - private String trimToNull(String username) { + private static String trimToNull(String username) { if (username != null) { username = username.trim(); if ("".equalsIgnoreCase(username)) diff --git a/src/main/java/de/sventorben/keycloak/authentication/hidpd/LoginForm.java b/src/main/java/de/sventorben/keycloak/authentication/hidpd/LoginForm.java index 5672cdb..b5710cc 100755 --- a/src/main/java/de/sventorben/keycloak/authentication/hidpd/LoginForm.java +++ b/src/main/java/de/sventorben/keycloak/authentication/hidpd/LoginForm.java @@ -21,13 +21,24 @@ final class LoginForm { this.loginFormsProvider = loginFormsProvider; } + Response createWithSignInButtonOnly(MultivaluedMap formData) { + LoginFormsProvider form = createForm(formData); + form.setAttribute(LoginFormsProvider.USERNAME_HIDDEN, "true"); + form.setAttribute(LoginFormsProvider.REGISTRATION_DISABLED, "true"); + return form.createLoginUsername(); + } Response create(MultivaluedMap formData) { + LoginFormsProvider forms = createForm(formData); + return forms.createLoginUsername(); + } + + private LoginFormsProvider createForm(MultivaluedMap formData) { LoginFormsProvider forms = context.form(); if (!formData.isEmpty()) { forms.setFormData(formData); } - return forms.createLoginUsername(); + return forms; } Response create(List idps) { diff --git a/src/main/java/de/sventorben/keycloak/authentication/hidpd/LoginPage.java b/src/main/java/de/sventorben/keycloak/authentication/hidpd/LoginPage.java index 1e30e1c..40094a5 100755 --- a/src/main/java/de/sventorben/keycloak/authentication/hidpd/LoginPage.java +++ b/src/main/java/de/sventorben/keycloak/authentication/hidpd/LoginPage.java @@ -19,10 +19,12 @@ final class LoginPage { private final AuthenticationFlowContext context; private final HomeIdpForwarderConfig config; + private final Reauthentication reauthentication; - LoginPage(AuthenticationFlowContext context, HomeIdpForwarderConfig config) { + LoginPage(AuthenticationFlowContext context, HomeIdpForwarderConfig config, Reauthentication reauthentication) { this.context = context; this.config = config; + this.reauthentication = reauthentication; } boolean shouldByPass() { @@ -39,6 +41,10 @@ boolean shouldByPass() { LOG.debugf("SAML: Forced authentication"); return false; } + if (reauthentication.required()) { + LOG.debugf("Forced, cause reauthentication is required"); + return false; + } } return bypassLoginPage; } diff --git a/src/main/java/de/sventorben/keycloak/authentication/hidpd/Reauthentication.java b/src/main/java/de/sventorben/keycloak/authentication/hidpd/Reauthentication.java new file mode 100644 index 0000000..93ee3d5 --- /dev/null +++ b/src/main/java/de/sventorben/keycloak/authentication/hidpd/Reauthentication.java @@ -0,0 +1,25 @@ +package de.sventorben.keycloak.authentication.hidpd; + +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.models.UserSessionModel; +import org.keycloak.protocol.LoginProtocol; +import org.keycloak.services.managers.AuthenticationManager; + +final class Reauthentication { + + private final AuthenticationFlowContext context; + + Reauthentication(AuthenticationFlowContext context) { + this.context = context; + } + + boolean required() { + AuthenticationManager.AuthResult authResult = AuthenticationManager.authenticateIdentityCookie(context.getSession(), context.getRealm(), true); + UserSessionModel userSessionModel = null; + if (authResult != null) { + userSessionModel = authResult.getSession(); + } + LoginProtocol protocol = context.getSession().getProvider(LoginProtocol.class, context.getAuthenticationSession().getProtocol()); + return protocol.requireReauthentication(userSessionModel, context.getAuthenticationSession()); + } +} diff --git a/src/test/java/de/sventorben/keycloak/authentication/hidpd/LoginPageTest.java b/src/test/java/de/sventorben/keycloak/authentication/hidpd/LoginPageTest.java index 9303648..e39f7cd 100755 --- a/src/test/java/de/sventorben/keycloak/authentication/hidpd/LoginPageTest.java +++ b/src/test/java/de/sventorben/keycloak/authentication/hidpd/LoginPageTest.java @@ -28,6 +28,9 @@ class LoginPageTest { @Mock(answer = Answers.RETURNS_DEEP_STUBS) AuthenticationFlowContext context; + @Mock + Reauthentication reauthentication; + @InjectMocks LoginPage cut; @@ -67,6 +70,14 @@ void givenSamlAuthnIsForcedThenDoNotBypassLogin() { assertThat(shouldByPass).isFalse(); } + @Test + @DisplayName("and given reauthentication is required, then should not bypass login") + void givenReauthenticationIsRequiredThenDoNotBypassLogin() { + given(reauthentication.required()).willReturn(true); + boolean shouldByPass = cut.shouldByPass(); + assertThat(shouldByPass).isFalse(); + } + @Test @DisplayName("then should bypass login") void thenShouldBypassLogin() {