diff --git a/ol-keycloak/oltheme/pom.xml b/ol-keycloak/oltheme/pom.xml
index b0dfa32..6d0dca0 100644
--- a/ol-keycloak/oltheme/pom.xml
+++ b/ol-keycloak/oltheme/pom.xml
@@ -9,6 +9,28 @@
UTF-8
1.8
1.8
+ 26.3.0
OL theme
+
+
+
+ org.keycloak
+ keycloak-server-spi
+ ${keycloak.version}
+ provided
+
+
+ org.keycloak
+ keycloak-server-spi-private
+ ${keycloak.version}
+ provided
+
+
+ org.keycloak
+ keycloak-services
+ ${keycloak.version}
+ provided
+
+
diff --git a/ol-keycloak/oltheme/src/main/java/edu/mit/ol/keycloak/providers/CustomVerifyEmailRequiredAction.java b/ol-keycloak/oltheme/src/main/java/edu/mit/ol/keycloak/providers/CustomVerifyEmailRequiredAction.java
new file mode 100644
index 0000000..07f10ae
--- /dev/null
+++ b/ol-keycloak/oltheme/src/main/java/edu/mit/ol/keycloak/providers/CustomVerifyEmailRequiredAction.java
@@ -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) {
+ 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");
+ context.challenge(challenge);
+ }
+ }
+
+ private void sendVerificationCode(RequiredActionContext context) {
+ String code = generateNumericCode(6); // 6-digit numeric code
+ 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 attributes = new HashMap<>();
+ attributes.put("code", code);
+ emailProvider.setRealm(context.getRealm())
+ .setUser(context.getUser())
+ .send("emailVerificationSubject", "email-verification.ftl", attributes);
+ } 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
+ }
+ }
+}
diff --git a/ol-keycloak/oltheme/src/main/resources/META-INF/services/org.keycloak.authentication.RequiredActionFactory b/ol-keycloak/oltheme/src/main/resources/META-INF/services/org.keycloak.authentication.RequiredActionFactory
new file mode 100644
index 0000000..a256d4b
--- /dev/null
+++ b/ol-keycloak/oltheme/src/main/resources/META-INF/services/org.keycloak.authentication.RequiredActionFactory
@@ -0,0 +1 @@
+edu.mit.ol.keycloak.providers.CustomVerifyEmailRequiredAction$Factory
diff --git a/ol-keycloak/oltheme/src/main/resources/theme/ol/email/mjml/email-verification.mjml b/ol-keycloak/oltheme/src/main/resources/theme/ol/email/mjml/email-verification.mjml
index 0eedba9..ff95ce7 100644
--- a/ol-keycloak/oltheme/src/main/resources/theme/ol/email/mjml/email-verification.mjml
+++ b/ol-keycloak/oltheme/src/main/resources/theme/ol/email/mjml/email-verification.mjml
@@ -3,7 +3,6 @@
-
@@ -12,6 +11,7 @@
Thank you for creating an account with ${realmName}. Please complete
the account verification process by clicking this link:
+
Verify Your Email
@@ -21,7 +21,6 @@
-
diff --git a/ol-keycloak/oltheme/src/main/resources/theme/ol/login/login-verify-email.ftl b/ol-keycloak/oltheme/src/main/resources/theme/ol/login/login-verify-email.ftl
index bb23f5e..1cba92f 100755
--- a/ol-keycloak/oltheme/src/main/resources/theme/ol/login/login-verify-email.ftl
+++ b/ol-keycloak/oltheme/src/main/resources/theme/ol/login/login-verify-email.ftl
@@ -6,15 +6,50 @@
${msg("emailVerifyInstruction1", user.email)}
+
+
+
+
+
+
+
+
+
<#elseif section = "info">
-
${msg("emailVerifyInstruction2")}
+
A 6-digit verification code has been sent to your email address.
-
${msg("emailVerifyInstruction3")}
+
Enter the code from your email to verify your account.
- ${msg("emailVerifyInstruction4Bold")}
+ Code not working?
${msg("emailVerifyInstruction4")}
+
+
+ Didn't receive the code?
+ Check your spam folder or click "Resend Code" above.
${msg("emailVerifySupportLinkTitle")}.