diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/browser/ConditionalOtpFormAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/browser/ConditionalOtpFormAuthenticator.java new file mode 100644 index 0000000000..02aaa01a9f --- /dev/null +++ b/services/src/main/java/org/keycloak/authentication/authenticators/browser/ConditionalOtpFormAuthenticator.java @@ -0,0 +1,234 @@ +package org.keycloak.authentication.authenticators.browser; + +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.models.RoleModel; +import org.keycloak.models.UserModel; + +import javax.ws.rs.core.MultivaluedMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; + +import static org.keycloak.authentication.authenticators.browser.ConditionalOtpFormAuthenticator.OtpDecision.*; +import static org.keycloak.models.utils.KeycloakModelUtils.getRoleFromString; +import static org.keycloak.models.utils.KeycloakModelUtils.hasRole; + +/** + * An {@link OTPFormAuthenticator} that can conditionally require OTP authentication. + *

+ *

+ * The decision for whether or not to require OTP authentication can be made based on multiple conditions + * which are evaluated in the following order. The first matching condition determines the outcome. + *

+ *
    + *
  1. User Attribute
  2. + *
  3. Role
  4. + *
  5. Request Header
  6. + *
  7. Configured Default
  8. + *
+ *

+ * If no condition matches, the {@link ConditionalOtpFormAuthenticator} fallback is to require OTP authentication. + *

+ *

+ *

User Attribute

+ * A User Attribute like otp_auth can be used to control OTP authentication on individual user level. + * The supported values are skip and force. If the value is set to skip then the OTP auth is skipped for the user, + * otherwise if the value is force then the OTP auth is enforced. The setting is ignored for any other value. + *

+ *

+ *

Role

+ * A role can be used to control the OTP authentication. If the user has the specified role the OTP authentication is forced. + * Otherwise if no role is selected the setting is ignored. + *

+ *

+ *

+ *

Request Header

+ *

+ * Request Headers are matched via regex {@link Pattern}s and can be specified as a whitelist and blacklist. + * No OTP for Header specifies the pattern for which OTP authentication is not required. + * This can be used to specify trusted networks, e.g. via: X-Forwarded-Host: (1.2.3.4|1.2.3.5) where + * The IPs 1.2.3.4, 1.2.3.5 denote trusted machines. + * Force OTP for Header specifies the pattern for which OTP authentication is required. Whitelist entries take + * precedence before blacklist entries. + *

+ *

+ *

Configured Default

+ * A default fall-though behaviour can be specified to handle cases where all previous conditions did not lead to a conclusion. + * An OTP authentication is required in case no default is configured. + *

+ * + * @author Thomas Darimont + */ +public class ConditionalOtpFormAuthenticator extends OTPFormAuthenticator { + + 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 FORCE_OTP_ROLE = "forceOtpRole"; + + public static final String NO_OTP_REQUIRED_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, config), context)) { + return; + } + + if (tryConcludeBasedOn(voteForUserForceOtpRole(context, config), context)) { + return; + } + + if (tryConcludeBasedOn(voteForHttpHeaderMatchesPattern(context, config), context)) { + return; + } + + if (tryConcludeBasedOn(voteForDefaultFallback(context, config), context)) { + return; + } + + showOtpForm(context); + } + + private OtpDecision voteForDefaultFallback(AuthenticationFlowContext context, 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(AuthenticationFlowContext context, Map config) { + + if (!config.containsKey(OTP_CONTROL_USER_ATTRIBUTE)) { + return ABSTAIN; + } + + String attributeName = config.get(OTP_CONTROL_USER_ATTRIBUTE); + if (attributeName == null) { + return ABSTAIN; + } + + List values = context.getUser().getAttribute(attributeName); + + if (values.isEmpty()) { + return ABSTAIN; + } + + String value = values.get(0).trim(); + + switch (value) { + case SKIP: + return SKIP_OTP; + case FORCE: + return SHOW_OTP; + default: + return ABSTAIN; + } + } + + private OtpDecision voteForHttpHeaderMatchesPattern(AuthenticationFlowContext context, Map config) { + + if (!config.containsKey(FORCE_OTP_FOR_HTTP_HEADER) && !config.containsKey(NO_OTP_REQUIRED_FOR_HTTP_HEADER)) { + return ABSTAIN; + } + + MultivaluedMap requestHeaders = context.getHttpRequest().getHttpHeaders().getRequestHeaders(); + + //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(NO_OTP_REQUIRED_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? + Pattern pattern = Pattern.compile(headerPattern, Pattern.DOTALL); + + 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 voteForUserForceOtpRole(AuthenticationFlowContext context, Map config) { + + if (!config.containsKey(FORCE_OTP_ROLE)) { + return ABSTAIN; + } + + RoleModel forceOtpRole = getRoleFromString(context.getRealm(), config.get(FORCE_OTP_ROLE)); + UserModel user = context.getUser(); + + if (hasRole(user.getRoleMappings(), forceOtpRole)) { + return SHOW_OTP; + } + + return ABSTAIN; + } +} diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/browser/ConditionalOtpFormAuthenticatorFactory.java b/services/src/main/java/org/keycloak/authentication/authenticators/browser/ConditionalOtpFormAuthenticatorFactory.java new file mode 100755 index 0000000000..17c9620e34 --- /dev/null +++ b/services/src/main/java/org/keycloak/authentication/authenticators/browser/ConditionalOtpFormAuthenticatorFactory.java @@ -0,0 +1,132 @@ +package org.keycloak.authentication.authenticators.browser; + +import org.keycloak.Config; +import org.keycloak.authentication.Authenticator; +import org.keycloak.authentication.AuthenticatorFactory; +import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.UserCredentialModel; +import org.keycloak.provider.ProviderConfigProperty; + +import java.util.List; + +import static java.util.Arrays.asList; +import static org.keycloak.authentication.authenticators.browser.ConditionalOtpFormAuthenticator.*; +import static org.keycloak.provider.ProviderConfigProperty.*; + +/** + * An {@link AuthenticatorFactory} for {@link ConditionalOtpFormAuthenticator}s. + * + * @author Thomas Darimont + */ +public class ConditionalOtpFormAuthenticatorFactory implements AuthenticatorFactory { + + public static final String PROVIDER_ID = "auth-conditional-otp-form"; + + public static final ConditionalOtpFormAuthenticator SINGLETON = new ConditionalOtpFormAuthenticator(); + + public static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = { + AuthenticationExecutionModel.Requirement.REQUIRED, + AuthenticationExecutionModel.Requirement.OPTIONAL, + AuthenticationExecutionModel.Requirement.DISABLED}; + + @Override + public Authenticator create(KeycloakSession session) { + return SINGLETON; + } + + @Override + public void init(Config.Scope config) { + //NOOP + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + //NOOP + } + + @Override + public void close() { + //NOOP + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public String getReferenceCategory() { + return UserCredentialModel.TOTP; + } + + @Override + public boolean isConfigurable() { + return true; + } + + @Override + public boolean isUserSetupAllowed() { + return true; + } + + + @Override + public AuthenticationExecutionModel.Requirement[] getRequirementChoices() { + return REQUIREMENT_CHOICES; + } + + @Override + public String getDisplayType() { + return "Conditional OTP Form"; + } + + @Override + public String getHelpText() { + return "Validates a OTP on a separate OTP form. Only shown if required based on the configured conditions."; + } + + @Override + public List 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."); + + 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."); + + ProviderConfigProperty noOtpRequiredForHttpHeader = new ProviderConfigProperty(); + noOtpRequiredForHttpHeader.setType(STRING_TYPE); + noOtpRequiredForHttpHeader.setName(NO_OTP_REQUIRED_FOR_HTTP_HEADER); + noOtpRequiredForHttpHeader.setLabel("No OTP for Header"); + noOtpRequiredForHttpHeader.setHelpText("OTP required if a HTTP request header does not match 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."); + noOtpRequiredForHttpHeader.setDefaultValue(""); + + 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(""); + + ProviderConfigProperty defaultOutcome = new ProviderConfigProperty(); + defaultOutcome.setType(LIST_TYPE); + defaultOutcome.setName(DEFAULT_OTP_OUTCOME); + defaultOutcome.setLabel("Fallback OTP handling"); + defaultOutcome.setDefaultValue(asList(SKIP, FORCE)); + defaultOutcome.setHelpText("What to do in case of every check abstains. Defaults to force OTP authentication."); + + return asList(forceOtpUserAttribute, forceOtpRole, noOtpRequiredForHttpHeader, forceOtpForHttpHeader, defaultOutcome); + } +} diff --git a/services/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory b/services/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory index 70551a17e9..052d1c42b1 100755 --- a/services/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory +++ b/services/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory @@ -14,3 +14,4 @@ org.keycloak.authentication.authenticators.broker.IdpCreateUserIfUniqueAuthentic org.keycloak.authentication.authenticators.broker.IdpConfirmLinkAuthenticatorFactory org.keycloak.authentication.authenticators.broker.IdpEmailVerificationAuthenticatorFactory org.keycloak.authentication.authenticators.broker.IdpUsernamePasswordFormFactory +org.keycloak.authentication.authenticators.browser.ConditionalOtpFormAuthenticatorFactory \ No newline at end of file