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.IdpEmailVerificationAuthenticatorFactory
|
||||
org.keycloak.authentication.authenticators.broker.IdpUsernamePasswordFormFactory
|
||||
org.keycloak.authentication.authenticators.browser.ConditionalOtpFormAuthenticatorFactory
|
Loading…
Reference in a new issue