Skip to content

Commit

Permalink
Keycloak: Revise Email Address Update
Browse files Browse the repository at this point in the history
- Add email notification on successful email change
- Add support for sending generic account update notifications
- Revise email texts
  • Loading branch information
thomasdarimont committed Nov 7, 2023
1 parent 88886bf commit 6fbb7ee
Show file tree
Hide file tree
Showing 11 changed files with 77 additions and 32 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand All @@ -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:
Expand All @@ -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) {
Expand All @@ -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:
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,17 +103,16 @@ public void processAction(RequiredActionContext context) {
// TODO trigger email verification via email
// user submitted the form
MultivaluedMap<String, String> 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());

Expand Down Expand Up @@ -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();
Expand Down
4 changes: 4 additions & 0 deletions keycloak/themes/internal/email/html/acme-account-updated.ftl
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<#import "template.ftl" as layout>
<@layout.emailLayout>
${kcSanitize(msg("acmeAccountUpdatedBodyHtml",user.username,update.changedAttribute,update.changedValue))?no_esc}
</@layout.emailLayout>
6 changes: 3 additions & 3 deletions keycloak/themes/internal/email/html/template.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@
<html>
<body>
<header>
Acme Header
Acme Header
</header>
<main>
<#nested>
<#nested>
</main>
<footer>
Acme Footer
Acme Footer
</footer>
</body>
</html>
Expand Down
12 changes: 8 additions & 4 deletions keycloak/themes/internal/email/messages/messages_de.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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=<p>2-Faktor Authentifizierung (OTP) wurde am {0} von {1} ge\u00E4ndert. Falls das nicht Sie waren, dann kontaktieren Sie bitte Ihren Admin.</p>

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=<p>Bitte verifizieren Sie Ihre E-Mail-Adresse, indem Sie den folgenden Code eingeben.</p><p><b>Code: {0}</b></p>

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=<p>Ein neues vertrautes Ger\u00e4t mit dem Namen <strong>{1}</strong> wurde ihrem Konto hinzugef\u00fcgt.</p>
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=<p>Ein vertrautes Ger\u00e4t mit dem Namen <strong>{1}</strong> wurde aus ihrem Konto entfernt.</p>

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=<p>Eine neue Zweifaktorauthentifizierung vom Typ <strong>{1}</strong> wurde ihrem Konto hinzugef\u00fcgt.</p>
acmeMfaRemovedSubject=Zweifaktorauthentifizierung entfernt f\u00fcr {0}
Expand All @@ -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 <strong>{0}</strong> 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=<p>Ihr Benutzerkonto <strong>{0}</strong> wurde aktualisiert.</p><p>{1} -&gt; {2}</p>

# realmDisplayName, userDisplayName
acmeWelcomeSubject=Willkommen bei {0}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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=<p>2nd Factor Authentication (OTP) was updated for your account on {0} from {1}. If this was not you, please contact an administrator.</p>

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=<p>Please verify your email address by entering in the following code.</p><p><b>Code: {0}</b></p>

Expand All @@ -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=<p>Please confirm the deletion of your User account {0} by clicking on the following link.</p><p>Link: <a href="{1}">Confirm Account Deletion</a>.</p>

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 <strong>{0}</strong> has been locked. Please contact support.

acmeAccountUpdatedSubject={0} Account Updated
acmeAccountUpdatedBody=Your account {0} was updated.\n\n{1} -> {2}\n\n
acmeAccountUpdatedBodyHtml=<p>Your account {0} was updated.</p><p>{1} -&gt; {2}</p>

acmeWelcomeSubject=Welcome to {0}
acmeWelcomeBody=Hello {2}, welcome to {0}. Username: {1}
acmeWelcomeBodyHtml=Hello {2}, welcome to {0}. Username: {1}
Expand Down
5 changes: 5 additions & 0 deletions keycloak/themes/internal/email/text/acme-account-updated.ftl
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<#ftl output_format="plainText">
<#import "template.ftl" as layout>
<@layout.emailLayout>
${msg("acmeAccountUpdatedBodyHtml",user.username,update.changedAttribute,update.changedValue)}
</@layout.emailLayout>
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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.

0 comments on commit 6fbb7ee

Please sign in to comment.