From 6fbb7ee562b0de2cef84aee49099418828cf5f81 Mon Sep 17 00:00:00 2001 From: Thomas Darimont Date: Tue, 7 Nov 2023 20:17:54 +0100 Subject: [PATCH] Keycloak: Revise Email Address Update - Add email notification on successful email change - Add support for sending generic account update notifications - Revise email texts --- .../custom/account/AccountActivity.java | 29 ++++++++++++------- .../custom/account/AccountChange.java | 11 +++++++ .../custom/audit/AcmeAuditListener.java | 20 ++++++++----- .../UpdateEmailRequiredAction.java | 10 ++++--- .../email/html/acme-account-updated.ftl | 4 +++ .../themes/internal/email/html/template.ftl | 6 ++-- .../email/messages/messages_de.properties | 12 +++++--- .../email/messages/messages_en.properties | 8 +++-- .../email/text/acme-account-updated.ftl | 5 ++++ .../login/messages/messages_de.properties | 2 ++ .../login/messages/messages_en.properties | 2 ++ 11 files changed, 77 insertions(+), 32 deletions(-) create mode 100644 keycloak/extensions/src/main/java/com/github/thomasdarimont/keycloak/custom/account/AccountChange.java create mode 100644 keycloak/themes/internal/email/html/acme-account-updated.ftl create mode 100644 keycloak/themes/internal/email/text/acme-account-updated.ftl diff --git a/keycloak/extensions/src/main/java/com/github/thomasdarimont/keycloak/custom/account/AccountActivity.java b/keycloak/extensions/src/main/java/com/github/thomasdarimont/keycloak/custom/account/AccountActivity.java index 00e24093..0c3ff899 100644 --- a/keycloak/extensions/src/main/java/com/github/thomasdarimont/keycloak/custom/account/AccountActivity.java +++ b/keycloak/extensions/src/main/java/com/github/thomasdarimont/keycloak/custom/account/AccountActivity.java @@ -3,6 +3,7 @@ import com.github.thomasdarimont.keycloak.custom.auth.mfa.MfaInfo; import com.github.thomasdarimont.keycloak.custom.auth.trusteddevice.action.TrustedDeviceInfo; import com.github.thomasdarimont.keycloak.custom.support.RealmUtils; +import jakarta.ws.rs.core.UriInfo; import lombok.extern.jbosslog.JBossLog; import org.keycloak.credential.CredentialModel; import org.keycloak.email.EmailException; @@ -13,7 +14,6 @@ import org.keycloak.models.UserModel; import org.keycloak.models.credential.OTPCredentialModel; -import jakarta.ws.rs.core.UriInfo; import java.net.URI; import java.util.List; @@ -23,20 +23,19 @@ public class AccountActivity { public static void onUserMfaChanged(KeycloakSession session, RealmModel realm, UserModel user, CredentialModel credential, MfaChange change) { try { - var realmDisplayName = RealmUtils.getDisplayName(realm); var credentialLabel = getCredentialLabel(credential); var mfaInfo = new MfaInfo(credential.getType(), credentialLabel); switch (change) { case ADD: AccountEmail.send(session.getProvider(EmailTemplateProvider.class), realm, user, (emailTemplateProvider, attributes) -> { attributes.put("mfaInfo", mfaInfo); - emailTemplateProvider.send("acmeMfaAddedSubject", List.of(realmDisplayName), "acme-mfa-added.ftl", attributes); + emailTemplateProvider.send("acmeMfaAddedSubject", List.of(RealmUtils.getDisplayName(realm)), "acme-mfa-added.ftl", attributes); }); break; case REMOVE: AccountEmail.send(session.getProvider(EmailTemplateProvider.class), realm, user, (emailTemplateProvider, attributes) -> { attributes.put("mfaInfo", mfaInfo); - emailTemplateProvider.send("acmeMfaRemovedSubject", List.of(realmDisplayName), "acme-mfa-removed.ftl", attributes); + emailTemplateProvider.send("acmeMfaRemovedSubject", List.of(RealmUtils.getDisplayName(realm)), "acme-mfa-removed.ftl", attributes); }); break; default: @@ -48,12 +47,11 @@ public static void onUserMfaChanged(KeycloakSession session, RealmModel realm, U } public static void onAccountDeletionRequested(KeycloakSession session, RealmModel realm, UserModel user, UriInfo uriInfo) { - var realmDisplayName = RealmUtils.getDisplayName(realm); try { URI actionTokenUrl = AccountDeletion.createActionToken(session, realm, user, uriInfo); AccountEmail.send(session.getProvider(EmailTemplateProvider.class), realm, user, (emailTemplateProvider, attributes) -> { attributes.put("actionTokenUrl", actionTokenUrl); - emailTemplateProvider.send("acmeAccountDeletionRequestedSubject", List.of(realmDisplayName), "acme-account-deletion-requested.ftl", attributes); + emailTemplateProvider.send("acmeAccountDeletionRequestedSubject", List.of(RealmUtils.getDisplayName(realm)), "acme-account-deletion-requested.ftl", attributes); }); log.infof("Requested user account deletion. realm=%s userId=%s", realm.getName(), user.getId()); } catch (EmailException e) { @@ -63,19 +61,18 @@ public static void onAccountDeletionRequested(KeycloakSession session, RealmMode public static void onTrustedDeviceChange(KeycloakSession session, RealmModel realm, UserModel user, TrustedDeviceInfo trustedDeviceInfo, MfaChange change) { try { - var realmDisplayName = RealmUtils.getDisplayName(realm); switch (change) { case ADD: AccountEmail.send(session.getProvider(EmailTemplateProvider.class), realm, user, (emailTemplateProvider, attributes) -> { attributes.put("trustedDeviceInfo", trustedDeviceInfo); - emailTemplateProvider.send("acmeTrustedDeviceAddedSubject", List.of(realmDisplayName), "acme-trusted-device-added.ftl", attributes); + emailTemplateProvider.send("acmeTrustedDeviceAddedSubject", List.of(RealmUtils.getDisplayName(realm)), "acme-trusted-device-added.ftl", attributes); }); break; case REMOVE: AccountEmail.send(session.getProvider(EmailTemplateProvider.class), realm, user, (emailTemplateProvider, attributes) -> { attributes.put("trustedDeviceInfo", trustedDeviceInfo); - emailTemplateProvider.send("acmeTrustedDeviceRemovedSubject", List.of(realmDisplayName), "acme-trusted-device-removed.ftl", attributes); + emailTemplateProvider.send("acmeTrustedDeviceRemovedSubject", List.of(RealmUtils.getDisplayName(realm)), "acme-trusted-device-removed.ftl", attributes); }); break; default: @@ -87,17 +84,27 @@ public static void onTrustedDeviceChange(KeycloakSession session, RealmModel rea } public static void onAccountLockedOut(KeycloakSession session, RealmModel realm, UserModel user, UserLoginFailureModel userLoginFailure) { - var realmDisplayName = RealmUtils.getDisplayName(realm); try { AccountEmail.send(session.getProvider(EmailTemplateProvider.class), realm, user, (emailTemplateProvider, attributes) -> { attributes.put("userLoginFailure", userLoginFailure); - emailTemplateProvider.send("acmeAccountBlockedSubject", List.of(realmDisplayName), "acme-account-blocked.ftl", attributes); + emailTemplateProvider.send("acmeAccountBlockedSubject", List.of(RealmUtils.getDisplayName(realm)), "acme-account-blocked.ftl", attributes); }); } catch (EmailException e) { log.errorf(e, "Failed to send email for user account block. userId=%s", userLoginFailure.getUserId()); } } + public static void onAccountUpdate(KeycloakSession session, RealmModel realm, UserModel user, AccountChange update) { + try { + AccountEmail.send(session.getProvider(EmailTemplateProvider.class), realm, user, (emailTemplateProvider, attributes) -> { + attributes.put("update", update); + emailTemplateProvider.send("acmeAccountUpdatedSubject", List.of(RealmUtils.getDisplayName(realm)), "acme-account-updated.ftl", attributes); + }); + } catch (EmailException e) { + log.errorf(e, "Failed to send email for user account update. userId=%s", user.getId()); + } + } + private static String getCredentialLabel(CredentialModel credential) { var type = credential.getType(); diff --git a/keycloak/extensions/src/main/java/com/github/thomasdarimont/keycloak/custom/account/AccountChange.java b/keycloak/extensions/src/main/java/com/github/thomasdarimont/keycloak/custom/account/AccountChange.java new file mode 100644 index 00000000..11f290be --- /dev/null +++ b/keycloak/extensions/src/main/java/com/github/thomasdarimont/keycloak/custom/account/AccountChange.java @@ -0,0 +1,11 @@ +package com.github.thomasdarimont.keycloak.custom.account; + +import lombok.Data; + +@Data +public class AccountChange { + + private final String changedAttribute; + + private final String changedValue; +} diff --git a/keycloak/extensions/src/main/java/com/github/thomasdarimont/keycloak/custom/audit/AcmeAuditListener.java b/keycloak/extensions/src/main/java/com/github/thomasdarimont/keycloak/custom/audit/AcmeAuditListener.java index 5c63ec3e..0362df60 100644 --- a/keycloak/extensions/src/main/java/com/github/thomasdarimont/keycloak/custom/audit/AcmeAuditListener.java +++ b/keycloak/extensions/src/main/java/com/github/thomasdarimont/keycloak/custom/audit/AcmeAuditListener.java @@ -1,6 +1,7 @@ package com.github.thomasdarimont.keycloak.custom.audit; import com.github.thomasdarimont.keycloak.custom.account.AccountActivity; +import com.github.thomasdarimont.keycloak.custom.account.AccountChange; import com.github.thomasdarimont.keycloak.custom.account.MfaChange; import com.github.thomasdarimont.keycloak.custom.support.CredentialUtils; import com.google.auto.service.AutoService; @@ -49,18 +50,21 @@ private void processUserEventAfterTransaction(Event event) { var authSession = context.getAuthenticationSession(); var user = authSession == null ? null : authSession.getAuthenticatedUser(); + if (user == null) { + return; + } + switch (event.getType()) { + case UPDATE_EMAIL: + AccountActivity.onAccountUpdate(session, realm, user, new AccountChange("email", user.getEmail())); + break; case UPDATE_TOTP: - if (user != null) { - CredentialUtils.findFirstOtpCredential(user).ifPresent(credential -> // - AccountActivity.onUserMfaChanged(session, realm, user, credential, MfaChange.ADD)); - } + CredentialUtils.findFirstOtpCredential(user).ifPresent(credential -> // + AccountActivity.onUserMfaChanged(session, realm, user, credential, MfaChange.ADD)); break; case REMOVE_TOTP: - if (user != null) { - CredentialUtils.findFirstOtpCredential(user).ifPresent(credential -> // - AccountActivity.onUserMfaChanged(session, realm, user, credential, MfaChange.REMOVE)); - } + CredentialUtils.findFirstOtpCredential(user).ifPresent(credential -> // + AccountActivity.onUserMfaChanged(session, realm, user, credential, MfaChange.REMOVE)); break; } } catch (Exception ex) { diff --git a/keycloak/extensions/src/main/java/com/github/thomasdarimont/keycloak/custom/profile/emailupdate/UpdateEmailRequiredAction.java b/keycloak/extensions/src/main/java/com/github/thomasdarimont/keycloak/custom/profile/emailupdate/UpdateEmailRequiredAction.java index 91d87c3d..bd0add07 100644 --- a/keycloak/extensions/src/main/java/com/github/thomasdarimont/keycloak/custom/profile/emailupdate/UpdateEmailRequiredAction.java +++ b/keycloak/extensions/src/main/java/com/github/thomasdarimont/keycloak/custom/profile/emailupdate/UpdateEmailRequiredAction.java @@ -103,17 +103,16 @@ public void processAction(RequiredActionContext context) { // TODO trigger email verification via email // user submitted the form MultivaluedMap formData = context.getHttpRequest().getDecodedFormParameters(); - EventBuilder event = context.getEvent().clone().event(EventType.UPDATE_EMAIL); + AuthenticationSessionModel authSession = context.getAuthenticationSession(); RealmModel realm = context.getRealm(); UserModel currentUser = context.getUser(); KeycloakSession session = context.getSession(); + String oldEmail = currentUser.getEmail(); String newEmail = String.valueOf(formData.getFirst(EMAIL_FIELD)).trim(); - event.detail(Details.EMAIL, newEmail); - - EventBuilder errorEvent = event.clone().event(EventType.UPDATE_EMAIL_ERROR) + EventBuilder errorEvent = context.getEvent().clone().event(EventType.UPDATE_EMAIL_ERROR) .client(authSession.getClient()) .user(authSession.getAuthenticatedUser()); @@ -211,6 +210,9 @@ public String getEmail() { currentUser.setEmailVerified(true); currentUser.removeRequiredAction(ID); + EventBuilder event = context.getEvent().clone().event(EventType.UPDATE_EMAIL); + event.detail("email_old", oldEmail); + event.detail(Details.EMAIL, newEmail); event.success(); context.success(); diff --git a/keycloak/themes/internal/email/html/acme-account-updated.ftl b/keycloak/themes/internal/email/html/acme-account-updated.ftl new file mode 100644 index 00000000..0873cb0f --- /dev/null +++ b/keycloak/themes/internal/email/html/acme-account-updated.ftl @@ -0,0 +1,4 @@ +<#import "template.ftl" as layout> +<@layout.emailLayout> +${kcSanitize(msg("acmeAccountUpdatedBodyHtml",user.username,update.changedAttribute,update.changedValue))?no_esc} + diff --git a/keycloak/themes/internal/email/html/template.ftl b/keycloak/themes/internal/email/html/template.ftl index ac174d82..0b3d1339 100644 --- a/keycloak/themes/internal/email/html/template.ftl +++ b/keycloak/themes/internal/email/html/template.ftl @@ -2,13 +2,13 @@
- Acme Header +Acme Header
- <#nested> +<#nested>
- Acme Footer +Acme Footer
diff --git a/keycloak/themes/internal/email/messages/messages_de.properties b/keycloak/themes/internal/email/messages/messages_de.properties index eab0c31c..faf38e50 100644 --- a/keycloak/themes/internal/email/messages/messages_de.properties +++ b/keycloak/themes/internal/email/messages/messages_de.properties @@ -2,18 +2,18 @@ eventUpdateTotpSubject=2-Faktor Authentifizierung (OTP) Aktualisiert eventUpdateTotpBody=2-Faktor Authentifizierung (OTP) wurde am {0} von {1} ge\u00E4ndert. Falls das nicht Sie waren, dann kontaktieren Sie bitte Ihren Admin. eventUpdateTotpBodyHtml=

2-Faktor Authentifizierung (OTP) wurde am {0} von {1} ge\u00E4ndert. Falls das nicht Sie waren, dann kontaktieren Sie bitte Ihren Admin.

-acmeEmailVerifySubject=Verifizierung der Email \u00c4nderung f\u00fcr {0} +acmeEmailVerifySubject=Verifizierung der Email \u00c4nderung f\u00fcr {0} Benutzerkonto acmeEmailVerificationBodyCode=Bitte verifizieren Sie Ihre E-Mail-Adresse, indem Sie den folgenden Code eingeben.\n\nCode: {0}\n\n. acmeEmailVerificationBodyCodeHtml=

Bitte verifizieren Sie Ihre E-Mail-Adresse, indem Sie den folgenden Code eingeben.

Code: {0}

-acmeTrustedDeviceAddedSubject=Neues vertrautes Ger\u00e4t hinzugef\u00fcgt f\u00fcr {0} +acmeTrustedDeviceAddedSubject=Neues vertrautes Ger\u00e4t hinzugef\u00fcgt f\u00fcr {0} Benutzerkonto acmeTrustedDeviceAddedBody=Ein neues vertrautes Ger\u00e4t mit dem Namen {1} wurde ihrem Konto hinzugef\u00fcgt. acmeTrustedDeviceAddedBodyHtml=

Ein neues vertrautes Ger\u00e4t mit dem Namen {1} wurde ihrem Konto hinzugef\u00fcgt.

-acmeTrustedDeviceRemovedSubject=Vertrautes Ger\u00e4t entfernt f\u00fcr {0} +acmeTrustedDeviceRemovedSubject=Vertrautes Ger\u00e4t entfernt f\u00fcr {0} Benutzerkonto acmeTrustedDeviceRemovedBody=Ein vertrautes Ger\u00e4t mit dem Namen {1} wurde aus ihrem Konto entfernt. acmeTrustedDeviceRemovedBodyHtml=

Ein vertrautes Ger\u00e4t mit dem Namen {1} wurde aus ihrem Konto entfernt.

-acmeMfaAddedSubject=Neue Zweifaktorauthentifizierung hinzugef\u00fcgt f\u00fcr {0} +acmeMfaAddedSubject=Neue Zweifaktorauthentifizierung hinzugef\u00fcgt f\u00fcr {0} Benutzerkonto acmeMfaAddedBody=Eine neue Zweifaktorauthentifizierung vom Typ {1} wurde ihrem Konto hinzugef\u00fcgt. acmeMfaAddedBodyHtml=

Eine neue Zweifaktorauthentifizierung vom Typ {1} wurde ihrem Konto hinzugef\u00fcgt.

acmeMfaRemovedSubject=Zweifaktorauthentifizierung entfernt f\u00fcr {0} @@ -28,6 +28,10 @@ acmeAccountBlockedSubject=Sperrung ihres {0} Benutzerkontos acmeAccountBlockedBody=Wegen zu vieler ung\u00fcltiger Anmeldeversuche wurde ihr Benutzerkonto {0} gesperrt. Bitte wenden Sie sich an den Support. acmeAccountBlockedBodyHtml=Wegen zu vieler ung\u00fcltiger Anmeldeversuche wurde ihr Benutzerkonto {0} gesperrt. Bitte wenden Sie sich an den Support. +acmeAccountUpdatedSubject=Aktualisierung ihres {0} Benutzerkontos +acmeAccountUpdatedBody=Ihr Benutzerkonto {0} wurde aktualisiert.\n\n{1} -> {2}\n\n +acmeAccountUpdatedBodyHtml=

Ihr Benutzerkonto {0} wurde aktualisiert.

{1} -> {2}

+ # realmDisplayName, userDisplayName acmeWelcomeSubject=Willkommen bei {0} diff --git a/keycloak/themes/internal/email/messages/messages_en.properties b/keycloak/themes/internal/email/messages/messages_en.properties index 027561aa..e1f60334 100644 --- a/keycloak/themes/internal/email/messages/messages_en.properties +++ b/keycloak/themes/internal/email/messages/messages_en.properties @@ -2,7 +2,7 @@ eventUpdateTotpSubject=2nd Factor Authentication (OTP) Updated eventUpdateTotpBody=2nd Factor Authentication (OTP) was updated for your account on {0} from {1}. If this was not you, please contact an administrator. eventUpdateTotpBodyHtml=

2nd Factor Authentication (OTP) was updated for your account on {0} from {1}. If this was not you, please contact an administrator.

-acmeEmailVerifySubject=Verify email update for {0} +acmeEmailVerifySubject=Verify email update for {0} Account acmeEmailVerificationBodyCode=Please verify your email address by entering in the following code.\n\nCode: {0} acmeEmailVerificationBodyCodeHtml=

Please verify your email address by entering in the following code.

Code: {0}

@@ -24,10 +24,14 @@ acmeAccountDeletionRequestedSubject={0} Account Deletion acmeAccountDeletionRequestedBody=Please confirm the deletion of your User account {0} by clicking on the following link.\n\nLink: {1}.\n\n acmeAccountDeletionRequestedBodyHtml=

Please confirm the deletion of your User account {0} by clicking on the following link.

Link: Confirm Account Deletion.

-acmeAccountBlockedSubject={0} User account locked +acmeAccountBlockedSubject={0} Account Locked acmeAccountBlockedBody=Due to too many invalid login attempts, your user account {0} has been locked. Please contact support. acmeAccountBlockedBodyHtml=Due to too many invalid login attempts, your user account {0} has been locked. Please contact support. +acmeAccountUpdatedSubject={0} Account Updated +acmeAccountUpdatedBody=Your account {0} was updated.\n\n{1} -> {2}\n\n +acmeAccountUpdatedBodyHtml=

Your account {0} was updated.

{1} -> {2}

+ acmeWelcomeSubject=Welcome to {0} acmeWelcomeBody=Hello {2}, welcome to {0}. Username: {1} acmeWelcomeBodyHtml=Hello {2}, welcome to {0}. Username: {1} diff --git a/keycloak/themes/internal/email/text/acme-account-updated.ftl b/keycloak/themes/internal/email/text/acme-account-updated.ftl new file mode 100644 index 00000000..921ae069 --- /dev/null +++ b/keycloak/themes/internal/email/text/acme-account-updated.ftl @@ -0,0 +1,5 @@ +<#ftl output_format="plainText"> +<#import "template.ftl" as layout> +<@layout.emailLayout> +${msg("acmeAccountUpdatedBodyHtml",user.username,update.changedAttribute,update.changedValue)} + diff --git a/keycloak/themes/internal/login/messages/messages_de.properties b/keycloak/themes/internal/login/messages/messages_de.properties index 1c4db552..0072e741 100644 --- a/keycloak/themes/internal/login/messages/messages_de.properties +++ b/keycloak/themes/internal/login/messages/messages_de.properties @@ -89,5 +89,7 @@ mfa-email-code-form-help-text=Geben Sie einen Verifizierungscode aus einer E-Mai acme-email-code-form-display-name=E-Mail Code Authentifizierung acme-email-code-form-help-text=Geben Sie einen Verifizierungscode aus einer E-Mail ein. +error-invalid-code=Code ung\u00fcltig + acmeMagicLinkTitle=Anmeldelink acmeMagicLinkText=Wir haben Ihnen einen Anmeldelink per E-Mail geschickt. Bitte pr\u00fcfen Sie Ihren Posteingang. diff --git a/keycloak/themes/internal/login/messages/messages_en.properties b/keycloak/themes/internal/login/messages/messages_en.properties index 62e37e7a..44466102 100644 --- a/keycloak/themes/internal/login/messages/messages_en.properties +++ b/keycloak/themes/internal/login/messages/messages_en.properties @@ -88,5 +88,7 @@ mfa-email-code-form-help-text=Enter a valid access code sent via email. acme-email-code-form-display-name=Email Code acme-email-code-form-help-text=Enter a valid access code sent via email. +error-invalid-code=Invalid code + acmeMagicLinkTitle=Magic Link acmeMagicLinkText=We sent you a login link via email. Check your inbox for details. \ No newline at end of file