Skip to content

Commit

Permalink
added overridable email sender impl (#41)
Browse files Browse the repository at this point in the history
* added overridable email sender impl

* proper property name parsing

* removed ServerInfoAware impl because it leaks the config to other realms

* added docs to README

* updated docs

* added cache counter for max emails

* cache key includes date for 1 day expiration

* sample of cache xml. failsafe without cache configured.

* formatting in readme. changed condition name to useRealmConfig for clarity.

* space between variables in dockerfile
  • Loading branch information
xgp authored Nov 20, 2024
1 parent 21f2c35 commit 4d08407
Show file tree
Hide file tree
Showing 6 changed files with 294 additions and 3 deletions.
35 changes: 34 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
<replicated-cache name="counterCache">
<expiration lifespan="-1"/>
</replicated-cache>
```

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.
Expand All @@ -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. |

---
Expand Down
76 changes: 76 additions & 0 deletions conf/cache-ispn-custom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<?xml version="1.0" encoding="UTF-8"?>
<infinispan
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="urn:infinispan:config:15.0 http://www.infinispan.org/schemas/infinispan-config-15.0.xsd"
xmlns="urn:infinispan:config:15.0">

<cache-container name="keycloak">
<transport lock-timeout="60000" stack="udp"/>
<local-cache name="realms" simple-cache="true">
<encoding>
<key media-type="application/x-java-object"/>
<value media-type="application/x-java-object"/>
</encoding>
<memory max-count="10000"/>
</local-cache>
<local-cache name="users" simple-cache="true">
<encoding>
<key media-type="application/x-java-object"/>
<value media-type="application/x-java-object"/>
</encoding>
<memory max-count="10000"/>
</local-cache>
<distributed-cache name="sessions" owners="1">
<expiration lifespan="-1"/>
<memory max-count="10000"/>
</distributed-cache>
<distributed-cache name="authenticationSessions" owners="2">
<expiration lifespan="-1"/>
</distributed-cache>
<distributed-cache name="offlineSessions" owners="1">
<expiration lifespan="-1"/>
<memory max-count="10000"/>
</distributed-cache>
<distributed-cache name="clientSessions" owners="1">
<expiration lifespan="-1"/>
<memory max-count="10000"/>
</distributed-cache>
<distributed-cache name="offlineClientSessions" owners="1">
<expiration lifespan="-1"/>
<memory max-count="10000"/>
</distributed-cache>
<distributed-cache name="loginFailures" owners="2">
<expiration lifespan="-1"/>
</distributed-cache>
<local-cache name="authorization" simple-cache="true">
<encoding>
<key media-type="application/x-java-object"/>
<value media-type="application/x-java-object"/>
</encoding>
<memory max-count="10000"/>
</local-cache>
<replicated-cache name="work">
<expiration lifespan="-1"/>
</replicated-cache>
<!-- custom for counters -->
<replicated-cache name="counterCache">
<expiration lifespan="-1"/>
</replicated-cache>
<local-cache name="keys" simple-cache="true">
<encoding>
<key media-type="application/x-java-object"/>
<value media-type="application/x-java-object"/>
</encoding>
<expiration max-idle="3600000"/>
<memory max-count="1000"/>
</local-cache>
<distributed-cache name="actionTokens" owners="2">
<encoding>
<key media-type="application/x-java-object"/>
<value media-type="application/x-java-object"/>
</encoding>
<expiration max-idle="-1" lifespan="-1" interval="300000"/>
<memory max-count="-1"/>
</distributed-cache>
</cache-container>
</infinispan>
6 changes: 4 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 [email protected] --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

6 changes: 6 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,12 @@
<version>${keycloak.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-model-infinispan</artifactId>
<version>${keycloak.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.freemarker</groupId>
<artifactId>freemarker</artifactId>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, String> conf;
private final Integer maxEmails;
private final String cacheKey;
private Cache<String, Integer> counterCache;

public OverridableEmailSenderProvider(
KeycloakSession session, Map<String, String> 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<String, String> 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<String, String> 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<String, String> 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);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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<String, String> 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<String, String> 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";
}
}

0 comments on commit 4d08407

Please sign in to comment.