From eca0530c07400feeb8ca5be190fa89e9ae33cfbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=B3th=20L=C3=A1szl=C3=B3?= Date: Thu, 8 Aug 2024 19:56:59 +0200 Subject: [PATCH] ConditionalEmailAuthenticator from ConditionalOtpFormAuthenticator --- .../ConditionalEmailAuthenticatorForm.java | 205 ++++++++++++++++++ ...ditionalEmailAuthenticatorFormFactory.java | 109 ++++++++++ ...ycloak.authentication.AuthenticatorFactory | 1 + 3 files changed, 315 insertions(+) create mode 100644 src/main/java/com/mesutpiskin/keycloak/auth/email/ConditionalEmailAuthenticatorForm.java create mode 100644 src/main/java/com/mesutpiskin/keycloak/auth/email/ConditionalEmailAuthenticatorFormFactory.java diff --git a/src/main/java/com/mesutpiskin/keycloak/auth/email/ConditionalEmailAuthenticatorForm.java b/src/main/java/com/mesutpiskin/keycloak/auth/email/ConditionalEmailAuthenticatorForm.java new file mode 100644 index 0000000..aed169d --- /dev/null +++ b/src/main/java/com/mesutpiskin/keycloak/auth/email/ConditionalEmailAuthenticatorForm.java @@ -0,0 +1,205 @@ +package com.mesutpiskin.keycloak.auth.email; + +import static com.mesutpiskin.keycloak.auth.email.ConditionalEmailAuthenticatorForm.OtpDecision.ABSTAIN; +import static com.mesutpiskin.keycloak.auth.email.ConditionalEmailAuthenticatorForm.OtpDecision.SHOW_OTP; +import static com.mesutpiskin.keycloak.auth.email.ConditionalEmailAuthenticatorForm.OtpDecision.SKIP_OTP; +import static org.keycloak.models.utils.KeycloakModelUtils.getRoleFromString; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.regex.Pattern; + +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.models.RealmModel; +import org.keycloak.models.RoleModel; +import org.keycloak.models.UserModel; + +import jakarta.ws.rs.core.MultivaluedMap; + +public class ConditionalEmailAuthenticatorForm extends EmailAuthenticatorForm { + + public static final String SKIP = "skip"; + + public static final String FORCE = "force"; + + public static final String OTP_CONTROL_USER_ATTRIBUTE = "otpControlAttribute"; + + public static final String SKIP_OTP_ROLE = "skipOtpRole"; + + public static final String FORCE_OTP_ROLE = "forceOtpRole"; + + public static final String SKIP_OTP_FOR_HTTP_HEADER = "noOtpRequiredForHeaderPattern"; + + public static final String FORCE_OTP_FOR_HTTP_HEADER = "forceOtpForHeaderPattern"; + + public static final String DEFAULT_OTP_OUTCOME = "defaultOtpOutcome"; + + enum OtpDecision { + SKIP_OTP, SHOW_OTP, ABSTAIN + } + + @Override + public void authenticate(AuthenticationFlowContext context) { + + Map config = context.getAuthenticatorConfig().getConfig(); + + if (tryConcludeBasedOn(voteForUserOtpControlAttribute(context.getUser(), config), context)) { + return; + } + + if (tryConcludeBasedOn(voteForUserRole(context.getRealm(), context.getUser(), config), context)) { + return; + } + + if (tryConcludeBasedOn(voteForHttpHeaderMatchesPattern(context.getHttpRequest().getHttpHeaders().getRequestHeaders(), config), context)) { + return; + } + + if (tryConcludeBasedOn(voteForDefaultFallback(config), context)) { + return; + } + + showOtpForm(context); + } + + private OtpDecision voteForDefaultFallback(Map config) { + + if (!config.containsKey(DEFAULT_OTP_OUTCOME)) { + return ABSTAIN; + } + + switch (config.get(DEFAULT_OTP_OUTCOME)) { + case SKIP: + return SKIP_OTP; + case FORCE: + return SHOW_OTP; + default: + return ABSTAIN; + } + } + + private boolean tryConcludeBasedOn(OtpDecision state, AuthenticationFlowContext context) { + + switch (state) { + + case SHOW_OTP: + showOtpForm(context); + return true; + + case SKIP_OTP: + context.success(); + return true; + + default: + return false; + } + } + + private void showOtpForm(AuthenticationFlowContext context) { + super.authenticate(context); + } + + private OtpDecision voteForUserOtpControlAttribute(UserModel user, Map config) { + + if (!config.containsKey(OTP_CONTROL_USER_ATTRIBUTE)) { + return ABSTAIN; + } + + String attributeName = config.get(OTP_CONTROL_USER_ATTRIBUTE); + if (attributeName == null) { + return ABSTAIN; + } + + Optional value = user.getAttributeStream(attributeName).findFirst(); + if (!value.isPresent()) { + return ABSTAIN; + } + + switch (value.get().trim()) { + case SKIP: + return SKIP_OTP; + case FORCE: + return SHOW_OTP; + default: + return ABSTAIN; + } + } + + private OtpDecision voteForHttpHeaderMatchesPattern(MultivaluedMap requestHeaders, Map config) { + + if (!config.containsKey(FORCE_OTP_FOR_HTTP_HEADER) && !config.containsKey(SKIP_OTP_FOR_HTTP_HEADER)) { + return ABSTAIN; + } + + //Inverted to allow white-lists, e.g. for specifying trusted remote hosts: X-Forwarded-Host: (1.2.3.4|1.2.3.5) + if (containsMatchingRequestHeader(requestHeaders, config.get(SKIP_OTP_FOR_HTTP_HEADER))) { + return SKIP_OTP; + } + + if (containsMatchingRequestHeader(requestHeaders, config.get(FORCE_OTP_FOR_HTTP_HEADER))) { + return SHOW_OTP; + } + + return ABSTAIN; + } + + private boolean containsMatchingRequestHeader(MultivaluedMap requestHeaders, String headerPattern) { + + if (headerPattern == null) { + return false; + } + + //TODO cache RequestHeader Patterns + //TODO how to deal with pattern syntax exceptions? + // need CASE_INSENSITIVE flag so that we also have matches when the underlying container use a different case than what + // is usually expected (e.g.: vertx) + Pattern pattern = Pattern.compile(headerPattern, Pattern.DOTALL | Pattern.CASE_INSENSITIVE); + + for (Map.Entry> entry : requestHeaders.entrySet()) { + + String key = entry.getKey(); + + for (String value : entry.getValue()) { + + String headerEntry = key.trim() + ": " + value.trim(); + + if (pattern.matcher(headerEntry).matches()) { + return true; + } + } + } + + return false; + } + + private OtpDecision voteForUserRole(RealmModel realm, UserModel user, Map config) { + + if (!config.containsKey(SKIP_OTP_ROLE) && !config.containsKey(FORCE_OTP_ROLE)) { + return ABSTAIN; + } + + if (userHasRole(realm, user, config.get(SKIP_OTP_ROLE))) { + return SKIP_OTP; + } + + if (userHasRole(realm, user, config.get(FORCE_OTP_ROLE))) { + return SHOW_OTP; + } + + return ABSTAIN; + } + + private boolean userHasRole(RealmModel realm, UserModel user, String roleName) { + + if (roleName == null) { + return false; + } + + RoleModel role = getRoleFromString(realm, roleName); + if (role != null) { + return user.hasRole(role); + } + return false; + } +} diff --git a/src/main/java/com/mesutpiskin/keycloak/auth/email/ConditionalEmailAuthenticatorFormFactory.java b/src/main/java/com/mesutpiskin/keycloak/auth/email/ConditionalEmailAuthenticatorFormFactory.java new file mode 100644 index 0000000..7df7935 --- /dev/null +++ b/src/main/java/com/mesutpiskin/keycloak/auth/email/ConditionalEmailAuthenticatorFormFactory.java @@ -0,0 +1,109 @@ +package com.mesutpiskin.keycloak.auth.email; + +import static java.util.Arrays.asList; +import static org.keycloak.authentication.authenticators.browser.ConditionalOtpFormAuthenticator.DEFAULT_OTP_OUTCOME; +import static org.keycloak.authentication.authenticators.browser.ConditionalOtpFormAuthenticator.FORCE; +import static org.keycloak.authentication.authenticators.browser.ConditionalOtpFormAuthenticator.FORCE_OTP_FOR_HTTP_HEADER; +import static org.keycloak.authentication.authenticators.browser.ConditionalOtpFormAuthenticator.FORCE_OTP_ROLE; +import static org.keycloak.authentication.authenticators.browser.ConditionalOtpFormAuthenticator.OTP_CONTROL_USER_ATTRIBUTE; +import static org.keycloak.authentication.authenticators.browser.ConditionalOtpFormAuthenticator.SKIP; +import static org.keycloak.authentication.authenticators.browser.ConditionalOtpFormAuthenticator.SKIP_OTP_FOR_HTTP_HEADER; +import static org.keycloak.authentication.authenticators.browser.ConditionalOtpFormAuthenticator.SKIP_OTP_ROLE; +import static org.keycloak.provider.ProviderConfigProperty.LIST_TYPE; +import static org.keycloak.provider.ProviderConfigProperty.ROLE_TYPE; +import static org.keycloak.provider.ProviderConfigProperty.STRING_TYPE; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.keycloak.authentication.Authenticator; +import org.keycloak.models.KeycloakSession; +import org.keycloak.provider.ProviderConfigProperty; + +public class ConditionalEmailAuthenticatorFormFactory extends EmailAuthenticatorFormFactory { + + public static final String PROVIDER_ID = "email-conditional-authenticator"; + public static final ConditionalEmailAuthenticatorForm SINGLETON = new ConditionalEmailAuthenticatorForm(); + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public String getDisplayType() { + return "Conditional Email OTP"; + } + + @Override + public String getHelpText() { + return "Conditional Email otp authenticator."; + } + + @Override + public List getConfigProperties() { + List list = new ArrayList<>(super.getConfigProperties()); + + ProviderConfigProperty forceOtpUserAttribute = new ProviderConfigProperty(); + forceOtpUserAttribute.setType(STRING_TYPE); + forceOtpUserAttribute.setName(OTP_CONTROL_USER_ATTRIBUTE); + forceOtpUserAttribute.setLabel("OTP control User Attribute"); + forceOtpUserAttribute.setHelpText("The name of the user attribute to explicitly control OTP auth. " + + "If attribute value is 'force' then OTP is always required. " + + "If value is 'skip' the OTP auth is skipped. Otherwise this check is ignored."); + list.add(forceOtpUserAttribute); + + ProviderConfigProperty skipOtpRole = new ProviderConfigProperty(); + skipOtpRole.setType(ROLE_TYPE); + skipOtpRole.setName(SKIP_OTP_ROLE); + skipOtpRole.setLabel("Skip OTP for Role"); + skipOtpRole.setHelpText("OTP is always skipped if user has the given Role."); + list.add(skipOtpRole); + + ProviderConfigProperty forceOtpRole = new ProviderConfigProperty(); + forceOtpRole.setType(ROLE_TYPE); + forceOtpRole.setName(FORCE_OTP_ROLE); + forceOtpRole.setLabel("Force OTP for Role"); + forceOtpRole.setHelpText("OTP is always required if user has the given Role."); + list.add(forceOtpRole); + + ProviderConfigProperty skipOtpForHttpHeader = new ProviderConfigProperty(); + skipOtpForHttpHeader.setType(STRING_TYPE); + skipOtpForHttpHeader.setName(SKIP_OTP_FOR_HTTP_HEADER); + skipOtpForHttpHeader.setLabel("Skip OTP for Header"); + skipOtpForHttpHeader.setHelpText("OTP is skipped if a HTTP request header does matches the given pattern." + + "Can be used to specify trusted networks via: X-Forwarded-Host: (1.2.3.4|1.2.3.5)." + + "In this case requests from 1.2.3.4 and 1.2.3.5 come from a trusted source."); + skipOtpForHttpHeader.setDefaultValue(""); + list.add(skipOtpForHttpHeader); + + ProviderConfigProperty forceOtpForHttpHeader = new ProviderConfigProperty(); + forceOtpForHttpHeader.setType(STRING_TYPE); + forceOtpForHttpHeader.setName(FORCE_OTP_FOR_HTTP_HEADER); + forceOtpForHttpHeader.setLabel("Force OTP for Header"); + forceOtpForHttpHeader.setHelpText("OTP required if a HTTP request header matches the given pattern."); + forceOtpForHttpHeader.setDefaultValue(""); + list.add(forceOtpForHttpHeader); + + ProviderConfigProperty defaultOutcome = new ProviderConfigProperty(); + defaultOutcome.setType(LIST_TYPE); + defaultOutcome.setName(DEFAULT_OTP_OUTCOME); + defaultOutcome.setLabel("Fallback OTP handling"); + defaultOutcome.setOptions(asList(SKIP, FORCE)); + defaultOutcome.setHelpText("What to do in case of every check abstains. Defaults to force OTP authentication."); + list.add(defaultOutcome); + + return Collections.unmodifiableList(list); + } + + @Override + public boolean isUserSetupAllowed() { + return true; + } + + @Override + public Authenticator create(KeycloakSession session) { + return SINGLETON; + } +} diff --git a/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory b/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory index d3d741b..bda24e0 100644 --- a/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory +++ b/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory @@ -1 +1,2 @@ com.mesutpiskin.keycloak.auth.email.EmailAuthenticatorFormFactory +com.mesutpiskin.keycloak.auth.email.ConditionalEmailAuthenticatorFormFactory \ No newline at end of file