Skip to content

Commit

Permalink
Merge pull request #44 from halcsi19790320/main
Browse files Browse the repository at this point in the history
Support Keycloak 25.0.2 and ConditionalEmailFormAuthenticator
  • Loading branch information
mesutpiskin authored Sep 13, 2024
2 parents 5a20de4 + eca0530 commit 9b5d9c8
Show file tree
Hide file tree
Showing 8 changed files with 333 additions and 37 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,4 @@ hs_err_pid*
target/*
.idea
.vscode
/target/
21 changes: 4 additions & 17 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,12 @@
<java.version>17</java.version>
<maven.compiler.source>${java.version}</maven.compiler.source>
<maven.compiler.target>${java.version}</maven.compiler.target>
<lombok.version>1.18.22</lombok.version>
<keycloak.version>22.0.1</keycloak.version>
<auto-service.version>1.0.1</auto-service.version>
<lombok.version>1.18.34</lombok.version>
<keycloak.version>25.0.2</keycloak.version>
<maven-jar.plugin.version>3.4.2</maven-jar.plugin.version>
</properties>

<dependencies>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>

<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi</artifactId>
Expand Down Expand Up @@ -55,21 +49,14 @@
<optional>true</optional>
</dependency>

<dependency>
<groupId>com.google.auto.service</groupId>
<artifactId>auto-service</artifactId>
<version>${auto-service.version}</version>
<scope>provided</scope>
<optional>true</optional>
</dependency>

</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>${maven-jar.plugin.version}</version>
<configuration>
<archive>
<!-- This is required since we need to add the jboss module references
Expand Down
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
Expand Up @@ -28,11 +28,6 @@

@JBossLog
public class EmailAuthenticatorForm extends AbstractUsernameFormAuthenticator {
private final KeycloakSession session;

public EmailAuthenticatorForm(KeycloakSession session) {
this.session = session;
}

@Override
public void authenticate(AuthenticationFlowContext context) {
Expand Down Expand Up @@ -74,7 +69,7 @@ private void generateAndSendEmailCode(AuthenticationFlowContext context) {
}

String code = SecretGenerator.getInstance().randomString(length, SecretGenerator.DIGITS);
sendEmailWithCode(context.getRealm(), context.getUser(), code, ttl);
sendEmailWithCode(context.getSession(), context.getRealm(), context.getUser(), code, ttl);
session.setAuthNote(EmailConstants.CODE, code);
session.setAuthNote(EmailConstants.CODE_TTL, Long.toString(System.currentTimeMillis() + (ttl * 1000L)));
}
Expand Down Expand Up @@ -158,7 +153,7 @@ public void close() {
// NOOP
}

private void sendEmailWithCode(RealmModel realm, UserModel user, String code, int ttl) {
private void sendEmailWithCode(KeycloakSession session, RealmModel realm, UserModel user, String code, int ttl) {
if (user.getEmail() == null) {
log.warnf("Could not send access code email due to missing email. realm=%s user=%s", realm.getId(), user.getUsername());
throw new AuthenticationFlowException(AuthenticationFlowError.INVALID_USER);
Expand Down
Loading

0 comments on commit 9b5d9c8

Please sign in to comment.