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.
+ *
+ *
+ * - User Attribute
+ * - Role
+ * - Request Header
+ * - Configured Default
+ *
+ *
+ * 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