/*
* Copyright 2016 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.authentication.authenticators.browser;
import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.models.AuthenticatorConfigModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.RoleModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.utils.RoleUtils;
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.ABSTAIN;
import static org.keycloak.authentication.authenticators.browser.ConditionalOtpFormAuthenticator.OtpDecision.SHOW_OTP;
import static org.keycloak.authentication.authenticators.browser.ConditionalOtpFormAuthenticator.OtpDecision.SKIP_OTP;
import static org.keycloak.models.utils.KeycloakModelUtils.getRoleFromString;
/**
* 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 skip OTP role then OTP authentication is skipped for the user.
* If the user has the specified force OTP role, then the OTP authentication is required for the user.
* If not configured, e.g. if no role is selected, then this 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 SKIP_OTP_ROLE = "skipOtpRole";
public static final String FORCE_OTP_ROLE = "forceOtpRole";
public static final String SKIP_OTP_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.getUser(), config), context)) {
return;
}
if (tryConcludeBasedOn(voteForUserRole(context.getRealm(), context.getUser(), config), context)) {
return;
}
if (tryConcludeBasedOn(voteForHttpHeaderMatchesPattern(context.getHttpRequest().getHttpHeaders().getRequestHeaders(), config), context)) {
return;
}
if (tryConcludeBasedOn(voteForDefaultFallback(config), context)) {
return;
}
showOtpForm(context);
}
private OtpDecision voteForDefaultFallback(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 boolean tryConcludeBasedOn(OtpDecision state) {
switch (state) {
case SHOW_OTP:
return true;
case SKIP_OTP:
return false;
default:
return false;
}
}
private void showOtpForm(AuthenticationFlowContext context) {
super.authenticate(context);
}
private OtpDecision voteForUserOtpControlAttribute(UserModel user, 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 = user.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(MultivaluedMap<String, String> requestHeaders, Map<String, String> config) {
if (!config.containsKey(FORCE_OTP_FOR_HTTP_HEADER) && !config.containsKey(SKIP_OTP_FOR_HTTP_HEADER)) {
return ABSTAIN;
}
//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(SKIP_OTP_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 voteForUserRole(RealmModel realm, UserModel user, Map<String, String> config) {
if (!config.containsKey(SKIP_OTP_ROLE) && !config.containsKey(FORCE_OTP_ROLE)) {
return ABSTAIN;
}
if (userHasRole(realm, user, config.get(SKIP_OTP_ROLE))) {
return SKIP_OTP;
}
if (userHasRole(realm, user, config.get(FORCE_OTP_ROLE))) {
return SHOW_OTP;
}
return ABSTAIN;
}
private boolean userHasRole(RealmModel realm, UserModel user, String roleName) {
if (roleName == null) {
return false;
}
RoleModel role = getRoleFromString(realm, roleName);
return RoleUtils.hasRole(user.getRoleMappings(), role);
}
private boolean isOTPRequired(KeycloakSession session, RealmModel realm, UserModel user) {
MultivaluedMap<String, String> requestHeaders = session.getContext().getRequestHeaders().getRequestHeaders();
for (AuthenticatorConfigModel configModel : realm.getAuthenticatorConfigs()) {
if (tryConcludeBasedOn(voteForUserOtpControlAttribute(user, configModel.getConfig()))) {
return true;
}
if (tryConcludeBasedOn(voteForUserRole(realm, user, configModel.getConfig()))) {
return true;
}
if (tryConcludeBasedOn(voteForHttpHeaderMatchesPattern(requestHeaders, configModel.getConfig()))) {
return true;
}
if (configModel.getConfig().get(DEFAULT_OTP_OUTCOME) != null
&& configModel.getConfig().get(DEFAULT_OTP_OUTCOME).equals(FORCE)
&& configModel.getConfig().size() <= 1) {
return true;
}
}
return false;
}
@Override
public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) {
if (!isOTPRequired(session, realm, user)) {
user.removeRequiredAction(UserModel.RequiredAction.CONFIGURE_TOTP);
} else if (!user.getRequiredActions().contains(UserModel.RequiredAction.CONFIGURE_TOTP.name())) {
user.addRequiredAction(UserModel.RequiredAction.CONFIGURE_TOTP.name());
}
}
}