diff --git a/README.md b/README.md index 37109ee..942767b 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Themes and theme utilities meant for simple theme customization without deployin - A modified login theme that allows colors, logo, CSS to be loaded from Realm attributes. - An implementation of `ThemeProvider` that loads named Freemarker templates and messages from Realm attributes. Currently only for email. - An implementation of `EmailTemplateProvider` that allows the use of mustache.js templates. +- An implementation of `EmailSenderProvider` that allows overriding SMTP server with defaults. - An implementation of `ThemeProvider` that allows runtime loading of themes from JAR files. Both globally and per-realm. This extension is used in the [Phase Two](https://phasetwo.io) cloud offering, and is released here as part of its commitment to making its [core extensions](https://phasetwo.io/docs/introduction/open-source) open source. Please consult the [license](COPYING) for information regarding use. @@ -95,6 +96,38 @@ The implementation of `EmailTemplateProvider` that allows the use of mustache.js - We get equivalent funcationlity to the methods like `linkExpirationFormatter(linkExpiration)` by using the library's lambda functionality, and using the mustache-y syntax `{{#linkExpirationFormatter}}{{linkExpiration}}{{/linkExpirationFormatter}}`, but there isn't complete coverage yet. - There is essentially no i18n at this point, so only the english templates work. +### Email Sender + +This includes an implementation of `EmailSenderProvider` which behaves as the default, unless you specify variables to configure provider defaults. In this case, any realm that does not have an SMTP server set up will default to use the values set in the variables. This is useful in environments where a single SMTP server is used by many realms, and the Keycloak administrator does not want to distribute credentials to every realm administrator. + +This can also be useful in environments where you want to allow realms to "test" Keycloak's email sending without having to configure an SMTP server. For this use case, we have also included a counter in the distributed cache that is used to limit the number of emails that are sent using the global configuration, in order to prevent spammers from exploiting the free email capability. This can be configured with the `max-emails` variable. To use the limiting functionality, you must have a distributed or replicated cache configuration for `counterCache` in your Infinispan XML cache configuration. E.g.: + +```xml + + + +``` + +If you wish to set the global overrides, you can set the following variables: + +| Variable | Required | Default | Description | +| ---- | ---- | ---- | ---- | +| `--spi-email-sender-provider` | yes | `ext-email-override` | Must be set in order to use this provider. | +| `--spi-email-sender-ext-email-override-enabled` | yes | `true` | Must be set in order to use this provider. | +| `--spi-email-sender-ext-email-override-max-emails` | no | 100 | Maximum number of emails that can be sent in a day for a realm using the override. Fails silently after this maximum. Set to `-1` for no limit. | +| `--spi-email-sender-ext-email-override-host` | yes | | SMTP hostname. Must be set in order to use this provider. | +| `--spi-email-sender-ext-email-override-from` | yes | | From email address. Must be set in order to use this provider. | +| `--spi-email-sender-ext-email-override-auth` | no | `false` | `true` for auth enabled. | +| `--spi-email-sender-ext-email-override-user` | no | | From email address. | +| `--spi-email-sender-ext-email-override-password` | no | | From email address. | +| `--spi-email-sender-ext-email-override-ssl` | no | `false` | `true` for SSL enabled. | +| `--spi-email-sender-ext-email-override-starttls` | no | `false` | `true` for StartTLS enabled. | +| `--spi-email-sender-ext-email-override-port` | no | `25` | SMTP port. | +| `--spi-email-sender-ext-email-override-from-display-name` | no | | From email address display name. | +| `--spi-email-sender-ext-email-override-reply-to` | no | | Reply-to email address. | +| `--spi-email-sender-ext-email-override-reply-to-display-name` | no | | Reply-to email address display name. | +| `--spi-email-sender-ext-email-override-envelope-from` | no | | Envelope-from email address. | + ### JAR Folder Theme Provider This includes an implementation of `ThemeProvider` that dynamically loads theme JARs from a specified directory at runtime. This is useful for deploying packaged themes without requiring a restart of Keycloak. The specified directory is scanned every 1 minute for JAR files both at the top level, and 1 directory deep. The JAR files placed in the top level will expose the enclosed themes to all realms. The subdirectories are meant to be named with realm names that should be allowed to access the JAR files contained in those subdirectories. @@ -105,7 +138,7 @@ The following variables can be set in order to configure this provider: | Variable | Required | Default | Description | | ---- | ---- | ---- | ---- | -| `--spi-theme-cache-themes` | yes | | Must be set to `true` in order to use this provider. | +| `--spi-theme-cache-themes` | yes | true | Must be set to `false` in order to use this provider. | | `--spi-theme-ext-theme-jar-folder-dir` | yes | | Directory to be watched by this provider for theme JARs. | --- diff --git a/conf/cache-ispn-custom.xml b/conf/cache-ispn-custom.xml new file mode 100644 index 0000000..c7dd681 --- /dev/null +++ b/conf/cache-ispn-custom.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docker-compose.yml b/docker-compose.yml index 3c22b90..e2057f1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,5 +12,7 @@ services: - 8080:8080 volumes: - ./target/keycloak-themes-0.34-SNAPSHOT.jar:/opt/keycloak/providers/keycloak-themes.jar - - ${PWD}/mnt_themes:/opt/keycloak/jarthemes - entrypoint: /opt/keycloak/bin/kc.sh --verbose start-dev --spi-email-template-provider=freemarker-plus-mustache --spi-email-template-freemarker-plus-mustache-enabled=true --spi-theme-ext-theme-jar-folder-dir=/opt/keycloak/jarthemes --spi-theme-cache-themes=false + - ${PWD}/jarthemes:/opt/keycloak/jarthemes + - ./conf/cache-ispn-custom.xml:/opt/keycloak/conf/cache-ispn-custom.xml + entrypoint: /opt/keycloak/bin/kc.sh --verbose start-dev --cache-config-file=cache-ispn-custom.xml --spi-email-template-provider=freemarker-plus-mustache --spi-email-template-freemarker-plus-mustache-enabled=true --spi-email-sender-provider=ext-email-override --spi-email-sender-ext-email-override-enabled=true --spi-email-sender-ext-email-override-host=smtp.someserver.com --spi-email-sender-ext-email-override-auth=true --spi-email-sender-ext-email-override-from=support@phasetwo.io --spi-email-sender-ext-email-override-port=587 --spi-email-sender-ext-email-override-starttls=true --spi-email-sender-ext-email-override-user=someuser --spi-email-sender-ext-email-override-password=somepass --spi-email-sender-ext-email-override-max-emails=200 --spi-theme-ext-theme-jar-folder-dir=/opt/keycloak/jarthemes --spi-theme-cache-themes=false + \ No newline at end of file diff --git a/pom.xml b/pom.xml index 556e473..3c6a7d1 100644 --- a/pom.xml +++ b/pom.xml @@ -186,6 +186,12 @@ ${keycloak.version} provided + + org.keycloak + keycloak-model-infinispan + ${keycloak.version} + provided + org.freemarker freemarker diff --git a/src/main/java/io/phasetwo/keycloak/email/OverridableEmailSenderProvider.java b/src/main/java/io/phasetwo/keycloak/email/OverridableEmailSenderProvider.java new file mode 100644 index 0000000..0ebc53c --- /dev/null +++ b/src/main/java/io/phasetwo/keycloak/email/OverridableEmailSenderProvider.java @@ -0,0 +1,104 @@ +package io.phasetwo.keycloak.email; + +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import lombok.extern.jbosslog.JBossLog; +import org.infinispan.Cache; +import org.keycloak.connections.infinispan.InfinispanConnectionProvider; +import org.keycloak.email.DefaultEmailSenderProvider; +import org.keycloak.email.EmailException; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.UserModel; + +@JBossLog +public class OverridableEmailSenderProvider extends DefaultEmailSenderProvider { + + private final KeycloakSession session; + private final Map conf; + private final Integer maxEmails; + private final String cacheKey; + private Cache counterCache; + + public OverridableEmailSenderProvider( + KeycloakSession session, Map conf, Integer maxEmails) { + super(session); + this.session = session; + this.conf = conf; + this.maxEmails = maxEmails; + this.cacheKey = getCacheKey(); + try { + this.counterCache = + session.getProvider(InfinispanConnectionProvider.class).getCache("counterCache", true); + } catch (Exception e) { + log.warnf("Error loading counterCache %s", e); + } + } + + private boolean useRealmConfig(Map config) { + return (!config.isEmpty() && config.containsKey("host")); + } + + private String getCacheKey() { + if (session.getContext().getRealm() != null) { + SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd"); + return String.format( + "ext-email-override-emailCounter-%s-%s", + session.getContext().getRealm().getName(), formatter.format(new Date())); + } else { + return null; + } + } + + private boolean canSend() { + if (cacheKey == null) return true; + Integer count = counterCache.get(cacheKey); + log.infof("Count for %s is %d / %d", cacheKey, count, maxEmails); + if (count == null || count <= maxEmails) return true; + else return false; + } + + private Integer increment() { + if (cacheKey == null) return 0; + else + return counterCache.compute( + cacheKey, (key, value) -> (value == null) ? 1 : value + 1, 1, TimeUnit.DAYS); + } + + @Override + public void send( + Map config, UserModel user, String subject, String textBody, String htmlBody) + throws EmailException { + if (useRealmConfig(config)) { + log.debug("Using customer override email sender"); + super.send(config, user, subject, textBody, htmlBody); + } else { + if (canSend()) { + super.send(conf, user, subject, textBody, htmlBody); + Integer count = increment(); + log.infof("Email count %d for %s", count, cacheKey); + } else { + log.infof("Unable to send email for limit %d %s", maxEmails, cacheKey); + } + } + } + + @Override + public void send( + Map config, String address, String subject, String textBody, String htmlBody) + throws EmailException { + if (useRealmConfig(config)) { + log.debug("Using customer override email sender"); + super.send(config, address, subject, textBody, htmlBody); + } else { + if (canSend()) { + super.send(conf, address, subject, textBody, htmlBody); + Integer count = increment(); + log.infof("Email count %d for %s", count, cacheKey); + } else { + log.infof("Unable to send email for limit %d %s", maxEmails, cacheKey); + } + } + } +} diff --git a/src/main/java/io/phasetwo/keycloak/email/OverridableEmailSenderProviderFactory.java b/src/main/java/io/phasetwo/keycloak/email/OverridableEmailSenderProviderFactory.java new file mode 100644 index 0000000..36d4adb --- /dev/null +++ b/src/main/java/io/phasetwo/keycloak/email/OverridableEmailSenderProviderFactory.java @@ -0,0 +1,70 @@ +package io.phasetwo.keycloak.email; + +import com.google.auto.service.AutoService; +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableMap; +import java.util.Map; +import lombok.extern.jbosslog.JBossLog; +import org.keycloak.Config; +import org.keycloak.email.EmailSenderProviderFactory; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; + +@JBossLog +@AutoService(EmailSenderProviderFactory.class) +public class OverridableEmailSenderProviderFactory implements EmailSenderProviderFactory { + + private Integer maxEmails; + private Map conf; + + @Override + public OverridableEmailSenderProvider create(KeycloakSession session) { + return new OverridableEmailSenderProvider(session, conf, maxEmails); + } + + public static final String[] PROPERTY_NAMES = { + "host", + "auth", + "ssl", + "starttls", + "port", + "from", + "fromDisplayName", + "replyTo", + "replyToDisplayName", + "envelopeFrom", + "user", + "password" + }; + + @Override + public void init(Config.Scope config) { + log.info("Initializing config for email sender."); + this.maxEmails = config.getInt("maxEmails", 100); + log.infof("maxEmails set to %d", this.maxEmails); + String host = config.get("host"); + if (!Strings.isNullOrEmpty(host)) { // TODO better test than this + ImmutableMap.Builder builder = ImmutableMap.builder(); + for (String name : PROPERTY_NAMES) { + String v = config.get(name); + if (v != null) { + builder.put(name, v); + } + } + this.conf = builder.build(); + } else { + this.conf = ImmutableMap.of(); + } + } + + @Override + public void postInit(KeycloakSessionFactory factory) {} + + @Override + public void close() {} + + @Override + public String getId() { + return "ext-email-override"; + } +}