KEYCLOAK-4937 - convert time units in emails into human-friendly format

This commit is contained in:
Vlastimil Elias 2018-01-26 09:32:22 +01:00 committed by Hynek Mlnařík
parent 82815ff614
commit a5f675d693
13 changed files with 281 additions and 46 deletions

View file

@ -17,6 +17,16 @@
package org.keycloak.email.freemarker;
import java.io.IOException;
import java.text.MessageFormat;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Properties;
import org.keycloak.broker.provider.BrokeredIdentityContext;
import org.keycloak.common.util.ObjectUtil;
import org.keycloak.email.EmailException;
@ -34,25 +44,19 @@ import org.keycloak.theme.FreeMarkerException;
import org.keycloak.theme.FreeMarkerUtil;
import org.keycloak.theme.Theme;
import org.keycloak.theme.ThemeProvider;
import org.keycloak.theme.beans.LinkExpirationFormatterMethod;
import org.keycloak.theme.beans.MessageFormatterMethod;
import java.io.IOException;
import java.text.MessageFormat;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Properties;
/**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
*/
public class FreeMarkerEmailTemplateProvider implements EmailTemplateProvider {
protected KeycloakSession session;
/** authenticationSession can be null for some email sendings, it is filled only for email sendings performed as part of the authentication session (email verification, password reset, broker link etc.)! */
/**
* authenticationSession can be null for some email sendings, it is filled only for email sendings performed as part of the authentication session (email verification, password reset, broker link
* etc.)!
*/
protected AuthenticationSessionModel authenticationSession;
protected FreeMarkerUtil freeMarker;
protected RealmModel realm;
@ -109,8 +113,7 @@ public class FreeMarkerEmailTemplateProvider implements EmailTemplateProvider {
public void sendPasswordReset(String link, long expirationInMinutes) throws EmailException {
Map<String, Object> attributes = new HashMap<String, Object>(this.attributes);
attributes.put("user", new ProfileBean(user));
attributes.put("link", link);
attributes.put("linkExpiration", expirationInMinutes);
addLinkInfoIntoAttributes(link, expirationInMinutes, attributes);
attributes.put("realmName", getRealmName());
@ -134,8 +137,7 @@ public class FreeMarkerEmailTemplateProvider implements EmailTemplateProvider {
public void sendConfirmIdentityBrokerLink(String link, long expirationInMinutes) throws EmailException {
Map<String, Object> attributes = new HashMap<String, Object>(this.attributes);
attributes.put("user", new ProfileBean(user));
attributes.put("link", link);
attributes.put("linkExpiration", expirationInMinutes);
addLinkInfoIntoAttributes(link, expirationInMinutes, attributes);
attributes.put("realmName", getRealmName());
@ -154,8 +156,7 @@ public class FreeMarkerEmailTemplateProvider implements EmailTemplateProvider {
public void sendExecuteActions(String link, long expirationInMinutes) throws EmailException {
Map<String, Object> attributes = new HashMap<String, Object>(this.attributes);
attributes.put("user", new ProfileBean(user));
attributes.put("link", link);
attributes.put("linkExpiration", expirationInMinutes);
addLinkInfoIntoAttributes(link, expirationInMinutes, attributes);
attributes.put("realmName", getRealmName());
@ -166,14 +167,31 @@ public class FreeMarkerEmailTemplateProvider implements EmailTemplateProvider {
public void sendVerifyEmail(String link, long expirationInMinutes) throws EmailException {
Map<String, Object> attributes = new HashMap<String, Object>(this.attributes);
attributes.put("user", new ProfileBean(user));
attributes.put("link", link);
attributes.put("linkExpiration", expirationInMinutes);
addLinkInfoIntoAttributes(link, expirationInMinutes, attributes);
attributes.put("realmName", getRealmName());
send("emailVerificationSubject", "email-verification.ftl", attributes);
}
/**
* Add link info into template attributes.
*
* @param link to add
* @param expirationInMinutes to add
* @param attributes to add link info into
*/
protected void addLinkInfoIntoAttributes(String link, long expirationInMinutes, Map<String, Object> attributes) throws EmailException {
attributes.put("link", link);
attributes.put("linkExpiration", expirationInMinutes);
try {
Locale locale = session.getContext().resolveLocale(user);
attributes.put("linkExpirationFormatter", new LinkExpirationFormatterMethod(getTheme().getMessages(locale), locale));
} catch (IOException e) {
throw new EmailException("Failed to template email", e);
}
}
protected void send(String subjectKey, String template, Map<String, Object> attributes) throws EmailException {
send(subjectKey, Collections.emptyList(), template, attributes);
}

View file

@ -0,0 +1,75 @@
/*
* JBoss, Home of Professional Open Source
* Copyright 2018 Red Hat Inc. and/or its affiliates and other contributors
* as indicated by the @authors tag. All rights reserved.
*/
package org.keycloak.theme.beans;
import java.util.List;
import java.util.Locale;
import java.util.Properties;
import freemarker.template.TemplateMethodModelEx;
import freemarker.template.TemplateModelException;
/**
* Method used to format link expiration time period in emails.
*
* @author Vlastimil Elias (velias at redhat dot com)
*/
public class LinkExpirationFormatterMethod implements TemplateMethodModelEx {
protected final Properties messages;
protected final Locale locale;
public LinkExpirationFormatterMethod(Properties messages, Locale locale) {
this.messages = messages;
this.locale = locale;
}
@SuppressWarnings("rawtypes")
@Override
public Object exec(List arguments) throws TemplateModelException {
Object val = arguments.isEmpty() ? null : arguments.get(0);
if (val == null)
return "";
try {
//input value is in minutes, as defined in EmailTemplateProvider!
return format(Long.parseLong(val.toString().trim()) * 60);
} catch (NumberFormatException e) {
// not a number, return it as is
return val.toString();
}
}
protected String format(long valueInSeconds) {
String unitKey = "seconds";
long value = valueInSeconds;
if (value > 0 && value % 60 == 0) {
unitKey = "minutes";
value = value / 60;
if (value % 60 == 0) {
unitKey = "hours";
value = value / 60;
if (value % 24 == 0) {
unitKey = "days";
value = value / 24;
}
}
}
return value + " " + getUnitTextFromMessages(unitKey, value);
}
protected String getUnitTextFromMessages(String unitKey, long value) {
String msg = messages.getProperty("linkExpirationFormatter.timePeriodUnit." + unitKey + "." + value);
if (msg != null)
return msg;
return messages.getProperty("linkExpirationFormatter.timePeriodUnit." + unitKey);
}
}

View file

@ -0,0 +1,128 @@
/*
* JBoss, Home of Professional Open Source
* Copyright 2018 Red Hat Inc. and/or its affiliates and other contributors
* as indicated by the @authors tag. All rights reserved.
*/
package org.keycloak.theme.beans;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Properties;
import org.junit.Assert;
import org.junit.Test;
import freemarker.template.TemplateModelException;
/**
* @author Vlastimil Elias (velias at redhat dot com)
*/
public class LinkExpirationFormatterMethodTest {
protected static final Locale locale = Locale.ENGLISH;
protected static final Properties messages = new Properties();
static {
messages.put("linkExpirationFormatter.timePeriodUnit.seconds.1", "second");
messages.put("linkExpirationFormatter.timePeriodUnit.seconds", "seconds");
messages.put("linkExpirationFormatter.timePeriodUnit.minutes.1", "minute");
messages.put("linkExpirationFormatter.timePeriodUnit.minutes.3", "minutes-3");
messages.put("linkExpirationFormatter.timePeriodUnit.minutes", "minutes");
messages.put("linkExpirationFormatter.timePeriodUnit.hours.1", "hour");
messages.put("linkExpirationFormatter.timePeriodUnit.hours", "hours");
messages.put("linkExpirationFormatter.timePeriodUnit.days.1", "day");
messages.put("linkExpirationFormatter.timePeriodUnit.days", "days");
}
protected List<Object> toList(Object... objects) {
return Arrays.asList(objects);
}
@Test
public void inputtypes_null() throws TemplateModelException{
LinkExpirationFormatterMethod tested = new LinkExpirationFormatterMethod(messages, locale);
Assert.assertEquals("", tested.exec(Collections.emptyList()));
}
@Test
public void inputtypes_string_empty() throws TemplateModelException{
LinkExpirationFormatterMethod tested = new LinkExpirationFormatterMethod(messages, locale);
Assert.assertEquals("", tested.exec(toList("")));
Assert.assertEquals(" ", tested.exec(toList(" ")));
}
@Test
public void inputtypes_string_number() throws TemplateModelException{
LinkExpirationFormatterMethod tested = new LinkExpirationFormatterMethod(messages, locale);
Assert.assertEquals("2 minutes", tested.exec(toList("2")));
Assert.assertEquals("2 minutes", tested.exec(toList(" 2 ")));
}
@Test
public void inputtypes_string_notanumber() throws TemplateModelException{
LinkExpirationFormatterMethod tested = new LinkExpirationFormatterMethod(messages, locale);
Assert.assertEquals("ahoj", tested.exec(toList("ahoj")));
}
@Test
public void inputtypes_number() throws TemplateModelException{
LinkExpirationFormatterMethod tested = new LinkExpirationFormatterMethod(messages, locale);
Assert.assertEquals("5 minutes", tested.exec(toList(new Integer(5))));
Assert.assertEquals("5 minutes", tested.exec(toList(new Long(5))));
}
@Test
public void format_second_zero() throws TemplateModelException {
LinkExpirationFormatterMethod tested = new LinkExpirationFormatterMethod(messages, locale);
Assert.assertEquals("0 seconds", tested.exec(toList(0)));
}
@Test
public void format_minute_one() throws TemplateModelException {
LinkExpirationFormatterMethod tested = new LinkExpirationFormatterMethod(messages, locale);
Assert.assertEquals("1 minute", tested.exec(toList(1)));
}
@Test
public void format_minute_more() throws TemplateModelException {
LinkExpirationFormatterMethod tested = new LinkExpirationFormatterMethod(messages, locale);
Assert.assertEquals("2 minutes", tested.exec(toList(2)));
//test support for languages with more plurals depending on the value
Assert.assertEquals("3 minutes-3", tested.exec(toList(3)));
Assert.assertEquals("5 minutes", tested.exec(toList(5)));
Assert.assertEquals("24 minutes", tested.exec(toList(24)));
Assert.assertEquals("59 minutes", tested.exec(toList(59)));
Assert.assertEquals("61 minutes", tested.exec(toList(61)));
}
@Test
public void format_hour_one() throws TemplateModelException {
LinkExpirationFormatterMethod tested = new LinkExpirationFormatterMethod(messages, locale);
Assert.assertEquals("1 hour", tested.exec(toList(60)));
}
@Test
public void format_hour_more() throws TemplateModelException {
LinkExpirationFormatterMethod tested = new LinkExpirationFormatterMethod(messages, locale);
Assert.assertEquals("2 hours", tested.exec(toList(2 * 60)));
Assert.assertEquals("5 hours", tested.exec(toList(5 * 60)));
Assert.assertEquals("23 hours", tested.exec(toList(23 * 60)));
Assert.assertEquals("25 hours", tested.exec(toList(25 * 60)));
}
@Test
public void format_day_one() throws TemplateModelException {
LinkExpirationFormatterMethod tested = new LinkExpirationFormatterMethod(messages, locale);
Assert.assertEquals("1 day", tested.exec(toList(60 * 24)));
}
@Test
public void format_day_more() throws TemplateModelException {
LinkExpirationFormatterMethod tested = new LinkExpirationFormatterMethod(messages, locale);
Assert.assertEquals("2 days", tested.exec(toList(2 * 24 * 60)));
Assert.assertEquals("5 days", tested.exec(toList(5 * 24 * 60)));
}
}

View file

@ -685,11 +685,11 @@ public class UserTest extends AbstractAdminTest {
assertTrue(body.getText().contains("Update Password"));
assertTrue(body.getText().contains("your Admin-client-test account"));
assertTrue(body.getText().contains("This link will expire within 720 minutes"));
assertTrue(body.getText().contains("This link will expire within 12 hours"));
assertTrue(body.getHtml().contains("Update Password"));
assertTrue(body.getHtml().contains("your Admin-client-test account"));
assertTrue(body.getHtml().contains("This link will expire within 720 minutes"));
assertTrue(body.getHtml().contains("This link will expire within 12 hours"));
String link = MailUtils.getPasswordResetEmailLink(body);

View file

@ -1,5 +1,5 @@
<html>
<body>
${msg("emailVerificationBodyHtml",link, linkExpiration, realmName)?no_esc}
${msg("emailVerificationBodyHtml",link, linkExpiration, realmName, linkExpirationFormatter(linkExpiration))?no_esc}
</body>
</html>

View file

@ -4,6 +4,6 @@
<html>
<body>
${msg("executeActionsBodyHtml",link, linkExpiration, realmName, requiredActionsText)?no_esc}
${msg("executeActionsBodyHtml",link, linkExpiration, realmName, requiredActionsText, linkExpirationFormatter(linkExpiration))?no_esc}
</body>
</html>

View file

@ -1,5 +1,5 @@
<html>
<body>
${msg("identityProviderLinkBodyHtml", identityProviderAlias, realmName, identityProviderContext.username, link, linkExpiration)?no_esc}
${msg("identityProviderLinkBodyHtml", identityProviderAlias, realmName, identityProviderContext.username, link, linkExpiration, linkExpirationFormatter(linkExpiration))?no_esc}
</body>
</html>

View file

@ -1,5 +1,5 @@
<html>
<body>
${msg("passwordResetBodyHtml",link, linkExpiration, realmName)?no_esc}
${msg("passwordResetBodyHtml",link, linkExpiration, realmName, linkExpirationFormatter(linkExpiration))?no_esc}
</body>
</html>

View file

@ -1,18 +1,18 @@
emailVerificationSubject=Verify email
emailVerificationBody=Someone has created a {2} account with this email address. If this was you, click the link below to verify your email address\n\n{0}\n\nThis link will expire within {1} minutes.\n\nIf you didn''t create this account, just ignore this message.
emailVerificationBodyHtml=<p>Someone has created a {2} account with this email address. If this was you, click the link below to verify your email address</p><p><a href="{0}">Link to e-mail address verification</a></p><p>This link will expire within {1} minutes.</p><p>If you didn''t create this account, just ignore this message.</p>
emailVerificationBody=Someone has created a {2} account with this email address. If this was you, click the link below to verify your email address\n\n{0}\n\nThis link will expire within {3}.\n\nIf you didn''t create this account, just ignore this message.
emailVerificationBodyHtml=<p>Someone has created a {2} account with this email address. If this was you, click the link below to verify your email address</p><p><a href="{0}">Link to e-mail address verification</a></p><p>This link will expire within {3}.</p><p>If you didn''t create this account, just ignore this message.</p>
emailTestSubject=[KEYCLOAK] - SMTP test message
emailTestBody=This is a test message
emailTestBodyHtml=<p>This is a test message</p>
identityProviderLinkSubject=Link {0}
identityProviderLinkBody=Someone wants to link your "{1}" account with "{0}" account of user {2} . If this was you, click the link below to link accounts\n\n{3}\n\nThis link will expire within {4} minutes.\n\nIf you don''t want to link account, just ignore this message. If you link accounts, you will be able to login to {1} through {0}.
identityProviderLinkBodyHtml=<p>Someone wants to link your <b>{1}</b> account with <b>{0}</b> account of user {2} . If this was you, click the link below to link accounts</p><p><a href="{3}">Link to confirm account linking</a></p><p>This link will expire within {4} minutes.</p><p>If you don''t want to link account, just ignore this message. If you link accounts, you will be able to login to {1} through {0}.</p>
identityProviderLinkBody=Someone wants to link your "{1}" account with "{0}" account of user {2} . If this was you, click the link below to link accounts\n\n{3}\n\nThis link will expire within {5}.\n\nIf you don''t want to link account, just ignore this message. If you link accounts, you will be able to login to {1} through {0}.
identityProviderLinkBodyHtml=<p>Someone wants to link your <b>{1}</b> account with <b>{0}</b> account of user {2} . If this was you, click the link below to link accounts</p><p><a href="{3}">Link to confirm account linking</a></p><p>This link will expire within {5}.</p><p>If you don''t want to link account, just ignore this message. If you link accounts, you will be able to login to {1} through {0}.</p>
passwordResetSubject=Reset password
passwordResetBody=Someone just requested to change your {2} account''s credentials. If this was you, click on the link below to reset them.\n\n{0}\n\nThis link and code will expire within {1} minutes.\n\nIf you don''t want to reset your credentials, just ignore this message and nothing will be changed.
passwordResetBodyHtml=<p>Someone just requested to change your {2} account''s credentials. If this was you, click on the link below to reset them.</p><p><a href="{0}">Link to reset credentials</a></p><p>This link will expire within {1} minutes.</p><p>If you don''t want to reset your credentials, just ignore this message and nothing will be changed.</p>
passwordResetBody=Someone just requested to change your {2} account''s credentials. If this was you, click on the link below to reset them.\n\n{0}\n\nThis link and code will expire within {3}.\n\nIf you don''t want to reset your credentials, just ignore this message and nothing will be changed.
passwordResetBodyHtml=<p>Someone just requested to change your {2} account''s credentials. If this was you, click on the link below to reset them.</p><p><a href="{0}">Link to reset credentials</a></p><p>This link will expire within {3}.</p><p>If you don''t want to reset your credentials, just ignore this message and nothing will be changed.</p>
executeActionsSubject=Update Your Account
executeActionsBody=Your administrator has just requested that you update your {2} account by performing the following action(s): {3}. Click on the link below to start this process.\n\n{0}\n\nThis link will expire within {1} minutes.\n\nIf you are unaware that your admin has requested this, just ignore this message and nothing will be changed.
executeActionsBodyHtml=<p>Your administrator has just requested that you update your {2} account by performing the following action(s): {3}. Click on the link below to start this process.</p><p><a href="{0}">Link to account update</a></p><p>This link will expire within {1} minutes.</p><p>If you are unaware that your admin has requested this, just ignore this message and nothing will be changed.</p>
executeActionsBody=Your administrator has just requested that you update your {2} account by performing the following action(s): {3}. Click on the link below to start this process.\n\n{0}\n\nThis link will expire within {4}.\n\nIf you are unaware that your admin has requested this, just ignore this message and nothing will be changed.
executeActionsBodyHtml=<p>Your administrator has just requested that you update your {2} account by performing the following action(s): {3}. Click on the link below to start this process.</p><p><a href="{0}">Link to account update</a></p><p>This link will expire within {4}.</p><p>If you are unaware that your admin has requested this, just ignore this message and nothing will be changed.</p>
eventLoginErrorSubject=Login error
eventLoginErrorBody=A failed login attempt was detected to your account on {0} from {1}. If this was not you, please contact an admin.
eventLoginErrorBodyHtml=<p>A failed login attempt was detected to your account on {0} from {1}. If this was not you, please contact an admin.</p>
@ -31,3 +31,17 @@ requiredAction.terms_and_conditions=Terms and Conditions
requiredAction.UPDATE_PASSWORD=Update Password
requiredAction.UPDATE_PROFILE=Update Profile
requiredAction.VERIFY_EMAIL=Verify Email
# units for link expiration timeout formatting
linkExpirationFormatter.timePeriodUnit.seconds=seconds
linkExpirationFormatter.timePeriodUnit.seconds.1=second
linkExpirationFormatter.timePeriodUnit.minutes=minutes
linkExpirationFormatter.timePeriodUnit.minutes.1=minute
#for language which have more unit plural forms depending on the value (eg. Czech and other Slavic langs) you can override unit text for some other values like this:
#linkExpirationFormatter.timePeriodUnit.minutes.2=minuty
#linkExpirationFormatter.timePeriodUnit.minutes.3=minuty
#linkExpirationFormatter.timePeriodUnit.minutes.4=minuty
linkExpirationFormatter.timePeriodUnit.hours=hours
linkExpirationFormatter.timePeriodUnit.hours.1=hour
linkExpirationFormatter.timePeriodUnit.days=days
linkExpirationFormatter.timePeriodUnit.days.1=day

View file

@ -1,2 +1,2 @@
<#ftl output_format="plainText">
${msg("emailVerificationBody",link, linkExpiration, realmName)}
${msg("emailVerificationBody",link, linkExpiration, realmName, linkExpirationFormatter(linkExpiration))}

View file

@ -1,4 +1,4 @@
<#ftl output_format="plainText">
<#assign requiredActionsText><#if requiredActions??><#list requiredActions><#items as reqActionItem>${msg("requiredAction.${reqActionItem}")}<#sep>, </#items></#list><#else></#if></#assign>
${msg("executeActionsBody",link, linkExpiration, realmName, requiredActionsText)}
${msg("executeActionsBody",link, linkExpiration, realmName, requiredActionsText, linkExpirationFormatter(linkExpiration))}

View file

@ -1,2 +1,2 @@
<#ftl output_format="plainText">
${msg("identityProviderLinkBody", identityProviderAlias, realmName, identityProviderContext.username, link, linkExpiration)}
${msg("identityProviderLinkBody", identityProviderAlias, realmName, identityProviderContext.username, link, linkExpiration, linkExpirationFormatter(linkExpiration))}

View file

@ -1,2 +1,2 @@
<#ftl output_format="plainText">
${msg("passwordResetBody",link, linkExpiration, realmName)}
${msg("passwordResetBody",link, linkExpiration, realmName, linkExpirationFormatter(linkExpiration))}