Sending Mails via SMTP and XOAUTH2 authentication mechanism

Closes #17432

Signed-off-by: Sebastian Rose <sebastian.rose@gmail.com>
This commit is contained in:
Sebastian Rose
2024-11-14 21:09:27 +01:00
committed by Marek Posolda
parent ed809d7884
commit 4fb1c41155
26 changed files with 1000 additions and 115 deletions

View File

@@ -96,3 +96,9 @@ using Microsoft AD, based on the `pwdLastSet` attribute.
In order to check if a credential is local - managed by {project_name} - or federated, you can check the `federationLink` property available from both
`CredentialRepresentation` and `CredentialModel` types. If set, the `federationLink` property holds the UUID of the component model associated with a given
user storage provider.
= Token based authentication for SMTP (XOAUTH2)
The Keycloak outgoing link:{adminguide_email_name}[SMTP mail configuration] now supports token authentication (XOAUTH2).
Many service providers (Microsoft, Google) are moving towards SMTP OAuth authentication and end the support for basic authentication.
The token is gathered using Client Credentials Grant.

View File

@@ -41,4 +41,83 @@ Encryption::
Tick one of these checkboxes to support sending emails for recovering usernames and passwords, especially if the SMTP server is on an external network. You will most likely need to change the *Port* to 465, the default port for SSL/TLS.
Authentication::
Set this switch to *ON* if your SMTP server requires authentication. When prompted, supply the *Username* and *Password*. The value of the *Password* field can refer a value from an external <<_vault-administration,vault>>.
Set this switch to *ON* if your SMTP server requires authentication.
Username::
All authentication-mechanisms require a username.
Authentication Type::
Choose the kind of authentication: 'password' or 'token'.
Password::
Only needed when *Authentication Type* 'password' is selected.
Supply the *Password*. The value of the *Password* field can refer a value from an external <<_vault-administration,vault>>.
Auth Token URL::
Only needed when *Authentication Type* 'token' is selected.
Supply the *Auth Token URL* that is used to fetch a token via client credentials grant.
Auth Token Scope::
Only needed when *Authentication Type* 'token' is selected.
Supply the *Auth Token Scope* that is used to fetch a token from the *Auth Token URL*.
Auth Token ClientId::
Only needed when *Authentication Type* 'token' is selected.
Supply the *Auth ClientId* that is used to fetch a token from the *Auth Token URL*.
Auth Token Client Secret::
Only needed when *Authentication Type* 'token' is selected.
Supply the *Auth Client Secret* that authenticates the client to fetch a token from the *Auth Token URL*. The value of the *Auth Client Secret* field can refer a value from an external <<_vault-administration,vault>>.
== Configuration for Microsoft Azure and Office365
Microsoft Azure allows 'Client Credentials Grant' using a client secret to gather an access token.
Microsoft Office365 supports SMTP with XOAUTH2 to authenticate with the gathered token.
Links to relevant Microsoft documentation:
- https://learn.microsoft.com/en-us/exchange/permissions-exo/application-rbac[Usage of role base access control for applications in exchange online]
- Settings in https://learn.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth[Authenticate an IMAP, POP or SMTP connection using OAuth]
The following way setting up keycloak to send mails with Azure and Office365 has been verified by a test.
There might be other variants to achieve the same depending on your environment.
From::
`<some>@<domain>`
Host::
`smtp.office365.com`
Port::
`587`
Encryption::
Check Start TLS
Username::
`<some>@<domain>` (might be the same of a different value than the sender value)
Auth Token Url::
`+https://login.microsoftonline.com/<TenantID>/oauth2/v2.0/token+`
+
Replace TenantID with the id of your Microsoft tenant, usually a UUID, in Azure or just copy the token url from the list of endpoints displayed in the Azure Console.
Auth Token Scope::
`+https://outlook.office.com/.default+`
Auth Token ClientId::
`<ApplicationId>`
+
Replace ApplicationId with the id of your application in Azure, usually a UUID.
Auth Token ClientSecret::
`<Secret configured>`
== Configuration for Google Mail
Not supported by Keycloak yet, because Google decided to not allow client-secrets for the Client Credentials Grant.
== Configuration for AWS
XOAUTH2 is not supported by the AWS-SMTP service.
The AWS-service requires to use a password.

View File

@@ -54,6 +54,8 @@
:adminguide_clearcache_link: {adminguide_link}#_clear-cache
:apidocs_name: API Documentation
:apidocs_link: https://www.keycloak.org/docs/{project_version}/api_documentation/
:adminguide_email_name: Configuring email for a realm
:adminguide_email_link: {adminguide_link}#_email
:bootstrapadminrecovery_name: Admin Bootstrap and Recovery
:bootstrapadminrecovery_link: https://www.keycloak.org/server/bootstrap-admin-recovery
:client_certificate_lookup_link: https://www.keycloak.org/server/reverseproxy#_enabling_client_certificate_lookup

View File

@@ -3451,4 +3451,15 @@ selectRole=Select role
selectUsers=Select user
selectClient=Select client
grantedPermissions=Granted Permissions
deniedPermissions=Denied Permissions
deniedPermissions=Denied Permissions
authenticationType=Authentication Type
authenticationTypeBasicAuth=Password
authenticationTypeTokenAuth=Token
authTokenUrl=Auth Token URL
tokenTokenUrlHelp=Token endpoint for gathering tokens: keycloak example: http://localhost/auth/realms/my-realm/protocol/openid-connect/token
authTokenScope=Auth Token Scope
authTokenScopeHelp=The scope(s) separated by blanks, used during token gathering as scope parameter, e.g. 'basic sendmail'
authTokenClientId=Auth Token ClientId
authTokenClientIdHelp=The client_id used during token gathering, e.g. mykeycloak-sendmail-client
authTokenClientSecret=Auth Token Client Secret
enableDebugSMTP=Enable Debug SMTP

View File

@@ -7,6 +7,7 @@ import {
AlertVariant,
Button,
Checkbox,
Radio,
FormGroup,
PageSection,
} from "@patternfly/react-core";
@@ -57,7 +58,13 @@ export const RealmSettingsEmailTab = ({
const authenticationEnabled = useWatch({
control,
name: "smtpServer.auth",
defaultValue: "",
defaultValue: realm.smtpServer?.auth || "false",
});
const authType = useWatch({
control,
name: "smtpServer.authType",
defaultValue: realm.smtpServer?.authType || "basic",
});
const testConnection = async () => {
@@ -202,6 +209,7 @@ export const RealmSettingsEmailTab = ({
<SwitchControl
name="smtpServer.auth"
label={t("authentication")}
data-testid="smtpServer.auth"
defaultValue=""
labelOn={t("enabled")}
labelOff={t("disabled")}
@@ -217,16 +225,97 @@ export const RealmSettingsEmailTab = ({
required: t("required"),
}}
/>
<PasswordControl
name="smtpServer.password"
label={t("password")}
labelIcon={t("passwordHelp")}
rules={{
required: t("required"),
}}
/>
<FormGroup label={t("authenticationType")} fieldId="authType">
<Controller
name="smtpServer.authType"
control={control}
defaultValue="basic"
render={({ field }) => (
<>
<Radio
id="basicAuth"
name="smtpServer.authType"
data-testid="smtpServer.authType.basic"
label={t("authenticationTypeBasicAuth")}
value="basic"
isChecked={field.value === "basic"}
onChange={() => field.onChange("basic")}
/>
<Radio
id="tokenAuth"
name="smtpServer.authType"
data-testid="smtpServer.authType.token"
label={t("authenticationTypeTokenAuth")}
value="token"
isChecked={field.value === "token"}
onChange={() => field.onChange("token")}
/>
</>
)}
/>
</FormGroup>
{authType === "basic" && (
<PasswordControl
name="smtpServer.password"
label={t("password")}
labelIcon={t("passwordHelp")}
rules={{
required: t("required"),
}}
/>
)}
{authType === "token" && (
<>
<TextControl
name="smtpServer.authTokenUrl"
label={t("authTokenUrl")}
helperText={t("tokenTokenUrlHelp")}
rules={{
required: t("required"),
}}
/>
<TextControl
name="smtpServer.authTokenScope"
label={t("authTokenScope")}
helperText={t("authTokenScopeHelp")}
rules={{
required: t("required"),
}}
/>
<TextControl
name="smtpServer.authTokenClientId"
label={t("authTokenClientId")}
helperText={t("authTokenClientIdHelp")}
rules={{
required: t("required"),
}}
/>
<PasswordControl
name="smtpServer.authTokenClientSecret"
label={t("authTokenClientSecret")}
labelIcon={t("authTokenClientSecretHelp")}
rules={{
required: t("required"),
}}
/>
</>
)}
</>
)}
<Controller
name="smtpServer.debug"
control={control}
defaultValue="false"
render={({ field }) => (
<Checkbox
id="kc-enable-debug"
data-testid="enable-debug"
label={t("enableDebugSMTP")}
isChecked={field.value === "true"}
onChange={(_event, value) => field.onChange("" + value)}
/>
)}
/>
{currentUser && (
<FormGroup id="descriptionTestConnection">
{currentUser.email ? (

View File

@@ -0,0 +1,57 @@
import { test } from "@playwright/test";
import { v4 as uuid } from "uuid";
import adminClient from "../utils/AdminClient";
import { login } from "../utils/login";
import { assertNotificationMessage } from "../utils/masthead";
import { goToRealm, goToRealmSettings } from "../utils/sidebar";
import {
clickSaveEmailButton,
goToEmailTab,
populateEmailPageNoAuth,
assertEmailPageNoAuth,
populateEmailPageWithPasswordAuth,
assertEmailPageWithPasswordAuth,
populateEmailPageWithTokenAuth,
assertEmailPageWithTokenAuth,
} from "./email";
test.describe("Email", () => {
const realmName = `email-realm-settings-${uuid()}`;
test.beforeAll(() => adminClient.createRealm(realmName));
test.afterAll(() => adminClient.deleteRealm(realmName));
test.beforeEach(async ({ page }) => {
await login(page);
await goToRealm(page, realmName);
await goToRealmSettings(page);
await goToEmailTab(page);
});
test("Add email data with no authentication", async ({ page }) => {
await populateEmailPageNoAuth(page);
await clickSaveEmailButton(page);
await assertNotificationMessage(page, "Realm successfully updated");
await goToRealmSettings(page);
await goToEmailTab(page);
await assertEmailPageNoAuth(page);
});
test("Add email data with password authentication", async ({ page }) => {
await populateEmailPageWithPasswordAuth(page);
await clickSaveEmailButton(page);
await assertNotificationMessage(page, "Realm successfully updated");
await goToRealmSettings(page);
await goToEmailTab(page);
await assertEmailPageWithPasswordAuth(page);
});
test("Add email data with token authentication", async ({ page }) => {
await populateEmailPageWithTokenAuth(page);
await clickSaveEmailButton(page);
await assertNotificationMessage(page, "Realm successfully updated");
await goToRealmSettings(page);
await goToEmailTab(page);
await assertEmailPageWithTokenAuth(page);
});
});

View File

@@ -0,0 +1,149 @@
import { Page, expect } from "@playwright/test";
import { switchOn } from "../utils/form";
export async function goToEmailTab(page: Page) {
await page.getByTestId("rs-email-tab").click();
}
export async function populateEmailPageNoAuth(page: Page) {
await getFrom(page).fill("test@local.local");
await getFromDisplayName(page).fill("Tester");
await getReplyTo(page).fill("replyto-test@local.local");
await getReplyToDisplayName(page).fill("ReplyTo Tester");
await getEnvelopeFrom(page).fill("envelope-test@local.local");
await getHost(page).fill("host.smtp.local");
await getPort(page).fill("123");
await getEnableSSL(page).check();
await getEnableStartTLS(page).check();
await getDebug(page).check();
}
export async function assertEmailPageNoAuth(page: Page) {
await expect(getFrom(page)).toHaveValue("test@local.local");
await expect(getFromDisplayName(page)).toHaveValue("Tester");
await expect(getReplyTo(page)).toHaveValue("replyto-test@local.local");
await expect(getReplyToDisplayName(page)).toHaveValue("ReplyTo Tester");
await expect(getEnvelopeFrom(page)).toHaveValue("envelope-test@local.local");
await expect(getHost(page)).toHaveValue("host.smtp.local");
await expect(getPort(page)).toHaveValue("123");
await expect(getEnableSSL(page)).toBeChecked();
await expect(getEnableStartTLS(page)).toBeChecked();
await expect(getDebug(page)).toBeChecked();
}
export async function populateEmailPageWithPasswordAuth(page: Page) {
await switchOn(page, getAuth(page));
await getUser(page).fill("user");
await getAuthTypeBasic(page).check();
await getPassword(page).fill("password");
}
export async function assertEmailPageWithPasswordAuth(page: Page) {
await expect(getAuth(page)).toBeChecked();
await expect(getUser(page)).toHaveValue("user");
await expect(getAuthTypeBasic(page)).toBeChecked();
await expect(getPassword(page)).toHaveValue("**********");
}
export async function populateEmailPageWithTokenAuth(page: Page) {
await getAuthTypeToken(page).check();
await getAuthTokenUrl(page).fill("https://auth.token.url");
await getAuthTokenScope(page).fill("scope");
await getAuthTokenClientId(page).fill("client-id");
await getAuthTokenClientSecret(page).fill("client-secret");
}
export async function assertEmailPageWithTokenAuth(page: Page) {
await expect(getAuthTypeToken(page)).toBeChecked();
await expect(getAuthTokenUrl(page)).toHaveValue("https://auth.token.url");
await expect(getAuthTokenScope(page)).toHaveValue("scope");
await expect(getAuthTokenClientId(page)).toHaveValue("client-id");
await expect(getAuthTokenClientSecret(page)).toHaveValue("**********");
}
function getFrom(page: Page) {
return page.getByTestId("smtpServer.from");
}
function getFromDisplayName(page: Page) {
return page.getByTestId("smtpServer.fromDisplayName");
}
function getReplyTo(page: Page) {
return page.getByTestId("smtpServer.replyTo");
}
function getReplyToDisplayName(page: Page) {
return page.getByTestId("smtpServer.replyToDisplayName");
}
function getEnvelopeFrom(page: Page) {
return page.getByTestId("smtpServer.envelopeFrom");
}
function getHost(page: Page) {
return page.getByTestId("smtpServer.host");
}
function getPort(page: Page) {
return page.getByTestId("smtpServer.port");
}
function getEnableSSL(page: Page) {
return page.getByTestId("enable-ssl");
}
function getEnableStartTLS(page: Page) {
return page.getByTestId("enable-start-tls");
}
function getAuth(page: Page) {
return page.getByTestId("smtpServer.auth");
}
function getUser(page: Page) {
return page.getByTestId("smtpServer.user");
}
function getAuthTypeBasic(page: Page) {
return page.getByTestId("smtpServer.authType.basic");
}
function getAuthTypeToken(page: Page) {
return page.getByTestId("smtpServer.authType.token");
}
function getPassword(page: Page) {
return page.getByTestId("smtpServer.password");
}
function getAuthTokenUrl(page: Page) {
return page.getByTestId("smtpServer.authTokenUrl");
}
function getAuthTokenScope(page: Page) {
return page.getByTestId("smtpServer.authTokenScope");
}
function getAuthTokenClientId(page: Page) {
return page.getByTestId("smtpServer.authTokenClientId");
}
function getAuthTokenClientSecret(page: Page) {
return page.getByTestId("smtpServer.authTokenClientSecret");
}
function getDebug(page: Page) {
return page.getByTestId("enable-debug");
}
export async function clickSaveEmailButton(page: Page) {
await page.getByTestId("email-tab-save").click();
}

View File

@@ -860,11 +860,28 @@ public class DefaultExportImportManager implements ExportImportManager {
session.clientPolicy().updateRealmModelFromRepresentation(realm, rep);
if (rep.getSmtpServer() != null) {
Map<String, String> config = new HashMap(rep.getSmtpServer());
if (rep.getSmtpServer().containsKey("password") && ComponentRepresentation.SECRET_VALUE.equals(rep.getSmtpServer().get("password"))) {
String passwordValue = realm.getSmtpConfig() != null ? realm.getSmtpConfig().get("password") : null;
config.put("password", passwordValue);
Map<String, String> config = new HashMap<>(rep.getSmtpServer());
if(rep.getSmtpServer().containsKey("authType") && "basic".equals(rep.getSmtpServer().get("authType"))) {
if (rep.getSmtpServer().containsKey("password") && ComponentRepresentation.SECRET_VALUE.equals(rep.getSmtpServer().get("password"))) {
String passwordValue = realm.getSmtpConfig() != null ? realm.getSmtpConfig().get("password") : null;
config.put("password", passwordValue);
}
config.remove("authTokenUrl");
config.remove("authTokenScope");
config.remove("authTokenClientId");
config.remove("authTokenClientSecret");
}
if(rep.getSmtpServer().containsKey("authType") && "token".equals(rep.getSmtpServer().get("authType"))) {
if (rep.getSmtpServer().containsKey("authTokenClientSecret") && ComponentRepresentation.SECRET_VALUE.equals(rep.getSmtpServer().get("authTokenClientSecret"))) {
String authTokenClientSecretValue = realm.getSmtpConfig() != null ? realm.getSmtpConfig().get("authTokenClientSecret") : null;
config.put("authTokenClientSecret", authTokenClientSecretValue);
}
config.remove("password");
}
realm.setSmtpConfig(config);
}

View File

@@ -179,7 +179,7 @@
<oracle-jdbc.version>23.6.0.24.10</oracle-jdbc.version>
<!-- Test -->
<greenmail.version>2.1.0-alpha-1</greenmail.version>
<greenmail.version>2.1.3</greenmail.version>
<jmeter.version>2.10</jmeter.version>
<junit.version>4.13.2</junit.version>
<picketlink.version>2.7.0.Final</picketlink.version>

View File

@@ -134,6 +134,7 @@ public class StripSecretsUtils {
private static RealmRepresentation stripRealm(RealmRepresentation rep) {
stripFromMap(rep.getSmtpServer(), "password");
stripFromMap(rep.getSmtpServer(), "authTokenClientSecret");
return rep;
}

View File

@@ -18,10 +18,14 @@
package org.keycloak.utils;
import org.keycloak.common.util.Resteasy;
import org.keycloak.models.KeycloakContext;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
public class KeycloakSessionUtil {
private static final String NO_REALM = "no_realm_found_in_session";
private KeycloakSessionUtil() {
}
@@ -46,4 +50,26 @@ public class KeycloakSessionUtil {
return Resteasy.pushContext(KeycloakSession.class, session);
}
public static String getRealmNameFromContext(KeycloakSession session) {
if(session == null) {
return NO_REALM;
}
KeycloakContext context = session.getContext();
if(context == null) {
return NO_REALM;
}
RealmModel realm = context.getRealm();
if (realm == null) {
return NO_REALM;
}
if(realm.getName() != null) {
return realm.getName();
} else {
return NO_REALM;
}
}
}

View File

@@ -0,0 +1,19 @@
package org.keycloak.email;
import jakarta.mail.MessagingException;
import jakarta.mail.Transport;
import org.keycloak.models.KeycloakSession;
import java.util.Map;
public class DefaultEmailAuthenticator implements EmailAuthenticator {
@Override
public void connect(KeycloakSession session, Map<String, String> config, Transport transport) throws EmailException {
try {
transport.connect();
} catch (MessagingException e) {
throw new EmailException("Non authenticated connect failed", e);
}
}
}

View File

@@ -24,7 +24,6 @@ import org.keycloak.models.KeycloakSession;
import org.keycloak.models.UserModel;
import org.keycloak.services.ServicesLogger;
import org.keycloak.truststore.JSSETruststoreConfigurator;
import org.keycloak.vault.VaultStringSecret;
import jakarta.mail.Address;
import jakarta.mail.MessagingException;
@@ -55,9 +54,12 @@ public class DefaultEmailSenderProvider implements EmailSenderProvider {
private static final Logger logger = Logger.getLogger(DefaultEmailSenderProvider.class);
private static final String SUPPORTED_SSL_PROTOCOLS = getSupportedSslProtocols();
private final Map<EmailAuthenticator.AuthenticatorType, EmailAuthenticator> authenticators;
private final KeycloakSession session;
public DefaultEmailSenderProvider(KeycloakSession session) {
public DefaultEmailSenderProvider(KeycloakSession session, Map<EmailAuthenticator.AuthenticatorType, EmailAuthenticator> authenticators) {
this.authenticators = authenticators;
this.session = session;
}
@@ -72,58 +74,110 @@ public class DefaultEmailSenderProvider implements EmailSenderProvider {
@Override
public void send(Map<String, String> config, String address, String subject, String textBody, String htmlBody) throws EmailException {
Transport transport = null;
Session session = Session.getInstance(buildEmailProperties(config));
Message message = buildMessage(session, address, subject, config, buildMultipartBody(textBody, htmlBody));
try(Transport transport = session.getTransport("smtp")) {
EmailAuthenticator selectedAuthenticator = selectAuthenticatorBasedOnConfig(config);
selectedAuthenticator.connect(this.session, config, transport);
transport.sendMessage(message, new InternetAddress[]{new InternetAddress(address)});
} catch (Exception e) {
ServicesLogger.LOGGER.failedToSendEmail(e);
throw new EmailException("Error when attempting to send the email to the server. More information is available in the server log.", e);
}
}
private Properties buildEmailProperties(Map<String, String> config) {
Properties props = new Properties();
if (config.containsKey("host")) {
props.setProperty("mail.smtp.host", config.get("host"));
}
if (config.containsKey("port") && config.get("port") != null) {
props.setProperty("mail.smtp.port", config.get("port"));
}
if (isAuthConfigured(config)) {
props.setProperty("mail.smtp.auth", "true");
}
if (isAuthTypeTokenConfigured(config)) {
props.put("mail.smtp.auth.mechanisms", "XOAUTH2");
}
if (isDebugEnabled(config)) {
props.put("mail.debug", "true");
}
if (isSslConfigured(config)) {
props.setProperty("mail.smtp.ssl.enable", "true");
}
if (isStarttlsConfigured(config)) {
props.setProperty("mail.smtp.starttls.enable", "true");
}
if (isSslConfigured(config) || isStarttlsConfigured(config) || isAuthConfigured(config)) {
props.put("mail.smtp.ssl.protocols", SUPPORTED_SSL_PROTOCOLS);
setupTruststore(props);
}
props.setProperty("mail.smtp.timeout", "10000");
props.setProperty("mail.smtp.connectiontimeout", "10000");
props.setProperty("mail.smtp.writetimeout", "10000");
String envelopeFrom = config.get("envelopeFrom");
if (isNotBlank(envelopeFrom)) {
props.setProperty("mail.smtp.from", envelopeFrom);
}
return props;
}
private Message buildMessage(Session session, String address, String subject, Map<String, String> config, Multipart multipart) throws EmailException {
String from = config.get("from");
if (from == null) {
throw new EmailException("No sender address configured in the realm settings for emails");
}
String fromDisplayName = config.get("fromDisplayName");
String replyTo = config.get("replyTo");
String replyToDisplayName = config.get("replyToDisplayName");
try {
Message msg = new MimeMessage(session);
msg.setFrom(toInternetAddress(from, fromDisplayName));
msg.setReplyTo(new Address[]{toInternetAddress(from, fromDisplayName)});
Properties props = new Properties();
if (config.containsKey("host")) {
props.setProperty("mail.smtp.host", config.get("host"));
if (isNotBlank(replyTo)) {
msg.setReplyTo(new Address[]{toInternetAddress(replyTo, replyToDisplayName)});
}
boolean auth = "true".equals(config.get("auth"));
boolean ssl = "true".equals(config.get("ssl"));
boolean starttls = "true".equals(config.get("starttls"));
msg.setHeader("To", address);
msg.setSubject(MimeUtility.encodeText(subject, StandardCharsets.UTF_8.name(), null));
msg.setContent(multipart);
msg.saveChanges();
msg.setSentDate(new Date());
if (config.containsKey("port") && config.get("port") != null) {
props.setProperty("mail.smtp.port", config.get("port"));
}
return msg;
} catch (UnsupportedEncodingException e) {
throw new EmailException("Failed to encode email address", e);
} catch (AddressException e) {
throw new EmailException("Invalid email address format", e);
} catch (MessagingException e) {
throw new EmailException("MessagingException occurred", e);
}
}
if (auth) {
props.setProperty("mail.smtp.auth", "true");
}
if (ssl) {
props.setProperty("mail.smtp.ssl.enable", "true");
}
if (starttls) {
props.setProperty("mail.smtp.starttls.enable", "true");
}
if (ssl || starttls || auth){
props.put("mail.smtp.ssl.protocols", SUPPORTED_SSL_PROTOCOLS);
setupTruststore(props);
}
props.setProperty("mail.smtp.timeout", "10000");
props.setProperty("mail.smtp.connectiontimeout", "10000");
String from = config.get("from");
if (from == null) {
throw new EmailException("No sender address configured in the realm settings for emails");
}
String fromDisplayName = config.get("fromDisplayName");
String replyTo = config.get("replyTo");
String replyToDisplayName = config.get("replyToDisplayName");
String envelopeFrom = config.get("envelopeFrom");
Session session = Session.getInstance(props);
Multipart multipart = new MimeMultipart("alternative");
private Multipart buildMultipartBody(String textBody, String htmlBody) throws EmailException {
Multipart multipart = new MimeMultipart("alternative");
try {
if (textBody != null) {
MimeBodyPart textPart = new MimeBodyPart();
textPart.setText(textBody, "UTF-8");
@@ -135,51 +189,43 @@ public class DefaultEmailSenderProvider implements EmailSenderProvider {
htmlPart.setContent(htmlBody, "text/html; charset=UTF-8");
multipart.addBodyPart(htmlPart);
}
Message msg = new MimeMessage(session);
msg.setFrom(toInternetAddress(from, fromDisplayName));
msg.setReplyTo(new Address[]{toInternetAddress(from, fromDisplayName)});
if (isNotBlank(replyTo)) {
msg.setReplyTo(new Address[]{toInternetAddress(replyTo, replyToDisplayName)});
}
if (isNotBlank(envelopeFrom)) {
props.setProperty("mail.smtp.from", envelopeFrom);
}
msg.setHeader("To", address);
msg.setSubject(MimeUtility.encodeText(subject, StandardCharsets.UTF_8.name(), null));
msg.setContent(multipart);
msg.saveChanges();
msg.setSentDate(new Date());
transport = session.getTransport("smtp");
if (auth) {
try (VaultStringSecret vaultStringSecret = this.session.vault().getStringSecret(config.get("password"))) {
transport.connect(config.get("user"), vaultStringSecret.get().orElse(config.get("password")));
}
} else {
transport.connect();
}
transport.sendMessage(msg, new InternetAddress[]{new InternetAddress(address)});
} catch (EmailException e) {
throw e;
} catch (Exception e) {
ServicesLogger.LOGGER.failedToSendEmail(e);
throw new EmailException("Error when attempting to send the email to the server. More information is available in the server log.", e);
} finally {
if (transport != null) {
try {
transport.close();
} catch (MessagingException e) {
logger.warn("Failed to close transport", e);
}
}
} catch (MessagingException e) {
throw new EmailException("Error encoding email body parts", e);
}
return multipart;
}
private EmailAuthenticator selectAuthenticatorBasedOnConfig(Map<String, String> config) {
if(isAuthConfigured(config)) {
String authType = config.getOrDefault("authType", "basic");
return authenticators.get(EmailAuthenticator.AuthenticatorType.valueOf(authType.toUpperCase()));
}
return authenticators.get(EmailAuthenticator.AuthenticatorType.NONE);
}
private static boolean isStarttlsConfigured(Map<String, String> config) {
return "true".equals(config.get("starttls"));
}
private static boolean isSslConfigured(Map<String, String> config) {
return "true".equals(config.get("ssl"));
}
private static boolean isDebugEnabled(Map<String, String> config) {
return "true".equals(config.get("debug"));
}
private boolean isAuthConfigured(Map<String, String> config) {
return "true".equals(config.get("auth"));
}
private boolean isAuthTypeTokenConfigured(Map<String, String> config) {
return "token".equals(config.get("authType"));
}
protected InternetAddress toInternetAddress(String email, String displayName) throws UnsupportedEncodingException, AddressException, EmailException {
if (email == null || "".equals(email.trim())) {
throw new EmailException("Please provide a valid address", null);
@@ -203,8 +249,7 @@ public class DefaultEmailSenderProvider implements EmailSenderProvider {
if (configurator.getProvider().getPolicy() == HostnameVerificationPolicy.ANY) {
props.setProperty("mail.smtp.ssl.trust", "*");
props.put("mail.smtp.ssl.checkserveridentity", Boolean.FALSE.toString()); // this should be the default but seems to be impl specific, so set it explicitly just to be sure
}
else {
} else {
props.put("mail.smtp.ssl.checkserveridentity", Boolean.TRUE.toString());
}
}

View File

@@ -21,18 +21,26 @@ import org.keycloak.Config;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
*/
public class DefaultEmailSenderProviderFactory implements EmailSenderProviderFactory {
private final Map<EmailAuthenticator.AuthenticatorType, EmailAuthenticator> emailAuthenticators = new ConcurrentHashMap<>();
@Override
public EmailSenderProvider create(KeycloakSession session) {
return new DefaultEmailSenderProvider(session);
return new DefaultEmailSenderProvider(session, emailAuthenticators);
}
@Override
public void init(Config.Scope config) {
emailAuthenticators.put(EmailAuthenticator.AuthenticatorType.NONE, new DefaultEmailAuthenticator());
emailAuthenticators.put(EmailAuthenticator.AuthenticatorType.BASIC, new PasswordAuthEmailAuthenticator());
emailAuthenticators.put(EmailAuthenticator.AuthenticatorType.TOKEN, new TokenAuthEmailAuthenticator());
}
@Override
@@ -41,6 +49,7 @@ public class DefaultEmailSenderProviderFactory implements EmailSenderProviderFac
@Override
public void close() {
emailAuthenticators.clear();
}
@Override
@@ -48,4 +57,7 @@ public class DefaultEmailSenderProviderFactory implements EmailSenderProviderFac
return "default";
}
public Map<EmailAuthenticator.AuthenticatorType, EmailAuthenticator> getEmailAuthenticators() {
return emailAuthenticators;
}
}

View File

@@ -0,0 +1,19 @@
package org.keycloak.email;
import jakarta.mail.Transport;
import org.keycloak.models.KeycloakSession;
import java.util.Map;
public interface EmailAuthenticator {
void connect(KeycloakSession session, Map<String, String> config, Transport transport) throws EmailException;
enum AuthenticatorType {
NONE,
BASIC,
TOKEN
}
}

View File

@@ -0,0 +1,21 @@
package org.keycloak.email;
import jakarta.mail.MessagingException;
import jakarta.mail.Transport;
import org.keycloak.models.KeycloakSession;
import org.keycloak.vault.VaultStringSecret;
import java.util.Map;
public class PasswordAuthEmailAuthenticator implements EmailAuthenticator {
@Override
public void connect(KeycloakSession session, Map<String, String> config, Transport transport) throws EmailException {
try (VaultStringSecret vaultStringSecret = session.vault().getStringSecret(config.get("password"))) {
transport.connect(config.get("user"), vaultStringSecret.get().orElse(config.get("password")));
} catch (MessagingException e) {
throw new EmailException("Password based SMTP connect failed", e);
}
}
}

View File

@@ -0,0 +1,140 @@
package org.keycloak.email;
import com.fasterxml.jackson.databind.JsonNode;
import jakarta.mail.AuthenticationFailedException;
import jakarta.mail.MessagingException;
import jakarta.mail.Transport;
import org.jboss.logging.Logger;
import org.keycloak.broker.provider.util.SimpleHttp;
import org.keycloak.models.KeycloakSession;
import org.keycloak.utils.KeycloakSessionUtil;
import org.keycloak.vault.VaultStringSecret;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
public class TokenAuthEmailAuthenticator implements EmailAuthenticator {
private static final Logger logger = Logger.getLogger(TokenAuthEmailAuthenticator.class);
public static final int FALLBACK_EXPIRES_AT_IN_SECONDS = 60;
private final Map<String, TokenAuthEmailAuthenticator.TokenStoreEntry> tokenStore = new ConcurrentHashMap<>();
@Override
public void connect(KeycloakSession session, Map<String, String> config, Transport transport) throws EmailException {
try {
String token = gatherValidToken(session, config);
transport.connect(config.get("user"), token);
} catch (AuthenticationFailedException e) {
this.tokenStore.remove(session.getContext().getRealm().getId());
logger.debugf("AuthenticationFailed-Exception for SMTP in realm %s failed response was %s, will try again", KeycloakSessionUtil.getRealmNameFromContext(session), e.getMessage());
String token = gatherValidToken(session, config);
try {
transport.connect(config.get("user"), token);
} catch (MessagingException ex) {
logger.warnf("Retry after AuthenticationFailed-Exception for SMTP in realm %s failed response was %s", KeycloakSessionUtil.getRealmNameFromContext(session), ex);
throw new EmailException("Retry after AuthenticationFailed-Exception for SMTP failed.", ex);
}
} catch (MessagingException e) {
throw new EmailException("Connect failed for SMTP " + KeycloakSessionUtil.getRealmNameFromContext(session), e);
}
}
private String gatherValidToken(KeycloakSession session, Map<String, String> config) throws EmailException {
try (VaultStringSecret vaultStringSecret = session.vault().getStringSecret(config.get("authTokenClientSecret"))) {
String authTokenClientSecret = vaultStringSecret.get().orElse(config.get("authTokenClientSecret"));
String authTokenUrl = config.get("authTokenUrl");
String authTokenClientId = config.get("authTokenClientId");
String authTokenScope = config.get("authTokenScope");
int authTokenClientSecretHash = authTokenClientSecret.hashCode();
TokenStoreEntry tokenStoreEntry = this.tokenStore.get(session.getContext().getRealm().getId());
if (isValidAuthToken(authTokenUrl, authTokenScope, authTokenClientId, authTokenClientSecretHash, tokenStoreEntry)) {
return tokenStoreEntry.token;
}
synchronized (this.tokenStore) {
if (isValidAuthToken(authTokenUrl, authTokenScope, authTokenClientId, authTokenClientSecretHash, tokenStoreEntry)) {
return tokenStoreEntry.token;
}
JsonNode response = fetchTokenViaHTTP(session, authTokenUrl, authTokenScope, authTokenClientId, authTokenClientSecret);
Optional<String> maybeToken = getAccessToken(session, response);
Optional<LocalDateTime> maybeExpiresAt = getExpiresIn(session, response);
if (maybeToken.isPresent()) {
String token = maybeToken.get();
this.tokenStore.put(session.getContext().getRealm().getId(),
new TokenStoreEntry(
maybeExpiresAt.orElse(LocalDateTime.now().plusSeconds(FALLBACK_EXPIRES_AT_IN_SECONDS)),
authTokenUrl,
authTokenScope,
authTokenClientId,
authTokenClientSecretHash,
token));
return token;
} else {
throw new EmailException("No access token found in token-response for SMTP");
}
}
} catch (IOException e) {
throw new EmailException("Failed to gather valid token for SMTP", e);
}
}
private static boolean isValidAuthToken(String authTokenUrl, String authTokenScope, String authTokenClientId, int authTokenHash, TokenStoreEntry tokenStoreEntry) {
return tokenStoreEntry != null
&& authTokenUrl != null && authTokenUrl.equals(tokenStoreEntry.url)
&& authTokenScope != null && authTokenScope.equals(tokenStoreEntry.scope)
&& authTokenClientId != null && authTokenClientId.equals(tokenStoreEntry.clientId)
&& authTokenHash == tokenStoreEntry.clientSecretHash
&& tokenStoreEntry.expiration_at.plusSeconds(30).isAfter(LocalDateTime.now());
}
private Optional<String> getAccessToken(KeycloakSession session, JsonNode response) {
if (response.has("access_token")) {
return Optional.of(response.get("access_token").asText());
} else {
logger.warnf("Got no access_token from response for SMTP auth in realm %s, response was %s", KeycloakSessionUtil.getRealmNameFromContext(session), response);
return Optional.empty();
}
}
private Optional<LocalDateTime> getExpiresIn(KeycloakSession session, JsonNode response) {
//token-lifetime, must be given beside the token because token can be opaque (must not be a jwt token)
if (response.has("expires_in")) {
String expiresIn = response.get("expires_in").asText();
return Optional.of(LocalDateTime.now().plusSeconds(Long.parseLong(expiresIn)));
} else {
logger.warnf("Got no expires_in from response for SMTP auth in realm %s, response was %s", KeycloakSessionUtil.getRealmNameFromContext(session), response.asText());
return Optional.of((LocalDateTime.now().plusSeconds(FALLBACK_EXPIRES_AT_IN_SECONDS)));
}
}
private JsonNode fetchTokenViaHTTP(KeycloakSession session, String authTokenUrl, String authTokenScope, String authTokenClientId, String authTokenClientSecret) throws IOException {
return SimpleHttp.doPost(authTokenUrl, session)
.param("client_id", authTokenClientId)
.param("client_secret", authTokenClientSecret)
.param("scope", authTokenScope)
.param("grant_type", "client_credentials").asJson();
}
record TokenStoreEntry(
LocalDateTime expiration_at,
String url,
String scope,
String clientId,
int clientSecretHash,
String token) {
}
}

View File

@@ -1139,6 +1139,9 @@ public class RealmAdminResource {
if (ComponentRepresentation.SECRET_VALUE.equals(settings.get("password"))) {
settings.put("password", realm.getSmtpConfig().get("password"));
}
if (ComponentRepresentation.SECRET_VALUE.equals(settings.get("authTokenClientSecret"))) {
settings.put("authTokenClientSecret", realm.getSmtpConfig().get("authTokenClientSecret"));
}
session.getProvider(EmailTemplateProvider.class).sendSmtpTestEmail(settings, user);
} catch (Exception e) {
e.printStackTrace();

View File

@@ -1,6 +1,7 @@
package org.keycloak.testframework.server;
import org.keycloak.common.util.KeycloakUriBuilder;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import java.net.MalformedURLException;
import java.net.URL;
@@ -39,6 +40,10 @@ public class KeycloakUrls {
return toUrl(getAdmin());
}
public KeycloakUriBuilder getBaseBuilder() {
return toBuilder(getBase());
}
public KeycloakUriBuilder getAdminBuilder() {
return toBuilder(getAdmin());
}
@@ -59,4 +64,7 @@ public class KeycloakUrls {
return KeycloakUriBuilder.fromUri(url);
}
public String getToken(String realm) {
return baseUrl + "/realms/" + realm + "/protocol/" + OIDCLoginProtocol.LOGIN_PROTOCOL + "/token";
}
}

View File

@@ -32,7 +32,7 @@
<description>Email server extension for Keycloak Test Framework</description>
<properties>
<greenmail.version>2.1.1</greenmail.version>
<greenmail.version>2.1.3</greenmail.version>
</properties>
<dependencies>

View File

@@ -1,6 +1,8 @@
package org.keycloak.testframework.mail;
import com.icegreen.greenmail.store.FolderException;
import com.icegreen.greenmail.user.GreenMailUser;
import com.icegreen.greenmail.user.TokenValidator;
import com.icegreen.greenmail.util.GreenMail;
import com.icegreen.greenmail.util.ServerSetup;
import jakarta.mail.internet.MimeMessage;
@@ -25,6 +27,13 @@ public class MailServer extends ManagedTestResource {
greenMail.setUser(username, password);
}
public void credentials(String username, TokenValidator validator) {
greenMail.setUser(username, null);
GreenMailUser user = greenMail.getUserManager().getUser(username);
// greenmail refactoring required, see https://github.com/greenmail-mail-test/greenmail/pull/838
((com.icegreen.greenmail.user.UserImpl)user).setTokenValidator(validator);
}
public MimeMessage[] getReceivedMessages() {
return greenMail.getReceivedMessages();
}

View File

@@ -17,25 +17,37 @@
package org.keycloak.tests.admin;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import org.keycloak.admin.client.Keycloak;
import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.models.AdminRoles;
import org.keycloak.models.Constants;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import jakarta.mail.internet.MimeMessage;
import jakarta.ws.rs.core.Response;
import org.keycloak.testframework.annotations.InjectAdminClient;
import org.keycloak.testframework.annotations.InjectKeycloakUrls;
import org.keycloak.testframework.annotations.InjectRealm;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.testframework.mail.MailServer;
import org.keycloak.testframework.mail.annotations.InjectMailServer;
import org.keycloak.testframework.oauth.OAuthClient;
import org.keycloak.testframework.oauth.annotations.InjectOAuthClient;
import org.keycloak.testframework.realm.ManagedRealm;
import org.keycloak.testframework.realm.RealmConfig;
import org.keycloak.testframework.realm.RealmConfigBuilder;
import org.keycloak.testframework.server.KeycloakUrls;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -45,6 +57,7 @@ import static org.keycloak.representations.idm.ComponentRepresentation.SECRET_VA
/**
* @author <a href="mailto:bruno@abstractj.org">Bruno Oliveira</a>
*/
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
@KeycloakIntegrationTest
public class SMTPConnectionTest {
@@ -54,16 +67,24 @@ public class SMTPConnectionTest {
@InjectAdminClient(mode = InjectAdminClient.Mode.MANAGED_REALM, client = "myclient", user = "myadmin")
private Keycloak adminClient;
@InjectOAuthClient
OAuthClient oAuthClient;
@InjectMailServer
private MailServer mailServer;
@InjectKeycloakUrls
KeycloakUrls keycloakUrls;
@Test
@Order(1)
public void testWithNullSettings() throws Exception {
Response response = adminClient.realms().realm(managedRealm.getName()).testSMTPConnection(settings(null, null, null, null, null, null, null, null));
assertStatus(response, 500);
}
@Test
@Order(2)
public void testWithProperSettings() throws Exception {
Response response = adminClient.realms().realm(managedRealm.getName()).testSMTPConnection(settings("127.0.0.1", "3025", "auto@keycloak.org", null, null, null, null, null));
assertStatus(response, 204);
@@ -71,12 +92,14 @@ public class SMTPConnectionTest {
}
@Test
@Order(3)
public void testWithAuthEnabledCredentialsEmpty() throws Exception {
Response response = adminClient.realms().realm(managedRealm.getName()).testSMTPConnection(settings("127.0.0.1", "3025", "auto@keycloak.org", "true", null, null, null, null));
assertStatus(response, 500);
}
@Test
@Order(4)
public void testWithAuthEnabledValidCredentials() throws Exception {
String password = "admin";
@@ -86,6 +109,7 @@ public class SMTPConnectionTest {
}
@Test
@Order(5)
public void testAuthEnabledAndSavedCredentials() throws Exception {
String password = "admin";
RealmResource realm = adminClient.realms().realm(managedRealm.getName());
@@ -101,11 +125,122 @@ public class SMTPConnectionTest {
assertStatus(response, 204);
}
@Test
@Order(6)
public void testWithTokenAuthEnabledAndTokenCacheAndSavedCredentials() throws Exception {
final var realm = adminClient.realms().realm(managedRealm.getName());
final var realmRep = realm.toRepresentation();
realmRep.setSmtpServer(smtpMapForTokenAuth("127.0.0.1", "3025", "auto@keycloak.org", "true", null, null,
"admin@localhost", keycloakUrls.getToken(managedRealm.getName()), "test-smtp-client-I", "secret", "basic", null, null));
managedRealm.updateWithCleanup(r -> RealmConfigBuilder.update(realmRep));
//verify token sent to smtp
mailServer.credentials("admin@localhost", token -> {
var accessToken = oAuthClient.verifyToken(token, AccessToken.class);
return accessToken.isActive();
});
final var firstResponse = realm.testSMTPConnection(settings("127.0.0.1", "3025", "auto@keycloak.org", "true", null, null,
"admin@localhost", keycloakUrls.getToken(managedRealm.getName()), "test-smtp-client-I", SECRET_VALUE, "basic"));
assertStatus(firstResponse, 204);
assertMailReceived();
assertClientLoginEventsCountAndClear(realm, "test-smtp-client-I", 1);
assertEventsEmpty(realm);
final var secondResponse = realm.testSMTPConnection(settings("127.0.0.1", "3025", "auto@keycloak.org", "true", null, null,
"admin@localhost", keycloakUrls.getToken(managedRealm.getName()), "test-smtp-client-I", SECRET_VALUE, "basic"));
assertStatus(secondResponse, 204);
assertMailReceived();
assertEventsEmpty(realm);
}
@Test
@Order(7)
public void testWithTokenAuthEnabledRetryGivesUp() throws Exception {
RealmResource realm = adminClient.realms().realm(managedRealm.getName());
RealmRepresentation realmRep = realm.toRepresentation();
//decline token sent to smtp
mailServer.credentials("admin@localhost", token -> false);
final var response = realm.testSMTPConnection(settings("127.0.0.1", "3025", "auto@keycloak.org", "true", null, null,
"admin@localhost", keycloakUrls.getToken(managedRealm.getName()), "test-smtp-client-II", "secret", "basic"));
assertStatus(response, 500);
assertMailNotReceived();
assertClientLoginEventsCountAndClear(realm, "test-smtp-client-II", 2);
assertEventsEmpty(realm);
}
@Test
@Order(8)
public void testWithTokenAuthEnabledAndRetryWithValidTokenInSecondTry() throws Exception {
final var realm = adminClient.realms().realm(managedRealm.getName());
final List<AccessToken> tempAccessToken = new ArrayList<>();
//decline token sent to smtp
mailServer.credentials("admin@localhost", token -> {
var accessToken = oAuthClient
.verifyToken(token, AccessToken.class);
if (tempAccessToken.isEmpty()) {
tempAccessToken.add(accessToken);
// even a valid token is declined for the test
return false;
} else {
// make sure retry created a new token
Assertions.assertNotEquals(tempAccessToken.stream().findFirst().orElseThrow().getId(),accessToken.getId());
tempAccessToken.clear();
return accessToken.isActive();
}
});
final var response = realm.testSMTPConnection(settings("127.0.0.1", "3025", "auto@keycloak.org", "true", null, null,
"admin@localhost", keycloakUrls.getToken(managedRealm.getName()), "test-smtp-client-III", "secret", "basic"));
assertStatus(response, 204);
assertMailReceived();
assertClientLoginEventsCountAndClear(realm, "test-smtp-client-III", 2);
assertEventsEmpty(realm);
}
private Map<String, String> settings(String host, String port, String from, String auth, String ssl, String starttls,
String username, String password) throws Exception {
return smtpMap(host, port, from, auth, ssl, starttls, username, password, "", "");
}
private Map<String, String> settings(String host, String port, String from, String auth, String ssl, String starttls,
String username, String authTokenUrl, String authTokenClientId, String authTokenClientSecret, String authTokenScope) throws Exception {
return smtpMapForTokenAuth(host, port, from, auth, ssl, starttls, username, authTokenUrl, authTokenClientId, authTokenClientSecret, authTokenScope,"", "");
}
private Map<String, String> smtpMapForTokenAuth(String host, String port, String from, String auth, String ssl, String starttls,
String username, String authTokenUrl, String authTokenClientId, String authTokenClientSecret, String authTokenScope, String replyTo, String envelopeFrom) {
Map<String, String> config = new HashMap<>();
config.put("host", host);
config.put("port", port);
config.put("from", from);
config.put("ssl", ssl);
config.put("starttls", starttls);
config.put("user", username);
config.put("auth", auth);
config.put("authType", "token");
config.put("authTokenUrl", authTokenUrl);
config.put("authTokenClientId", authTokenClientId);
config.put("authTokenClientSecret", authTokenClientSecret);
config.put("authTokenScope", authTokenScope);
config.put("replyTo", replyTo);
config.put("envelopeFrom", envelopeFrom);
return config;
}
private Map<String, String> smtpMap(String host, String port, String from, String auth, String ssl, String starttls,
String username, String password, String replyTo, String envelopeFrom) {
Map<String, String> config = new HashMap<>();
@@ -113,6 +248,7 @@ public class SMTPConnectionTest {
config.put("port", port);
config.put("from", from);
config.put("auth", auth);
config.put("authType", "basic");
config.put("ssl", ssl);
config.put("starttls", starttls);
config.put("user", username);
@@ -122,6 +258,17 @@ public class SMTPConnectionTest {
return config;
}
private void assertClientLoginEventsCountAndClear(RealmResource realm, String clientId, int count) {
var events = realm.getEvents();
Assertions.assertEquals(count, events.stream().filter(e -> clientId.equals(e.getClientId())).count());
events.stream().filter(e -> clientId.equals(e.getClientId())).forEach(event -> Assertions.assertEquals("CLIENT_LOGIN", event.getType()));
realm.clearEvents();
}
private void assertEventsEmpty(RealmResource realm) {
Assertions.assertTrue(realm.getEvents().isEmpty());
}
private void assertStatus(Response response, int status) {
assertEquals(status, response.getStatus());
response.close();
@@ -132,6 +279,7 @@ public class SMTPConnectionTest {
try {
MimeMessage message = mailServer.getReceivedMessages()[0];
assertEquals("[KEYCLOAK] - SMTP test message", message.getSubject());
mailServer.runCleanup();
} catch (Exception e) {
e.printStackTrace();
}
@@ -140,14 +288,32 @@ public class SMTPConnectionTest {
}
}
private void assertMailNotReceived() {
assertEquals(0, mailServer.getReceivedMessages().length);
}
public static class SMTPRealmWithClientAndUser implements RealmConfig {
@Override
public RealmConfigBuilder configure(RealmConfigBuilder realm) {
realm.eventsEnabled(true); //testing XOAUTH2 token caching behaviour
realm.addClient("myclient")
.secret("mysecret")
.directAccessGrants();
//add client for token gathering (XOAUTH2)
//reuse the same client does not work
realm.addClient("test-smtp-client-I")
.secret("secret")
.serviceAccountsEnabled(true);
realm.addClient("test-smtp-client-II")
.secret("secret")
.serviceAccountsEnabled(true);
realm.addClient("test-smtp-client-III")
.secret("secret")
.serviceAccountsEnabled(true);
realm.addUser("myadmin")
.name("My", "Admin")
.email("admin@localhost")

View File

@@ -20,14 +20,17 @@
package org.keycloak.examples.providersoverride;
import org.keycloak.email.DefaultEmailSenderProvider;
import org.keycloak.email.EmailAuthenticator;
import org.keycloak.models.KeycloakSession;
import java.util.Map;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
public class CustomDefaultEmailSenderProvider1 extends DefaultEmailSenderProvider {
public CustomDefaultEmailSenderProvider1(KeycloakSession session) {
super(session);
public CustomDefaultEmailSenderProvider1(KeycloakSession session, Map<EmailAuthenticator.AuthenticatorType, EmailAuthenticator> authenticators) {
super(session, authenticators);
}
}

View File

@@ -20,14 +20,17 @@
package org.keycloak.examples.providersoverride;
import org.keycloak.email.DefaultEmailSenderProvider;
import org.keycloak.email.EmailAuthenticator;
import org.keycloak.models.KeycloakSession;
import java.util.Map;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
public class CustomDefaultEmailSenderProvider2 extends DefaultEmailSenderProvider {
public CustomDefaultEmailSenderProvider2(KeycloakSession session) {
super(session);
public CustomDefaultEmailSenderProvider2(KeycloakSession session, Map<EmailAuthenticator.AuthenticatorType, EmailAuthenticator> authenticators) {
super(session, authenticators);
}
}

View File

@@ -35,6 +35,6 @@ public class CustomDefaultEmailSenderProviderFactory1 extends DefaultEmailSender
@Override
public EmailSenderProvider create(KeycloakSession session) {
return new CustomDefaultEmailSenderProvider1(session);
return new CustomDefaultEmailSenderProvider1(session, getEmailAuthenticators());
}
}

View File

@@ -35,6 +35,6 @@ public class CustomDefaultEmailSenderProviderFactory2 extends DefaultEmailSender
@Override
public EmailSenderProvider create(KeycloakSession session) {
return new CustomDefaultEmailSenderProvider2(session);
return new CustomDefaultEmailSenderProvider2(session, getEmailAuthenticators());
}
}