Skip to content

Instantly share code, notes, and snippets.

@thomasdarimont
Last active November 15, 2023 05:49
Show Gist options
  • Save thomasdarimont/ad3aa0e36d33d067dba2 to your computer and use it in GitHub Desktop.
Save thomasdarimont/ad3aa0e36d33d067dba2 to your computer and use it in GitHub Desktop.
Keycloak Conditional OTP Step-by-Step

Conditional OTP authentication:

Scenario Setup

Run Keycloak with the custom authentication provider.

Create a new realm dynamic-otp-test.

Create a new realm role require_otp_auth.

Create a new test user otp

Goto Authentication -> Flows -> Select Browser.

Click on copy

Name the new flow browser dynamic otp

Click on actions in the line Browser Dynamic Otp Forms

Add execution: Conditional OTP Form.

Disable the OTP Form

Mark the Conditional OTP Form as required.

Click on Actions -> configure for the Conditional OTP Form

Give it the alias Conditional OTP Authentication

Select the require_otp_role from the Force OTP for Role

Configure the Fallback OTP handling to skip

Goto Bindings

Select browser dynamic otp for the browser flow

Scenario Test

As the user otp with no role assigned

Try login to the account application (tipp: use incognito mode / private browsing)

Enter username / password

Register OTP device.

Logout

Login again

... 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
@Sasankasekh
Copy link

Hii,
When i click on send otp then i'm getting info 422 in console. Can someone help me

@myonaingwinn
Copy link

myonaingwinn commented Nov 15, 2023

More clear version 😄

https://access.redhat.com/solutions/6976632

Issue

  • When OTP is enabled from authentication(Browser Flow). It gets enabled for all the users in realm.
  • Any way to have OTP enabled for only some set of users (Conditional OTP)

Resolution

  • Follow below steps to enable conditional OTP for set of users
  • Create a new role for example "require_otp_role"
  • Go to Authentication -> Flows -> Select Browser.
  • Click on copy
  • Name the new flow browser browser_otp
  • Click on actions in the line Browse_otp Forms
  • Add execution: Conditional OTP Form.
  • Disable the OTP Form
  • Mark the Conditional OTP Form as required.
  • Click on Actions -> configure for the Conditional OTP Form
  • Give it the alias require_otp_flow
  • Select the require_otp_role from the Force OTP for Role
  • Configure the Fallback OTP handling to skip
  • mark "Condition - User Configured" as "DISABLED" from the "Browser_otp Browser - Conditional OTP" execution
  • assign the role created "require_otp_role" to the user which you want to have the OTP option available.

Root Cause

  • Conditional OTP's are not enabled by default. You can enable as described in Resolution section.

Diagnostic Steps
Browser flow:

  • Browser - Conditional OTP

Condition - User Role = 'REQUIRED'

  • Config: Alias=[alias-name], user role: [select client role to users]

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment