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 |
Hi!
Thanks a lot for your work developing the ConditionalOtpFormAuthenticator
! This is exactly what I was looking for!
I was also following the steps documented here and I couldn't make it work in 10.0.1.
I was wondering if there is other documentation about this that I could follow, or maybe I'm doing something wrong?
The other docs that I found are the javadocs and the Keycloak Server Administration.
Would upgrading to 10.0.2 fix this? (It's not mentioned in the release notes/Jira).
Thanks!
Hi
I want to OTP for few specific functions in my application. To start/finish the transaction(Function) keycloak should ask for OTP(Google Authenticator).If OTP is valid ,it should allow to complete the transaction.
Need help to implement this on keycloak 12.0.4
Note that this code here is outdated. The ConditionaOtpAuthenticator was adopted a while ago by Keycloak and got adapted over time.
Shouldn't this statement be at the top of the readme? "This code was adopted into Keycloak in version X and remains here only as an example of extending a service provider." Just to save everyone time wondering "does this repo do what I need?"
Hii,
When i click on send otp then i'm getting info 422 in console. Can someone help me
More clear version 😄
https://access.redhat.com/solutions/6976632
Issue
Resolution
Root Cause
Diagnostic Steps
Browser flow:
- Browser - Conditional OTP
Condition - User Role = 'REQUIRED'
- Config: Alias=[alias-name], user role: [select client role to users]
Instead of
Browser Dynamic Otp Forms
useBrowser Dynamic Otp Browser - Conditional OTP
to add the execution. May be you can make this small update.Your gist is the only place where I found this configuration. Cheers! :)