Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

added overridable email sender impl #41

Merged
merged 12 commits into from
Nov 20, 2024
33 changes: 33 additions & 0 deletions 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.

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 @@ -94,6 +95,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. |

---

All documentation, source code and other files in this repository are Copyright 2024 Phase Two, Inc.
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>
3 changes: 2 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@ services:
- 8080:8080
volumes:
- ./target/keycloak-themes-0.34-SNAPSHOT.jar:/opt/keycloak/providers/keycloak-themes.jar
command: [ "start-dev --spi-email-template-provider=freemarker-plus-mustache --spi-email-template-freemarker-plus-mustache-enabled=true" ]
- ./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
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"));
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure i understood this condition.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is "should we use the realm config?"

}

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();
rtufisi marked this conversation as resolved.
Show resolved Hide resolved
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";
}
}