Conditional OTP authentication:
... try the various options of the Conditional OTP Authenticator.
I recommend the chrome ModHeader Plugin to test the header based patterns.
Conditional OTP authentication:
... try the various options of the Conditional OTP Authenticator.
I recommend the chrome ModHeader Plugin to test the header based patterns.
| 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. | |
| * <p> | |
| * <p> | |
| * 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. | |
| * </p> | |
| * <ol> | |
| * <li>User Attribute</li> | |
| * <li>Role</li> | |
| * <li>Request Header</li> | |
| * <li>Configured Default</li> | |
| * </ol> | |
| * <p> | |
| * If no condition matches, the {@link ConditionalOtpFormAuthenticator} fallback is to require OTP authentication. | |
| * </p> | |
| * <p> | |
| * <h2>User Attribute</h2> | |
| * A User Attribute like <code>otp_auth</code> can be used to control OTP authentication on individual user level. | |
| * The supported values are <i>skip</i> and <i>force</i>. If the value is set to <i>skip</i> then the OTP auth is skipped for the user, | |
| * otherwise if the value is <i>force</i> then the OTP auth is enforced. The setting is ignored for any other value. | |
| * </p> | |
| * <p> | |
| * <h2>Role</h2> | |
| * 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. | |
| * <p> | |
| * </p> | |
| * <p> | |
| * <h2>Request Header</h2> | |
| * <p> | |
| * Request Headers are matched via regex {@link Pattern}s and can be specified as a whitelist and blacklist. | |
| * <i>No OTP for Header</i> specifies the pattern for which OTP authentication <b>is not</b> required. | |
| * This can be used to specify trusted networks, e.g. via: <code>X-Forwarded-Host: (1.2.3.4|1.2.3.5)</code> where | |
| * The IPs 1.2.3.4, 1.2.3.5 denote trusted machines. | |
| * <i>Force OTP for Header</i> specifies the pattern for which OTP authentication <b>is</b> required. Whitelist entries take | |
| * precedence before blacklist entries. | |
| * </p> | |
| * <p> | |
| * <h2>Configured Default</h2> | |
| * 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. | |
| * </p> | |
| * | |
| * @author <a href="mailto:[email protected]">Thomas Darimont</a> | |
| */ | |
| 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<String, String> 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<String, String> 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<String, String> config) { | |
| if (!config.containsKey(OTP_CONTROL_USER_ATTRIBUTE)) { | |
| return ABSTAIN; | |
| } | |
| String attributeName = config.get(OTP_CONTROL_USER_ATTRIBUTE); | |
| if (attributeName == null) { | |
| return ABSTAIN; | |
| } | |
| List<String> 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<String, String> config) { | |
| if (!config.containsKey(FORCE_OTP_FOR_HTTP_HEADER) && !config.containsKey(NO_OTP_REQUIRED_FOR_HTTP_HEADER)) { | |
| return ABSTAIN; | |
| } | |
| MultivaluedMap<String, String> 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<String, String> 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<String, List<String>> 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<String, String> 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; | |
| } | |
| } |
| 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 <a href="mailto:[email protected]">Thomas Darimont</a> | |
| */ | |
| 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<ProviderConfigProperty> 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); | |
| } | |
| } |
| // Used in various role mappers | |
| public static RoleModel getRoleFromString(RealmModel realm, String roleName) { | |
| String[] parsedRole = parseRole(roleName); | |
| RoleModel role = null; | |
| if (parsedRole[0] == null) { | |
| role = realm.getRole(parsedRole[1]); | |
| } else { | |
| ClientModel client = realm.getClientByClientId(parsedRole[0]); | |
| if (client != null) { | |
| role = client.getRole(parsedRole[1]); | |
| } | |
| } | |
| return role; | |
| } | |
| // Used for hardcoded role mappers | |
| public static String[] parseRole(String role) { | |
| int scopeIndex = role.lastIndexOf('.'); | |
| if (scopeIndex > -1) { | |
| String appName = role.substring(0, scopeIndex); | |
| role = role.substring(scopeIndex + 1); | |
| String[] rtn = {appName, role}; | |
| return rtn; | |
| } else { | |
| String[] rtn = {null, role}; | |
| return rtn; | |
| } | |
| } |
| org.keycloak.authentication.authenticators.browser.ConditionalOtpFormAuthenticatorFactory |
More clear version 😄
https://access.redhat.com/solutions/6976632
Issue
Resolution
Root Cause
Diagnostic Steps
Browser flow:
Condition - User Role = 'REQUIRED'