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 |