Skip to content

Commit

Permalink
ConditionalEmailAuthenticator from ConditionalOtpFormAuthenticator
Browse files Browse the repository at this point in the history
  • Loading branch information
halcsi19790320 committed Aug 8, 2024
1 parent 8364da6 commit eca0530
Show file tree
Hide file tree
Showing 3 changed files with 315 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
package com.mesutpiskin.keycloak.auth.email;

import static com.mesutpiskin.keycloak.auth.email.ConditionalEmailAuthenticatorForm.OtpDecision.ABSTAIN;
import static com.mesutpiskin.keycloak.auth.email.ConditionalEmailAuthenticatorForm.OtpDecision.SHOW_OTP;
import static com.mesutpiskin.keycloak.auth.email.ConditionalEmailAuthenticatorForm.OtpDecision.SKIP_OTP;
import static org.keycloak.models.utils.KeycloakModelUtils.getRoleFromString;

import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.regex.Pattern;

import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.models.RealmModel;
import org.keycloak.models.RoleModel;
import org.keycloak.models.UserModel;

import jakarta.ws.rs.core.MultivaluedMap;

public class ConditionalEmailAuthenticatorForm extends EmailAuthenticatorForm {

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 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;
}

Optional<String> value = user.getAttributeStream(attributeName).findFirst();
if (!value.isPresent()) {
return ABSTAIN;
}

switch (value.get().trim()) {
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?
// need CASE_INSENSITIVE flag so that we also have matches when the underlying container use a different case than what
// is usually expected (e.g.: vertx)
Pattern pattern = Pattern.compile(headerPattern, Pattern.DOTALL | Pattern.CASE_INSENSITIVE);

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);
if (role != null) {
return user.hasRole(role);
}
return false;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package com.mesutpiskin.keycloak.auth.email;

import static java.util.Arrays.asList;
import static org.keycloak.authentication.authenticators.browser.ConditionalOtpFormAuthenticator.DEFAULT_OTP_OUTCOME;
import static org.keycloak.authentication.authenticators.browser.ConditionalOtpFormAuthenticator.FORCE;
import static org.keycloak.authentication.authenticators.browser.ConditionalOtpFormAuthenticator.FORCE_OTP_FOR_HTTP_HEADER;
import static org.keycloak.authentication.authenticators.browser.ConditionalOtpFormAuthenticator.FORCE_OTP_ROLE;
import static org.keycloak.authentication.authenticators.browser.ConditionalOtpFormAuthenticator.OTP_CONTROL_USER_ATTRIBUTE;
import static org.keycloak.authentication.authenticators.browser.ConditionalOtpFormAuthenticator.SKIP;
import static org.keycloak.authentication.authenticators.browser.ConditionalOtpFormAuthenticator.SKIP_OTP_FOR_HTTP_HEADER;
import static org.keycloak.authentication.authenticators.browser.ConditionalOtpFormAuthenticator.SKIP_OTP_ROLE;
import static org.keycloak.provider.ProviderConfigProperty.LIST_TYPE;
import static org.keycloak.provider.ProviderConfigProperty.ROLE_TYPE;
import static org.keycloak.provider.ProviderConfigProperty.STRING_TYPE;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

import org.keycloak.authentication.Authenticator;
import org.keycloak.models.KeycloakSession;
import org.keycloak.provider.ProviderConfigProperty;

public class ConditionalEmailAuthenticatorFormFactory extends EmailAuthenticatorFormFactory {

public static final String PROVIDER_ID = "email-conditional-authenticator";
public static final ConditionalEmailAuthenticatorForm SINGLETON = new ConditionalEmailAuthenticatorForm();

@Override
public String getId() {
return PROVIDER_ID;
}

@Override
public String getDisplayType() {
return "Conditional Email OTP";
}

@Override
public String getHelpText() {
return "Conditional Email otp authenticator.";
}

@Override
public List<ProviderConfigProperty> getConfigProperties() {
List<ProviderConfigProperty> list = new ArrayList<>(super.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.");
list.add(forceOtpUserAttribute);

ProviderConfigProperty skipOtpRole = new ProviderConfigProperty();
skipOtpRole.setType(ROLE_TYPE);
skipOtpRole.setName(SKIP_OTP_ROLE);
skipOtpRole.setLabel("Skip OTP for Role");
skipOtpRole.setHelpText("OTP is always skipped if user has the given Role.");
list.add(skipOtpRole);

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.");
list.add(forceOtpRole);

ProviderConfigProperty skipOtpForHttpHeader = new ProviderConfigProperty();
skipOtpForHttpHeader.setType(STRING_TYPE);
skipOtpForHttpHeader.setName(SKIP_OTP_FOR_HTTP_HEADER);
skipOtpForHttpHeader.setLabel("Skip OTP for Header");
skipOtpForHttpHeader.setHelpText("OTP is skipped if a HTTP request header does matches 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.");
skipOtpForHttpHeader.setDefaultValue("");
list.add(skipOtpForHttpHeader);

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("");
list.add(forceOtpForHttpHeader);

ProviderConfigProperty defaultOutcome = new ProviderConfigProperty();
defaultOutcome.setType(LIST_TYPE);
defaultOutcome.setName(DEFAULT_OTP_OUTCOME);
defaultOutcome.setLabel("Fallback OTP handling");
defaultOutcome.setOptions(asList(SKIP, FORCE));
defaultOutcome.setHelpText("What to do in case of every check abstains. Defaults to force OTP authentication.");
list.add(defaultOutcome);

return Collections.unmodifiableList(list);
}

@Override
public boolean isUserSetupAllowed() {
return true;
}

@Override
public Authenticator create(KeycloakSession session) {
return SINGLETON;
}
}
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
com.mesutpiskin.keycloak.auth.email.EmailAuthenticatorFormFactory
com.mesutpiskin.keycloak.auth.email.ConditionalEmailAuthenticatorFormFactory

0 comments on commit eca0530

Please sign in to comment.