-
Notifications
You must be signed in to change notification settings - Fork 94
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #44 from halcsi19790320/main
Support Keycloak 25.0.2 and ConditionalEmailFormAuthenticator
- Loading branch information
Showing
8 changed files
with
333 additions
and
37 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -25,3 +25,4 @@ hs_err_pid* | |
target/* | ||
.idea | ||
.vscode | ||
/target/ |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
205 changes: 205 additions & 0 deletions
205
src/main/java/com/mesutpiskin/keycloak/auth/email/ConditionalEmailAuthenticatorForm.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
109 changes: 109 additions & 0 deletions
109
...in/java/com/mesutpiskin/keycloak/auth/email/ConditionalEmailAuthenticatorFormFactory.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.