Merge pull request #2006 from thomasdarimont/issue/KEYCLOAK-2040-Conditional-OTP-Authentication
KEYCLOAK-2040 - Add support for Conditional OTP auth.
This commit is contained in:
commit
43dc250bf2
3 changed files with 367 additions and 0 deletions
|
@ -0,0 +1,234 @@
|
||||||
|
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:thomas.darimont@gmail.com">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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,132 @@
|
||||||
|
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:thomas.darimont@gmail.com">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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -14,3 +14,4 @@ org.keycloak.authentication.authenticators.broker.IdpCreateUserIfUniqueAuthentic
|
||||||
org.keycloak.authentication.authenticators.broker.IdpConfirmLinkAuthenticatorFactory
|
org.keycloak.authentication.authenticators.broker.IdpConfirmLinkAuthenticatorFactory
|
||||||
org.keycloak.authentication.authenticators.broker.IdpEmailVerificationAuthenticatorFactory
|
org.keycloak.authentication.authenticators.broker.IdpEmailVerificationAuthenticatorFactory
|
||||||
org.keycloak.authentication.authenticators.broker.IdpUsernamePasswordFormFactory
|
org.keycloak.authentication.authenticators.broker.IdpUsernamePasswordFormFactory
|
||||||
|
org.keycloak.authentication.authenticators.browser.ConditionalOtpFormAuthenticatorFactory
|
Loading…
Reference in a new issue