Skip to content

Commit 0d49da4

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 0d49da4

File tree

2 files changed

+218
-0
lines changed

2 files changed

+218
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
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.services.messages.Messages;
17+
import org.keycloak.sessions.AuthenticationSessionModel;
18+
19+
import java.util.List;
20+
import java.util.Optional;
21+
22+
public class SkippableRequiredAction implements RequiredActionProvider {
23+
24+
public static final String PROVIDER_ID = "ACME_DEMO_SKIPPABLE_ACTION";
25+
26+
public static final String ACTION_SKIPPED_SESSION_NOTE = PROVIDER_ID + ":skipped";
27+
28+
public static final String SKIP_COUNT_USER_ATTRIBUTE = "acme-action-count";
29+
30+
public static final String ACTION_DONE_USER_ATTRIBUTE = "acme-action-done";
31+
32+
public static final String MAX_SKIP_COUNT_CONFIG_ATTRIBUTE = "max-skip-count";
33+
34+
@Override
35+
public void evaluateTriggers(RequiredActionContext context) {
36+
37+
AuthenticationSessionModel authSession = context.getAuthenticationSession();
38+
// check if evaluate triggers was already called for this required action
39+
if (authSession.getAuthNote(PROVIDER_ID) != null) {
40+
return;
41+
}
42+
authSession.setAuthNote(PROVIDER_ID, "");
43+
44+
UserModel user = context.getUser();
45+
if (!isUserActionRequired(user)) {
46+
return;
47+
}
48+
49+
if (didUserSkipRequiredAction(context, authSession)) {
50+
return;
51+
}
52+
53+
authSession.addRequiredAction(PROVIDER_ID);
54+
}
55+
56+
protected boolean didUserSkipRequiredAction(RequiredActionContext context, AuthenticationSessionModel authSession) {
57+
// we remember the action skipping in the user session to have it available for every auth interaction within the current user session
58+
UserSessionModel userSession = context.getSession().sessions().getUserSession(context.getRealm(), authSession.getParentSession().getId());
59+
return userSession != null && "true".equals(userSession.getNote(ACTION_SKIPPED_SESSION_NOTE));
60+
}
61+
62+
protected boolean isUserActionRequired(UserModel user) {
63+
return user.getFirstAttribute(ACTION_DONE_USER_ATTRIBUTE) == null;
64+
}
65+
66+
@Override
67+
public void requiredActionChallenge(RequiredActionContext context) {
68+
69+
Response challenge = createChallengeForm(context);
70+
context.challenge(challenge);
71+
}
72+
73+
protected Response createChallengeForm(RequiredActionContext context) {
74+
LoginFormsProvider form = context.form();
75+
76+
boolean canSkip = isSkipActionPossible(context);
77+
form.setAttribute("canSkip", canSkip);
78+
79+
return form.createForm("login-skippable-action.ftl");
80+
}
81+
82+
protected boolean isSkipActionPossible(RequiredActionContext context) {
83+
84+
UserModel user = context.getUser();
85+
86+
int skipCount = Integer.parseInt(Optional.ofNullable(user.getFirstAttribute(SKIP_COUNT_USER_ATTRIBUTE)).orElse("0"));
87+
String maxSkipCountConfigValue = context.getConfig().getConfigValue(MAX_SKIP_COUNT_CONFIG_ATTRIBUTE);
88+
89+
if (maxSkipCountConfigValue == null) {
90+
return false;
91+
}
92+
93+
int maxSkipCount = Integer.parseInt(maxSkipCountConfigValue);
94+
return skipCount < maxSkipCount;
95+
}
96+
97+
@Override
98+
public void processAction(RequiredActionContext context) {
99+
100+
var formData = context.getHttpRequest().getDecodedFormParameters();
101+
102+
if (formData.containsKey("skip")) {
103+
104+
if (!isSkipActionPossible(context)) {
105+
// nice try sneaky hacker
106+
Response challenge = createChallengeForm(context);
107+
context.challenge(challenge);
108+
return;
109+
}
110+
111+
recordActionSkipped(context.getUser(), context.getAuthenticationSession());
112+
113+
context.success();
114+
return;
115+
}
116+
117+
markActionDone(context.getUser());
118+
119+
context.success();
120+
}
121+
122+
protected void markActionDone(UserModel user) {
123+
user.setSingleAttribute(ACTION_DONE_USER_ATTRIBUTE, Boolean.toString(true));
124+
user.removeAttribute(SKIP_COUNT_USER_ATTRIBUTE);
125+
}
126+
127+
protected void recordActionSkipped(UserModel user, AuthenticationSessionModel authSession) {
128+
int skipCount = Integer.parseInt(Optional.ofNullable(user.getFirstAttribute(SKIP_COUNT_USER_ATTRIBUTE)).orElse("0"));
129+
skipCount+=1;
130+
user.setSingleAttribute(SKIP_COUNT_USER_ATTRIBUTE, Integer.toString(skipCount));
131+
132+
authSession.setUserSessionNote(ACTION_SKIPPED_SESSION_NOTE, "true");
133+
}
134+
135+
@Override
136+
public void close() {
137+
// NOOP
138+
}
139+
140+
@AutoService(RequiredActionFactory.class)
141+
public static class Factory implements RequiredActionFactory {
142+
143+
private static final SkippableRequiredAction INSTANCE = new SkippableRequiredAction();
144+
145+
@Override
146+
public String getId() {
147+
return PROVIDER_ID;
148+
}
149+
150+
@Override
151+
public String getDisplayText() {
152+
return "Acme: Skippable Action";
153+
}
154+
155+
@Override
156+
public RequiredActionProvider create(KeycloakSession session) {
157+
return INSTANCE;
158+
}
159+
160+
@Override
161+
public List<ProviderConfigProperty> getConfigMetadata() {
162+
163+
List<ProviderConfigProperty> configProperties = ProviderConfigurationBuilder.create() //
164+
.property() //
165+
.name(MAX_SKIP_COUNT_CONFIG_ATTRIBUTE) //
166+
.label("Max Skip") //
167+
.required(true) //
168+
.defaultValue(2) //
169+
.helpText("Maximum skip count") //
170+
.type(ProviderConfigProperty.INTEGER_TYPE) //
171+
.add() //
172+
.build();
173+
return configProperties;
174+
}
175+
176+
@Override
177+
public void init(Config.Scope config) {
178+
// NOOP
179+
}
180+
181+
@Override
182+
public void postInit(KeycloakSessionFactory factory) {
183+
// NOOP
184+
}
185+
186+
@Override
187+
public void close() {
188+
// NOOP
189+
}
190+
}
191+
}
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)