-
Notifications
You must be signed in to change notification settings - Fork 0
Create custom email verification flow #185
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
1346a4d
ee16a6c
59a305c
e4ea854
233b84c
45bfce2
b4591ce
b59308f
0d22533
6e0c198
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
@@ -0,0 +1,169 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
package edu.mit.ol.keycloak.providers; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
import org.keycloak.authentication.RequiredActionContext; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
import org.keycloak.authentication.RequiredActionProvider; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
import org.keycloak.authentication.RequiredActionFactory; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
import org.keycloak.models.KeycloakSession; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
import org.keycloak.models.RealmModel; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
import org.keycloak.models.UserModel; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
import org.keycloak.email.EmailException; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
import org.keycloak.email.EmailTemplateProvider; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
import org.jboss.logging.Logger; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
import jakarta.ws.rs.core.Response; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
import java.util.Objects; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
import java.util.HashMap; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
import java.util.Map; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
import java.security.SecureRandom; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
public class CustomVerifyEmailRequiredAction implements RequiredActionProvider { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
private static final Logger logger = Logger.getLogger(CustomVerifyEmailRequiredAction.class); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
public static final String PROVIDER_ID = "CUSTOM_VERIFY_EMAIL"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
@Override | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
public void evaluateTriggers(RequiredActionContext context) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
if (!context.getUser().isEmailVerified()) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
context.getUser().addRequiredAction(PROVIDER_ID); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
@Override | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
public void requiredActionChallenge(RequiredActionContext context) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
// Only send code if one doesn't already exist | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
if (context.getUser().getFirstAttribute("email_verification_code") == null) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
sendVerificationCode(context); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Response challenge = context.form() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
.setAttribute("user", context.getUser()) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
.createForm("verify-email.ftl"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
context.challenge(challenge); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
private enum VerificationCodeStatus { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
VALID, INVALID, EXPIRED | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
private VerificationCodeStatus checkVerificationCode(RequiredActionContext context, String code) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
String storedCode = context.getUser().getFirstAttribute("email_verification_code"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
String timestampStr = context.getUser().getFirstAttribute("email_code_timestamp"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
if (storedCode == null || timestampStr == null) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
return VerificationCodeStatus.INVALID; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
long timestamp = Long.parseLong(timestampStr); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
long now = System.currentTimeMillis(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
if (now - timestamp > 15 * 60 * 1000) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The expiration time (15 minutes) is hardcoded as a magic number. Consider extracting this to a configurable constant for better maintainability.
Suggested change
Copilot uses AI. Check for mistakes. Positive FeedbackNegative Feedback |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
return VerificationCodeStatus.EXPIRED; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
} catch (NumberFormatException e) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
return VerificationCodeStatus.INVALID; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
return Objects.equals(code, storedCode) ? VerificationCodeStatus.VALID : VerificationCodeStatus.INVALID; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
@Override | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
public void processAction(RequiredActionContext context) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
String code = context.getHttpRequest().getDecodedFormParameters().getFirst("email_code"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
String resend = context.getHttpRequest().getDecodedFormParameters().getFirst("resend"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
if ("true".equals(resend)) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
sendVerificationCode(context); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
requiredActionChallenge(context); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
return; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
VerificationCodeStatus status = checkVerificationCode(context, code); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
if (status == VerificationCodeStatus.VALID) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
context.getUser().setEmailVerified(true); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
context.getUser().removeAttribute("email_verification_code"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
context.getUser().removeAttribute("email_code_timestamp"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
context.success(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
} else if (status == VerificationCodeStatus.EXPIRED) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
context.getUser().removeAttribute("email_verification_code"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
context.getUser().removeAttribute("email_code_timestamp"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
sendVerificationCode(context); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Response challenge = context.form() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
.setError("Your verification code has expired. A new code has been sent to your email.") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
.setAttribute("user", context.getUser()) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
.createForm("verify-email.ftl"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
context.challenge(challenge); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
} else { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Response challenge = context.form() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
.setError("Invalid verification code. Please check your email and try again.") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
.setAttribute("user", context.getUser()) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
.createForm("verify-email.ftl"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Comment on lines
+94
to
+100
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Template name 'verify-email.ftl' doesn't match the actual template file 'login-verify-email.ftl'. This will cause template resolution to fail.
Suggested change
Copilot uses AI. Check for mistakes. Positive FeedbackNegative Feedback
Comment on lines
+94
to
+100
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Template name 'verify-email.ftl' doesn't match the actual template file 'login-verify-email.ftl'. This will cause template resolution to fail.
Suggested change
Copilot uses AI. Check for mistakes. Positive FeedbackNegative Feedback |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
context.challenge(challenge); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
private void sendVerificationCode(RequiredActionContext context) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
String code = generateNumericCode(6); // 6-digit numeric code | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
long timestamp = System.currentTimeMillis(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
context.getUser().setSingleAttribute("email_verification_code", code); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
context.getUser().setSingleAttribute("email_code_timestamp", String.valueOf(timestamp)); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
EmailTemplateProvider emailProvider = context.getSession().getProvider(EmailTemplateProvider.class); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Map<String, Object> attributes = new HashMap<>(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
attributes.put("code", code); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
emailProvider.setRealm(context.getRealm()) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
.setUser(context.getUser()) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
.send("emailVerificationSubject", "email-verification.ftl", attributes); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The email template reference uses 'email-verification.ftl' but the actual file is 'email-verification.mjml'. This will cause template resolution to fail.
Suggested change
Copilot uses AI. Check for mistakes. Positive FeedbackNegative Feedback |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
} catch (EmailException e) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
logger.error("Failed to send verification email", e); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
private String generateNumericCode(int length) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
StringBuilder sb = new StringBuilder(length); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
SecureRandom random = new SecureRandom(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
for (int i = 0; i < length; i++) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
sb.append(random.nextInt(10)); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
return sb.toString(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
@Override | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
public void close() { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
public static class Factory implements RequiredActionFactory { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
@Override | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
public RequiredActionProvider create(KeycloakSession session) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
return new CustomVerifyEmailRequiredAction(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
@Override | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
public String getDisplayText() { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
return "Custom Email Verification with Code"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
@Override | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
public String getId() { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
return PROVIDER_ID; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
@Override | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
public void init(org.keycloak.Config.Scope config) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
// No initialization needed | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
@Override | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
public void postInit(org.keycloak.models.KeycloakSessionFactory factory) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
// No post-initialization needed | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
@Override | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
public void close() { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
// No resources to clean up | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
edu.mit.ol.keycloak.providers.CustomVerifyEmailRequiredAction$Factory |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -6,15 +6,50 @@ | |
<p class="instruction"> | ||
${msg("emailVerifyInstruction1", user.email)} | ||
</p> | ||
|
||
<!-- Verification code form --> | ||
<form id="kc-verify-email-code-form" class="${properties.kcFormClass!}" action="${url.loginAction}" method="post"> | ||
<div class="${properties.kcFormGroupClass!}"> | ||
<div class="${properties.kcLabelWrapperClass!}"> | ||
<label for="email_code" class="${properties.kcLabelClass!}">Verification Code</label> | ||
</div> | ||
<div class="${properties.kcInputWrapperClass!}"> | ||
<input type="text" id="email_code" name="email_code" class="${properties.kcInputClass!}" | ||
placeholder="Enter 6-digit code" maxlength="6" autofocus autocomplete="off" | ||
style="text-align: center; font-size: 18px; letter-spacing: 2px;" /> | ||
</div> | ||
</div> | ||
<div class="${properties.kcFormGroupClass!}"> | ||
<div id="kc-form-buttons" class="${properties.kcFormButtonsClass!}"> | ||
<input class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!}" | ||
name="submit" type="submit" value="Verify Email"/> | ||
</div> | ||
</div> | ||
</form> | ||
|
||
<!-- Resend code form --> | ||
<div style="margin-top: 15px; text-align: center;"> | ||
<form action="${url.loginAction}" method="post" style="display: inline;"> | ||
<input type="hidden" name="resend" value="true"/> | ||
<button type="submit" class="${properties.kcButtonClass!} ${properties.kcButtonDefaultClass!}"> | ||
Resend Code | ||
</button> | ||
</form> | ||
</div> | ||
|
||
<#elseif section = "info"> | ||
<div id="kc-info" class="${properties.kcSignUpClass!}"> | ||
<div id="kc-info-wrapper" class="${properties.kcInfoAreaWrapperClass!}"> | ||
<p class="pf-v5-u-my-md">${msg("emailVerifyInstruction2")}</p> | ||
<p class="pf-v5-u-my-md">A 6-digit verification code has been sent to your email address.</p> | ||
<hr /> | ||
<p class="pf-v5-u-my-md">${msg("emailVerifyInstruction3")}</p> | ||
<p class="pf-v5-u-my-md">Enter the code from your email to verify your account.</p> | ||
<p class="pf-v5-u-my-md"> | ||
<b>${msg("emailVerifyInstruction4Bold")}</b> | ||
<b>Code not working?</b> | ||
${msg("emailVerifyInstruction4")} | ||
</p> | ||
<p class="pf-v5-u-my-md"> | ||
<b>Didn't receive the code?</b> | ||
Check your spam folder or click "Resend Code" above. | ||
<a href="#">${msg("emailVerifySupportLinkTitle")}</a>. | ||
</p> | ||
Comment on lines
+43
to
54
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This template contains several hardcoded, user-facing strings (e.g., "A 6-digit verification code...", "Code not working?", etc.). For better maintainability and to support internationalization (i18n), it's a best practice in Keycloak theming to move these strings to a message properties file (e.g.,
|
||
</div> | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Template name 'verify-email.ftl' doesn't match the actual template file 'login-verify-email.ftl'. This will cause template resolution to fail.
Copilot uses AI. Check for mistakes.