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.
*
*
* 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.
*
*
* - User Attribute
* - Role
* - Request Header
* - Configured Default
*
*
* If no condition matches, the {@link ConditionalOtpFormAuthenticator} fallback is to require OTP authentication.
*
*
*
User Attribute
* A User Attribute like otp_auth can be used to control OTP authentication on individual user level.
* The supported values are skip and force. If the value is set to skip then the OTP auth is skipped for the user,
* otherwise if the value is force then the OTP auth is enforced. The setting is ignored for any other value.
*
*
*
Role
* 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.
*
*
*
*
Request Header
*
* Request Headers are matched via regex {@link Pattern}s and can be specified as a whitelist and blacklist.
* No OTP for Header specifies the pattern for which OTP authentication is not required.
* This can be used to specify trusted networks, e.g. via: X-Forwarded-Host: (1.2.3.4|1.2.3.5) where
* The IPs 1.2.3.4, 1.2.3.5 denote trusted machines.
* Force OTP for Header specifies the pattern for which OTP authentication is required. Whitelist entries take
* precedence before blacklist entries.
*
*
*
Configured Default
* 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.
*
*
* @author Thomas Darimont
*/
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 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 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 config) {
if (!config.containsKey(OTP_CONTROL_USER_ATTRIBUTE)) {
return ABSTAIN;
}
String attributeName = config.get(OTP_CONTROL_USER_ATTRIBUTE);
if (attributeName == null) {
return ABSTAIN;
}
List 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 config) {
if (!config.containsKey(FORCE_OTP_FOR_HTTP_HEADER) && !config.containsKey(NO_OTP_REQUIRED_FOR_HTTP_HEADER)) {
return ABSTAIN;
}
MultivaluedMap 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 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> 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 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;
}
}