diff --git a/docs/docusaurus/docs/07-developers/10-tutorials/09-email-otp-for-imported-voters.md b/docs/docusaurus/docs/07-developers/10-tutorials/09-email-otp-for-imported-voters.md new file mode 100644 index 00000000000..cb19624e564 --- /dev/null +++ b/docs/docusaurus/docs/07-developers/10-tutorials/09-email-otp-for-imported-voters.md @@ -0,0 +1,120 @@ +--- +id: email_otp_for_imported_voters +title: Email OTP for Imported Voters +--- + + + +# Email OTP for Imported Voters + +This tutorial explains how to make imported voters receive a one-time password by +email at login, without asking them to enrol anything first. It uses the +**Auto-create OTP credential** option (`autoCreateCredentialAttribute`) of the +`message-otp-authenticator` Keycloak extension. + +## Why this option exists + +The OTP step is normally guarded by Keycloak's **Condition - user configured**, +which asks the authenticator whether the voter is configured for it. Historically +that answer was `false` unless the voter had a stored `message-otp` credential. + +Voters imported or edited through the admin portal get their email and mobile +attributes set, but no `message-otp` credential is ever stored for them. The +result is that the OTP sub-flow is silently skipped: the voter signs in with the +password alone, and no second factor is ever requested. In flows where the OTP is +the only alternative left, the login instead fails with a generic +`invalid_user_credentials` error. + +With **Auto-create OTP credential** enabled, the authenticator creates the +`message-otp` credential during login for any non-deferred voter that already has +an email address or a mobile number, so the OTP step runs on the voter's very +first login. + +The option is **disabled by default**: enabling it is an explicit decision per +authentication flow. + +## Prerequisites + +- Voters imported with an **email address** (or a mobile number for SMS). +- A working email sender for the realm. In the development environment the dummy + sender writes the message to the Keycloak log instead of sending it. +- Access to the Keycloak admin console for the election event realm. + +## Step 1: add the OTP sub-flow to the browser flow + +In the election event realm, open **Authentication** and select the browser flow +used by the event (`sequent browser flow`). The OTP step lives in a +**Conditional** sub-flow that contains **Condition - user configured** followed by +**OTP - Message via Email/SMS**, both **Required**, placed after the username and +password step: + +![Browser flow with the message OTP sub-flow](./assets/otp_auto_credential_browser_flow.png) + +## Step 2: enable the option on the authenticator + +Open the settings (the gear icon) of the **OTP - Message via Email/SMS** step and +set: + +- **Message Courier**: `EMAIL` (use `BOTH` or `SMS` if you also deliver by SMS). +- **Auto-create OTP credential**: **On**. +- **Use Deferred User**: **Off**. Deferred voters take their address from the + authentication session and never need a stored credential, so this option does + not apply to them. + +![Auto-create OTP credential enabled](./assets/otp_auto_credential_config.png) + +Save the dialog. No voter re-import or edit is required, and no credential +enrolment required action needs to be assigned. + +## Step 3: the voter signs in + +The voter signs in with their username and password as usual: + +![Voter login form](./assets/otp_auto_credential_voter_login.png) + +Because the voter has an email address configured, the OTP step now runs on this +first login and the code is emailed to them: + +![OTP prompt showing the masked email address](./assets/otp_auto_credential_voter_otp.png) + +Entering the emailed code completes the login. + +In the development environment the message is written to the Keycloak log rather +than sent, so you can read the code with: + +```bash +docker logs keycloak --since 2m 2>&1 | grep -A6 "Sending dummy email" +``` + +## Step 4: verify the credential was created + +Open the voter in **Users → Credentials**. The `message-otp` credential now exists +alongside the password, created at the moment of that first login: + +![Voter credentials including message-otp](./assets/otp_auto_credential_user_credentials.png) + +The credential is created once. Later logins reuse it, so the OTP step keeps +working even if the option is turned off afterwards. + +## Behaviour summary + +| Situation | Option off | Option on | +| --- | --- | --- | +| Voter with email or mobile, no stored credential | OTP step skipped | Credential created, OTP sent | +| Voter with a stored `message-otp` credential | OTP sent | OTP sent | +| Voter with neither email nor mobile | OTP step skipped | OTP step skipped | +| Deferred voter (**Use Deferred User** on) | Address taken from the authentication session | Unchanged, no credential stored | + +## Notes + +- The option only decides whether the voter is *offered* the OTP step. It does not + weaken the check itself: the emailed code is still generated, delivered, and + validated exactly as before. +- A voter without an email address and without a mobile number is never considered + configured, so enabling the option cannot lock anybody into a step they cannot + complete. +- Because the credential is created during login, turning the option on affects + voters gradually as they sign in, rather than in one migration. diff --git a/docs/docusaurus/docs/07-developers/10-tutorials/assets/otp_auto_credential_browser_flow.png b/docs/docusaurus/docs/07-developers/10-tutorials/assets/otp_auto_credential_browser_flow.png new file mode 100644 index 00000000000..f8198afce74 Binary files /dev/null and b/docs/docusaurus/docs/07-developers/10-tutorials/assets/otp_auto_credential_browser_flow.png differ diff --git a/docs/docusaurus/docs/07-developers/10-tutorials/assets/otp_auto_credential_config.png b/docs/docusaurus/docs/07-developers/10-tutorials/assets/otp_auto_credential_config.png new file mode 100644 index 00000000000..926786eedf0 Binary files /dev/null and b/docs/docusaurus/docs/07-developers/10-tutorials/assets/otp_auto_credential_config.png differ diff --git a/docs/docusaurus/docs/07-developers/10-tutorials/assets/otp_auto_credential_user_credentials.png b/docs/docusaurus/docs/07-developers/10-tutorials/assets/otp_auto_credential_user_credentials.png new file mode 100644 index 00000000000..40121801e12 Binary files /dev/null and b/docs/docusaurus/docs/07-developers/10-tutorials/assets/otp_auto_credential_user_credentials.png differ diff --git a/docs/docusaurus/docs/07-developers/10-tutorials/assets/otp_auto_credential_voter_login.png b/docs/docusaurus/docs/07-developers/10-tutorials/assets/otp_auto_credential_voter_login.png new file mode 100644 index 00000000000..4174de86f69 Binary files /dev/null and b/docs/docusaurus/docs/07-developers/10-tutorials/assets/otp_auto_credential_voter_login.png differ diff --git a/docs/docusaurus/docs/07-developers/10-tutorials/assets/otp_auto_credential_voter_otp.png b/docs/docusaurus/docs/07-developers/10-tutorials/assets/otp_auto_credential_voter_otp.png new file mode 100644 index 00000000000..cb095d932ab Binary files /dev/null and b/docs/docusaurus/docs/07-developers/10-tutorials/assets/otp_auto_credential_voter_otp.png differ diff --git a/packages/keycloak-extensions/message-otp-authenticator/pom.xml b/packages/keycloak-extensions/message-otp-authenticator/pom.xml index 6f6d2682974..cafe326e280 100644 --- a/packages/keycloak-extensions/message-otp-authenticator/pom.xml +++ b/packages/keycloak-extensions/message-otp-authenticator/pom.xml @@ -89,6 +89,18 @@ SPDX-License-Identifier: AGPL-3.0-only 10.9.2 compile + + org.junit.jupiter + junit-jupiter + 6.0.3 + test + + + org.mockito + mockito-core + 5.20.0 + test + diff --git a/packages/keycloak-extensions/message-otp-authenticator/src/main/java/sequent/keycloak/authenticator/MessageOTPAuthenticator.java b/packages/keycloak-extensions/message-otp-authenticator/src/main/java/sequent/keycloak/authenticator/MessageOTPAuthenticator.java index 8d0aa39bf92..110c2152342 100644 --- a/packages/keycloak-extensions/message-otp-authenticator/src/main/java/sequent/keycloak/authenticator/MessageOTPAuthenticator.java +++ b/packages/keycloak-extensions/message-otp-authenticator/src/main/java/sequent/keycloak/authenticator/MessageOTPAuthenticator.java @@ -367,33 +367,53 @@ public boolean requiresUser() { @Override public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { log.info("configuredFor() called"); - MessageOTPCredentialProvider provider = getCredentialProvider(session); - if (provider == null || !provider.isConfiguredFor(realm, user, getType(session))) { - return false; - } - Optional config = Utils.getConfig(realm); + Map configMap = + MessageOTPAuthenticatorFactory.getConfigMap(config.orElse(null)); + boolean deferredUser = "true".equals(configMap.get(Utils.DEFERRED_USER_ATTRIBUTE)); - // If no configuration is found, fall back to default behavior - if (!config.isPresent() && user != null) { - return user.getFirstAttribute(MOBILE_NUMBER_FIELD) != null; - } - boolean deferredUser = - "true".equals(config.get().getConfig().get(Utils.DEFERRED_USER_ATTRIBUTE)); String mobileNumber = null; String emailAddress = null; - if (deferredUser) { AuthenticationSessionModel authSession = session.getContext().getAuthenticationSession(); - String mobileNumberAttribute = config.get().getConfig().get(Utils.TEL_USER_ATTRIBUTE); + String mobileNumberAttribute = configMap.get(Utils.TEL_USER_ATTRIBUTE); mobileNumber = authSession.getAuthNote(mobileNumberAttribute); emailAddress = authSession.getAuthNote("email"); } else if (user != null) { - mobileNumber = Utils.getMobile(config.get(), user); - emailAddress = user.getEmail(); + mobileNumber = Utils.getMobile(config.orElse(null), user); + emailAddress = config.isPresent() ? user.getEmail() : null; + } + boolean hasOtpAddress = mobileNumber != null || emailAddress != null; + + // In deferred mode the OTP address comes from the auth session notes (set during + // deferred registration/login), not from a stored credential, so a + // MessageOTPCredential must not be required. Otherwise, deferred users (which + // never get a credential created for them) would silently be filtered out of the + // authentication selection list, failing the flow with `invalid_user_credentials`. + if (!deferredUser) { + if (user == null) { + return false; + } + MessageOTPCredentialProvider provider = getCredentialProvider(session); + if (provider == null) { + return false; + } + if (!provider.isConfiguredFor(realm, user, getType(session))) { + // If enabled, automatically create the message-otp credential for users + // that have a mobile phone number or email address configured, so that + // imported/edited voters don't need a credential enrollment required action. + boolean autoCreateCredential = + "true".equals(configMap.get(Utils.AUTO_CREATE_CREDENTIAL_ATTRIBUTE)); + if (!autoCreateCredential || !hasOtpAddress) { + return false; + } + log.info("Auto-creating MessageOTPCredential for user with configured mobile/email"); + provider.createCredential( + realm, user, MessageOTPCredentialModel.create(/* isSetup= */ true)); + } } - return mobileNumber != null || emailAddress != null; + return hasOtpAddress; } @Override diff --git a/packages/keycloak-extensions/message-otp-authenticator/src/main/java/sequent/keycloak/authenticator/MessageOTPAuthenticatorFactory.java b/packages/keycloak-extensions/message-otp-authenticator/src/main/java/sequent/keycloak/authenticator/MessageOTPAuthenticatorFactory.java index 59ca84617ad..f807d79567c 100644 --- a/packages/keycloak-extensions/message-otp-authenticator/src/main/java/sequent/keycloak/authenticator/MessageOTPAuthenticatorFactory.java +++ b/packages/keycloak-extensions/message-otp-authenticator/src/main/java/sequent/keycloak/authenticator/MessageOTPAuthenticatorFactory.java @@ -144,6 +144,14 @@ public List getConfigProperties() { "If enabled, there won't be a need to have a valid user when using this authenticator", ProviderConfigProperty.BOOLEAN_TYPE, "false"), + new ProviderConfigProperty( + Utils.AUTO_CREATE_CREDENTIAL_ATTRIBUTE, + "Auto-create OTP credential", + "If enabled, the message-otp credential is automatically created during login for" + + " non-deferred users that have a mobile phone number or email address configured," + + " so that no credential enrollment required action is needed.", + ProviderConfigProperty.BOOLEAN_TYPE, + "false"), new ProviderConfigProperty( Utils.OTL_RESTORED_AUTH_NOTES_ATTRIBUTE, "Comma Separated Names of the Auth Notes to Restore", diff --git a/packages/keycloak-extensions/message-otp-authenticator/src/main/java/sequent/keycloak/authenticator/Utils.java b/packages/keycloak-extensions/message-otp-authenticator/src/main/java/sequent/keycloak/authenticator/Utils.java index 0b2ce4344ba..1fb087dad5f 100644 --- a/packages/keycloak-extensions/message-otp-authenticator/src/main/java/sequent/keycloak/authenticator/Utils.java +++ b/packages/keycloak-extensions/message-otp-authenticator/src/main/java/sequent/keycloak/authenticator/Utils.java @@ -85,6 +85,7 @@ public class Utils { public final String TEL_USER_ATTRIBUTE = "telUserAttribute"; public final String MESSAGE_COURIER_ATTRIBUTE = "messageCourierAttribute"; public final String DEFERRED_USER_ATTRIBUTE = "deferredUserAttribute"; + public final String AUTO_CREATE_CREDENTIAL_ATTRIBUTE = "autoCreateCredentialAttribute"; public final String OTL_RESTORED_AUTH_NOTES_ATTRIBUTE = "otlRestoredAuthNotesAttribute"; public final String SEND_CODE_SMS_I18N_KEY = "messageOtp.sendCode.sms.text"; diff --git a/packages/keycloak-extensions/message-otp-authenticator/src/test/java/sequent/keycloak/authenticator/MessageOTPAuthenticatorTest.java b/packages/keycloak-extensions/message-otp-authenticator/src/test/java/sequent/keycloak/authenticator/MessageOTPAuthenticatorTest.java new file mode 100644 index 00000000000..9d06a2257b6 --- /dev/null +++ b/packages/keycloak-extensions/message-otp-authenticator/src/test/java/sequent/keycloak/authenticator/MessageOTPAuthenticatorTest.java @@ -0,0 +1,219 @@ +// SPDX-FileCopyrightText: 2026 Sequent Tech +// +// SPDX-License-Identifier: AGPL-3.0-only +package sequent.keycloak.authenticator; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.keycloak.credential.CredentialModel; +import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.models.AuthenticationFlowModel; +import org.keycloak.models.AuthenticatorConfigModel; +import org.keycloak.models.KeycloakContext; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.SubjectCredentialManager; +import org.keycloak.models.UserModel; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.sessions.AuthenticationSessionModel; +import org.mockito.ArgumentCaptor; +import sequent.keycloak.authenticator.credential.MessageOTPCredentialModel; + +class MessageOTPAuthenticatorTest { + + private static final String FLOW_ID = "flow-id"; + private static final String CONFIG_ID = "config-id"; + private static final String PHONE_ATTRIBUTE = MessageOTPAuthenticator.MOBILE_NUMBER_FIELD; + private static final String PHONE_NUMBER = "+34600000000"; + private static final String EMAIL = "voter@example.com"; + + private final MessageOTPAuthenticator authenticator = new MessageOTPAuthenticator(); + private final KeycloakSession session = mock(KeycloakSession.class); + + private Map baseConfig() { + Map config = new HashMap<>(); + config.put(Utils.TEL_USER_ATTRIBUTE, PHONE_ATTRIBUTE); + return config; + } + + /** + * Builds a realm mock whose authentication flows contain a single message-otp-authenticator + * execution with the given config, so that {@code Utils.getConfig(realm)} finds it. + */ + private RealmModel mockRealm(Map configMap) { + RealmModel realm = mock(RealmModel.class); + AuthenticationFlowModel flow = new AuthenticationFlowModel(); + flow.setId(FLOW_ID); + AuthenticationExecutionModel execution = new AuthenticationExecutionModel(); + execution.setAuthenticator(MessageOTPAuthenticatorFactory.PROVIDER_ID); + execution.setAuthenticatorConfig(CONFIG_ID); + AuthenticatorConfigModel config = new AuthenticatorConfigModel(); + config.setId(CONFIG_ID); + config.setConfig(configMap); + when(realm.getAuthenticationFlowsStream()).thenAnswer(invocation -> Stream.of(flow)); + when(realm.getAuthenticationExecutionsStream(FLOW_ID)) + .thenAnswer(invocation -> Stream.of(execution)); + when(realm.getAuthenticatorConfigById(CONFIG_ID)).thenReturn(config); + return realm; + } + + private UserModel mockUser( + String mobileNumber, + String email, + boolean hasCredential, + SubjectCredentialManager credentialManager) { + UserModel user = mock(UserModel.class); + when(user.getFirstAttribute(PHONE_ATTRIBUTE)).thenReturn(mobileNumber); + when(user.getEmail()).thenReturn(email); + when(user.credentialManager()).thenReturn(credentialManager); + when(credentialManager.getStoredCredentialsByTypeStream(MessageOTPCredentialModel.TYPE)) + .thenAnswer( + invocation -> + hasCredential ? Stream.of(new CredentialModel()) : Stream.empty()); + when(credentialManager.createStoredCredential(any(CredentialModel.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + return user; + } + + @Test + void autoCreateDisabledByDefaultDoesNotCreateCredentialAndReturnsFalse() { + RealmModel realm = mockRealm(baseConfig()); + SubjectCredentialManager credentialManager = mock(SubjectCredentialManager.class); + UserModel user = mockUser(PHONE_NUMBER, EMAIL, /* hasCredential= */ false, credentialManager); + + assertFalse(authenticator.configuredFor(session, realm, user)); + verify(credentialManager, never()).createStoredCredential(any(CredentialModel.class)); + } + + @Test + void autoCreateEnabledCreatesCredentialForUserWithMobile() { + Map config = baseConfig(); + config.put(Utils.AUTO_CREATE_CREDENTIAL_ATTRIBUTE, "true"); + RealmModel realm = mockRealm(config); + SubjectCredentialManager credentialManager = mock(SubjectCredentialManager.class); + UserModel user = mockUser(PHONE_NUMBER, null, /* hasCredential= */ false, credentialManager); + + assertTrue(authenticator.configuredFor(session, realm, user)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(CredentialModel.class); + verify(credentialManager).createStoredCredential(captor.capture()); + assertEquals(MessageOTPCredentialModel.TYPE, captor.getValue().getType()); + } + + @Test + void autoCreateEnabledCreatesCredentialForUserWithEmailOnly() { + Map config = baseConfig(); + config.put(Utils.AUTO_CREATE_CREDENTIAL_ATTRIBUTE, "true"); + RealmModel realm = mockRealm(config); + SubjectCredentialManager credentialManager = mock(SubjectCredentialManager.class); + UserModel user = mockUser(null, EMAIL, /* hasCredential= */ false, credentialManager); + + assertTrue(authenticator.configuredFor(session, realm, user)); + verify(credentialManager).createStoredCredential(any(CredentialModel.class)); + } + + @Test + void autoCreateEnabledWithoutMobileOrEmailReturnsFalse() { + Map config = baseConfig(); + config.put(Utils.AUTO_CREATE_CREDENTIAL_ATTRIBUTE, "true"); + RealmModel realm = mockRealm(config); + SubjectCredentialManager credentialManager = mock(SubjectCredentialManager.class); + UserModel user = mockUser(null, null, /* hasCredential= */ false, credentialManager); + + assertFalse(authenticator.configuredFor(session, realm, user)); + verify(credentialManager, never()).createStoredCredential(any(CredentialModel.class)); + } + + @Test + void existingCredentialReturnsTrueWithoutCreatingAnother() { + Map config = baseConfig(); + config.put(Utils.AUTO_CREATE_CREDENTIAL_ATTRIBUTE, "true"); + RealmModel realm = mockRealm(config); + SubjectCredentialManager credentialManager = mock(SubjectCredentialManager.class); + UserModel user = mockUser(PHONE_NUMBER, EMAIL, /* hasCredential= */ true, credentialManager); + + assertTrue(authenticator.configuredFor(session, realm, user)); + verify(credentialManager, never()).createStoredCredential(any(CredentialModel.class)); + } + + @Test + void nullUserReturnsFalseWhenNotDeferred() { + Map config = baseConfig(); + config.put(Utils.AUTO_CREATE_CREDENTIAL_ATTRIBUTE, "true"); + RealmModel realm = mockRealm(config); + + assertFalse(authenticator.configuredFor(session, realm, null)); + } + + /** + * Deferred mode relies on auth notes and must never require nor create a stored credential, + * regardless of the auto-create credential setting. + */ + private void assertDeferredUserWorksWithAutoCreateSetting(String autoCreateValue) { + Map config = baseConfig(); + config.put(Utils.DEFERRED_USER_ATTRIBUTE, "true"); + config.put(Utils.AUTO_CREATE_CREDENTIAL_ATTRIBUTE, autoCreateValue); + RealmModel realm = mockRealm(config); + + KeycloakContext context = mock(KeycloakContext.class); + AuthenticationSessionModel authSession = mock(AuthenticationSessionModel.class); + when(session.getContext()).thenReturn(context); + when(context.getAuthenticationSession()).thenReturn(authSession); + when(authSession.getAuthNote(PHONE_ATTRIBUTE)).thenReturn(PHONE_NUMBER); + + SubjectCredentialManager credentialManager = mock(SubjectCredentialManager.class); + UserModel user = mockUser(null, null, /* hasCredential= */ false, credentialManager); + + assertTrue(authenticator.configuredFor(session, realm, user)); + verify(credentialManager, never()).createStoredCredential(any(CredentialModel.class)); + } + + @Test + void deferredUserWorksWithAutoCreateEnabledAndNeverCreatesCredential() { + assertDeferredUserWorksWithAutoCreateSetting("true"); + } + + @Test + void deferredUserWorksWithAutoCreateDisabledAndNeverCreatesCredential() { + assertDeferredUserWorksWithAutoCreateSetting("false"); + } + + @Test + void deferredUserWithoutAuthNotesReturnsFalse() { + Map config = baseConfig(); + config.put(Utils.DEFERRED_USER_ATTRIBUTE, "true"); + RealmModel realm = mockRealm(config); + + KeycloakContext context = mock(KeycloakContext.class); + AuthenticationSessionModel authSession = mock(AuthenticationSessionModel.class); + when(session.getContext()).thenReturn(context); + when(context.getAuthenticationSession()).thenReturn(authSession); + + assertFalse(authenticator.configuredFor(session, realm, null)); + } + + @Test + void autoCreateCredentialFactoryPropertyIsDisabledByDefault() { + Optional property = + new MessageOTPAuthenticatorFactory() + .getConfigProperties().stream() + .filter(prop -> Utils.AUTO_CREATE_CREDENTIAL_ATTRIBUTE.equals(prop.getName())) + .findFirst(); + + assertTrue(property.isPresent()); + assertEquals(ProviderConfigProperty.BOOLEAN_TYPE, property.get().getType()); + assertEquals("false", property.get().getDefaultValue()); + } +}