Skip to content

Commit b855823

Browse files
Add example for skippable required action
SkippableRequiredAction action can be configured in the realm. by default we allow users to skip the action two times. The attempts are recorded as a user attribute.
1 parent 586f003 commit b855823

File tree

2 files changed

+206
-0
lines changed

2 files changed

+206
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
package com.github.thomasdarimont.keycloak.custom.auth.demo;
2+
3+
import com.google.auto.service.AutoService;
4+
import jakarta.ws.rs.core.Response;
5+
import org.keycloak.Config;
6+
import org.keycloak.authentication.RequiredActionContext;
7+
import org.keycloak.authentication.RequiredActionFactory;
8+
import org.keycloak.authentication.RequiredActionProvider;
9+
import org.keycloak.forms.login.LoginFormsProvider;
10+
import org.keycloak.models.KeycloakSession;
11+
import org.keycloak.models.KeycloakSessionFactory;
12+
import org.keycloak.models.UserModel;
13+
import org.keycloak.models.UserSessionModel;
14+
import org.keycloak.provider.ProviderConfigProperty;
15+
import org.keycloak.provider.ProviderConfigurationBuilder;
16+
import org.keycloak.sessions.AuthenticationSessionModel;
17+
18+
import java.util.List;
19+
import java.util.Optional;
20+
21+
public class SkippableRequiredAction implements RequiredActionProvider {
22+
23+
public static final String PROVIDER_ID = "ACME_DEMO_SKIPPABLE_ACTION";
24+
25+
public static final String ACTION_SKIPPED_SESSION_NOTE = PROVIDER_ID + ":skipped";
26+
27+
public static final String SKIP_COUNT_USER_ATTRIBUTE = "acme-action-count";
28+
29+
public static final String ACTION_DONE_USER_ATTRIBUTE = "acme-action-done";
30+
31+
public static final String MAX_SKIP_COUNT_CONFIG_ATTRIBUTE = "max-skip-count";
32+
33+
@Override
34+
public void evaluateTriggers(RequiredActionContext context) {
35+
36+
AuthenticationSessionModel authSession = context.getAuthenticationSession();
37+
// check if evaluate triggers was already called for this required action
38+
if (authSession.getAuthNote(PROVIDER_ID) != null) {
39+
return;
40+
}
41+
authSession.setAuthNote(PROVIDER_ID, "");
42+
43+
UserModel user = context.getUser();
44+
if (!isUserActionRequired(user)) {
45+
return;
46+
}
47+
48+
if (didUserSkipRequiredAction(context, authSession)) {
49+
return;
50+
}
51+
52+
authSession.addRequiredAction(PROVIDER_ID);
53+
}
54+
55+
protected boolean didUserSkipRequiredAction(RequiredActionContext context, AuthenticationSessionModel authSession) {
56+
// we remember the action skipping in the user session to have it available for every auth interaction within the current user session
57+
UserSessionModel userSession = context.getSession().sessions().getUserSession(context.getRealm(), authSession.getParentSession().getId());
58+
return userSession != null && "true".equals(userSession.getNote(ACTION_SKIPPED_SESSION_NOTE));
59+
}
60+
61+
protected boolean isUserActionRequired(UserModel user) {
62+
return user.getFirstAttribute(ACTION_DONE_USER_ATTRIBUTE) == null;
63+
}
64+
65+
@Override
66+
public void requiredActionChallenge(RequiredActionContext context) {
67+
68+
LoginFormsProvider form = context.form();
69+
70+
boolean canSkip = isSkipActionPossible(context);
71+
form.setAttribute("canSkip", canSkip);
72+
73+
Response challenge = form.createForm("login-skippable-action.ftl");
74+
75+
context.challenge(challenge);
76+
}
77+
78+
protected boolean isSkipActionPossible(RequiredActionContext context) {
79+
80+
UserModel user = context.getUser();
81+
82+
int skipCount = Integer.parseInt(Optional.ofNullable(user.getFirstAttribute(SKIP_COUNT_USER_ATTRIBUTE)).orElse("0"));
83+
String maxSkipCountConfigValue = context.getConfig().getConfigValue(MAX_SKIP_COUNT_CONFIG_ATTRIBUTE);
84+
85+
if (maxSkipCountConfigValue == null) {
86+
return false;
87+
}
88+
89+
int maxSkipCount = Integer.parseInt(maxSkipCountConfigValue);
90+
return skipCount < maxSkipCount;
91+
}
92+
93+
@Override
94+
public void processAction(RequiredActionContext context) {
95+
96+
var formData = context.getHttpRequest().getDecodedFormParameters();
97+
98+
if (formData.containsKey("skip")) {
99+
recordActionSkipped(context.getUser(), context.getAuthenticationSession());
100+
101+
context.success();
102+
return;
103+
}
104+
105+
markActionDone(context.getUser());
106+
107+
context.success();
108+
}
109+
110+
protected void markActionDone(UserModel user) {
111+
user.setSingleAttribute(ACTION_DONE_USER_ATTRIBUTE, Boolean.toString(true));
112+
user.removeAttribute(SKIP_COUNT_USER_ATTRIBUTE);
113+
}
114+
115+
protected void recordActionSkipped(UserModel user, AuthenticationSessionModel authSession) {
116+
int skipCount = Integer.parseInt(Optional.ofNullable(user.getFirstAttribute(SKIP_COUNT_USER_ATTRIBUTE)).orElse("0"));
117+
skipCount+=1;
118+
user.setSingleAttribute(SKIP_COUNT_USER_ATTRIBUTE, Integer.toString(skipCount));
119+
120+
authSession.setUserSessionNote(ACTION_SKIPPED_SESSION_NOTE, "true");
121+
}
122+
123+
@Override
124+
public void close() {
125+
// NOOP
126+
}
127+
128+
@AutoService(RequiredActionFactory.class)
129+
public static class Factory implements RequiredActionFactory {
130+
131+
private static final SkippableRequiredAction INSTANCE = new SkippableRequiredAction();
132+
133+
@Override
134+
public String getId() {
135+
return PROVIDER_ID;
136+
}
137+
138+
@Override
139+
public String getDisplayText() {
140+
return "Acme: Skippable Action";
141+
}
142+
143+
@Override
144+
public RequiredActionProvider create(KeycloakSession session) {
145+
return INSTANCE;
146+
}
147+
148+
@Override
149+
public List<ProviderConfigProperty> getConfigMetadata() {
150+
151+
List<ProviderConfigProperty> configProperties = ProviderConfigurationBuilder.create() //
152+
.property() //
153+
.name(MAX_SKIP_COUNT_CONFIG_ATTRIBUTE) //
154+
.label("Max Skip") //
155+
.required(true) //
156+
.defaultValue(2) //
157+
.helpText("Maximum skip count") //
158+
.type(ProviderConfigProperty.INTEGER_TYPE) //
159+
.add() //
160+
.build();
161+
return configProperties;
162+
}
163+
164+
@Override
165+
public void init(Config.Scope config) {
166+
// NOOP
167+
}
168+
169+
@Override
170+
public void postInit(KeycloakSessionFactory factory) {
171+
// NOOP
172+
}
173+
174+
@Override
175+
public void close() {
176+
// NOOP
177+
}
178+
}
179+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<#import "template.ftl" as layout>
2+
<@layout.registrationLayout displayInfo=true; section>
3+
<#if section = "title">
4+
Skippable Action
5+
<#elseif section = "header">
6+
Skippable Action
7+
<#elseif section = "form">
8+
<p>Example Skippable Action</p>
9+
<form id="kc-skippable-action-form" class="${properties.kcFormClass!}" action="${url.loginAction}" method="post">
10+
11+
<div class="${properties.kcFormGroupClass!}">
12+
13+
<div id="kc-form-buttons" class="${properties.kcFormButtonsClass!}">
14+
<input class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonLargeClass!}"
15+
type="submit" value="${msg("doSubmit")}"/>
16+
17+
<#if canSkip>
18+
<input name="skip"
19+
class="${properties.kcButtonClass!} ${properties.kcButtonSecondaryClass!} ${properties.kcButtonLargeClass!}"
20+
type="submit" value="Skip"
21+
formnovalidate="formnovalidate"/>
22+
</#if>
23+
</div>
24+
</div>
25+
</form>
26+
</#if>
27+
</@layout.registrationLayout>

0 commit comments

Comments
 (0)