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; } }