From 4fb1c41155f486e49181c93dcebf29f692b62ef5 Mon Sep 17 00:00:00 2001 From: Sebastian Rose Date: Thu, 14 Nov 2024 21:09:27 +0100 Subject: [PATCH] Sending Mails via SMTP and XOAUTH2 authentication mechanism Closes #17432 Signed-off-by: Sebastian Rose --- .../release_notes/topics/26_2_0.adoc | 6 + .../server_admin/topics/realms/email.adoc | 81 ++++++- .../topics/templates/document-attributes.adoc | 2 + .../admin/messages/messages_en.properties | 13 +- .../admin-ui/src/realm-settings/EmailTab.tsx | 107 ++++++++- .../test/realm-settings/email.spec.ts | 57 +++++ js/apps/admin-ui/test/realm-settings/email.ts | 149 ++++++++++++ .../datastore/DefaultExportImportManager.java | 25 +- pom.xml | 2 +- .../models/utils/StripSecretsUtils.java | 1 + .../keycloak/utils/KeycloakSessionUtil.java | 26 ++ .../email/DefaultEmailAuthenticator.java | 19 ++ .../email/DefaultEmailSenderProvider.java | 227 +++++++++++------- .../DefaultEmailSenderProviderFactory.java | 14 +- .../keycloak/email/EmailAuthenticator.java | 19 ++ .../email/PasswordAuthEmailAuthenticator.java | 21 ++ .../email/TokenAuthEmailAuthenticator.java | 140 +++++++++++ .../resources/admin/RealmAdminResource.java | 3 + .../testframework/server/KeycloakUrls.java | 8 + test-framework/email-server/pom.xml | 2 +- .../testframework/mail/MailServer.java | 9 + .../tests/admin/SMTPConnectionTest.java | 166 +++++++++++++ .../CustomDefaultEmailSenderProvider1.java | 7 +- .../CustomDefaultEmailSenderProvider2.java | 7 +- ...tomDefaultEmailSenderProviderFactory1.java | 2 +- ...tomDefaultEmailSenderProviderFactory2.java | 2 +- 26 files changed, 1000 insertions(+), 115 deletions(-) create mode 100644 js/apps/admin-ui/test/realm-settings/email.spec.ts create mode 100644 js/apps/admin-ui/test/realm-settings/email.ts create mode 100644 services/src/main/java/org/keycloak/email/DefaultEmailAuthenticator.java create mode 100644 services/src/main/java/org/keycloak/email/EmailAuthenticator.java create mode 100644 services/src/main/java/org/keycloak/email/PasswordAuthEmailAuthenticator.java create mode 100644 services/src/main/java/org/keycloak/email/TokenAuthEmailAuthenticator.java diff --git a/docs/documentation/release_notes/topics/26_2_0.adoc b/docs/documentation/release_notes/topics/26_2_0.adoc index 2040c8f5f8b..b29d5625bc5 100644 --- a/docs/documentation/release_notes/topics/26_2_0.adoc +++ b/docs/documentation/release_notes/topics/26_2_0.adoc @@ -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. diff --git a/docs/documentation/server_admin/topics/realms/email.adoc b/docs/documentation/server_admin/topics/realms/email.adoc index aa94bac7186..2d833ed42c7 100644 --- a/docs/documentation/server_admin/topics/realms/email.adoc +++ b/docs/documentation/server_admin/topics/realms/email.adoc @@ -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:: +`@` + +Host:: +`smtp.office365.com` + +Port:: +`587` + +Encryption:: +Check Start TLS + +Username:: +`@` (might be the same of a different value than the sender value) + +Auth Token Url:: +`+https://login.microsoftonline.com//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:: +`` ++ +Replace ApplicationId with the id of your application in Azure, usually a UUID. + +Auth Token ClientSecret:: +`` + +== 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. diff --git a/docs/documentation/topics/templates/document-attributes.adoc b/docs/documentation/topics/templates/document-attributes.adoc index 5ae61cc20be..634e3543995 100644 --- a/docs/documentation/topics/templates/document-attributes.adoc +++ b/docs/documentation/topics/templates/document-attributes.adoc @@ -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 diff --git a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties index c694cf37702..76210770d46 100644 --- a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties +++ b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties @@ -3451,4 +3451,15 @@ selectRole=Select role selectUsers=Select user selectClient=Select client grantedPermissions=Granted Permissions -deniedPermissions=Denied Permissions \ No newline at end of file +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 \ No newline at end of file diff --git a/js/apps/admin-ui/src/realm-settings/EmailTab.tsx b/js/apps/admin-ui/src/realm-settings/EmailTab.tsx index 1add5d6db00..5b7ea35d137 100644 --- a/js/apps/admin-ui/src/realm-settings/EmailTab.tsx +++ b/js/apps/admin-ui/src/realm-settings/EmailTab.tsx @@ -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 = ({ - + + ( + <> + field.onChange("basic")} + /> + field.onChange("token")} + /> + + )} + /> + + {authType === "basic" && ( + + )} + {authType === "token" && ( + <> + + + + + + )} )} + ( + field.onChange("" + value)} + /> + )} + /> {currentUser && ( {currentUser.email ? ( diff --git a/js/apps/admin-ui/test/realm-settings/email.spec.ts b/js/apps/admin-ui/test/realm-settings/email.spec.ts new file mode 100644 index 00000000000..645d28eac94 --- /dev/null +++ b/js/apps/admin-ui/test/realm-settings/email.spec.ts @@ -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); + }); +}); diff --git a/js/apps/admin-ui/test/realm-settings/email.ts b/js/apps/admin-ui/test/realm-settings/email.ts new file mode 100644 index 00000000000..8c98c8a5053 --- /dev/null +++ b/js/apps/admin-ui/test/realm-settings/email.ts @@ -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(); +} diff --git a/model/storage-private/src/main/java/org/keycloak/storage/datastore/DefaultExportImportManager.java b/model/storage-private/src/main/java/org/keycloak/storage/datastore/DefaultExportImportManager.java index 6901d13f1aa..84294584c07 100644 --- a/model/storage-private/src/main/java/org/keycloak/storage/datastore/DefaultExportImportManager.java +++ b/model/storage-private/src/main/java/org/keycloak/storage/datastore/DefaultExportImportManager.java @@ -860,11 +860,28 @@ public class DefaultExportImportManager implements ExportImportManager { session.clientPolicy().updateRealmModelFromRepresentation(realm, rep); if (rep.getSmtpServer() != null) { - Map 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 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); } diff --git a/pom.xml b/pom.xml index 57b74d53545..d77357dec47 100644 --- a/pom.xml +++ b/pom.xml @@ -179,7 +179,7 @@ 23.6.0.24.10 - 2.1.0-alpha-1 + 2.1.3 2.10 4.13.2 2.7.0.Final diff --git a/server-spi-private/src/main/java/org/keycloak/models/utils/StripSecretsUtils.java b/server-spi-private/src/main/java/org/keycloak/models/utils/StripSecretsUtils.java index 04703d98be5..cae4cb6529f 100644 --- a/server-spi-private/src/main/java/org/keycloak/models/utils/StripSecretsUtils.java +++ b/server-spi-private/src/main/java/org/keycloak/models/utils/StripSecretsUtils.java @@ -134,6 +134,7 @@ public class StripSecretsUtils { private static RealmRepresentation stripRealm(RealmRepresentation rep) { stripFromMap(rep.getSmtpServer(), "password"); + stripFromMap(rep.getSmtpServer(), "authTokenClientSecret"); return rep; } diff --git a/server-spi/src/main/java/org/keycloak/utils/KeycloakSessionUtil.java b/server-spi/src/main/java/org/keycloak/utils/KeycloakSessionUtil.java index a2010503b8e..f4504f54313 100644 --- a/server-spi/src/main/java/org/keycloak/utils/KeycloakSessionUtil.java +++ b/server-spi/src/main/java/org/keycloak/utils/KeycloakSessionUtil.java @@ -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; + } + } + } diff --git a/services/src/main/java/org/keycloak/email/DefaultEmailAuthenticator.java b/services/src/main/java/org/keycloak/email/DefaultEmailAuthenticator.java new file mode 100644 index 00000000000..4392dd13265 --- /dev/null +++ b/services/src/main/java/org/keycloak/email/DefaultEmailAuthenticator.java @@ -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 config, Transport transport) throws EmailException { + try { + transport.connect(); + } catch (MessagingException e) { + throw new EmailException("Non authenticated connect failed", e); + } + } +} diff --git a/services/src/main/java/org/keycloak/email/DefaultEmailSenderProvider.java b/services/src/main/java/org/keycloak/email/DefaultEmailSenderProvider.java index 2e74a6b523b..dcd878f4fa5 100644 --- a/services/src/main/java/org/keycloak/email/DefaultEmailSenderProvider.java +++ b/services/src/main/java/org/keycloak/email/DefaultEmailSenderProvider.java @@ -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 authenticators; + private final KeycloakSession session; - public DefaultEmailSenderProvider(KeycloakSession session) { + public DefaultEmailSenderProvider(KeycloakSession session, Map authenticators) { + this.authenticators = authenticators; this.session = session; } @@ -72,58 +74,110 @@ public class DefaultEmailSenderProvider implements EmailSenderProvider { @Override public void send(Map 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 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 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 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 config) { + return "true".equals(config.get("starttls")); + } + + private static boolean isSslConfigured(Map config) { + return "true".equals(config.get("ssl")); + } + + private static boolean isDebugEnabled(Map config) { + return "true".equals(config.get("debug")); + } + + private boolean isAuthConfigured(Map config) { + return "true".equals(config.get("auth")); + } + + private boolean isAuthTypeTokenConfigured(Map 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()); } } diff --git a/services/src/main/java/org/keycloak/email/DefaultEmailSenderProviderFactory.java b/services/src/main/java/org/keycloak/email/DefaultEmailSenderProviderFactory.java index 08bec51e7f1..cad3f41ec43 100644 --- a/services/src/main/java/org/keycloak/email/DefaultEmailSenderProviderFactory.java +++ b/services/src/main/java/org/keycloak/email/DefaultEmailSenderProviderFactory.java @@ -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 Stian Thorgersen */ public class DefaultEmailSenderProviderFactory implements EmailSenderProviderFactory { + private final Map 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 getEmailAuthenticators() { + return emailAuthenticators; + } } diff --git a/services/src/main/java/org/keycloak/email/EmailAuthenticator.java b/services/src/main/java/org/keycloak/email/EmailAuthenticator.java new file mode 100644 index 00000000000..80892995130 --- /dev/null +++ b/services/src/main/java/org/keycloak/email/EmailAuthenticator.java @@ -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 config, Transport transport) throws EmailException; + + enum AuthenticatorType { + NONE, + BASIC, + TOKEN + } +} + + diff --git a/services/src/main/java/org/keycloak/email/PasswordAuthEmailAuthenticator.java b/services/src/main/java/org/keycloak/email/PasswordAuthEmailAuthenticator.java new file mode 100644 index 00000000000..634841a7cc1 --- /dev/null +++ b/services/src/main/java/org/keycloak/email/PasswordAuthEmailAuthenticator.java @@ -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 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); + } + } + +} diff --git a/services/src/main/java/org/keycloak/email/TokenAuthEmailAuthenticator.java b/services/src/main/java/org/keycloak/email/TokenAuthEmailAuthenticator.java new file mode 100644 index 00000000000..297af519d38 --- /dev/null +++ b/services/src/main/java/org/keycloak/email/TokenAuthEmailAuthenticator.java @@ -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 tokenStore = new ConcurrentHashMap<>(); + + @Override + public void connect(KeycloakSession session, Map 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 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 maybeToken = getAccessToken(session, response); + Optional 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 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 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) { + } +} diff --git a/services/src/main/java/org/keycloak/services/resources/admin/RealmAdminResource.java b/services/src/main/java/org/keycloak/services/resources/admin/RealmAdminResource.java index 28b386acd6c..68b391ded17 100644 --- a/services/src/main/java/org/keycloak/services/resources/admin/RealmAdminResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/RealmAdminResource.java @@ -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(); diff --git a/test-framework/core/src/main/java/org/keycloak/testframework/server/KeycloakUrls.java b/test-framework/core/src/main/java/org/keycloak/testframework/server/KeycloakUrls.java index 7dc0b128b3e..8b3258e1424 100644 --- a/test-framework/core/src/main/java/org/keycloak/testframework/server/KeycloakUrls.java +++ b/test-framework/core/src/main/java/org/keycloak/testframework/server/KeycloakUrls.java @@ -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"; + } } diff --git a/test-framework/email-server/pom.xml b/test-framework/email-server/pom.xml index 40b18a11258..d9c622f9424 100755 --- a/test-framework/email-server/pom.xml +++ b/test-framework/email-server/pom.xml @@ -32,7 +32,7 @@ Email server extension for Keycloak Test Framework - 2.1.1 + 2.1.3 diff --git a/test-framework/email-server/src/main/java/org/keycloak/testframework/mail/MailServer.java b/test-framework/email-server/src/main/java/org/keycloak/testframework/mail/MailServer.java index 64e83942cf3..76dae5661af 100644 --- a/test-framework/email-server/src/main/java/org/keycloak/testframework/mail/MailServer.java +++ b/test-framework/email-server/src/main/java/org/keycloak/testframework/mail/MailServer.java @@ -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(); } diff --git a/tests/base/src/test/java/org/keycloak/tests/admin/SMTPConnectionTest.java b/tests/base/src/test/java/org/keycloak/tests/admin/SMTPConnectionTest.java index ebce3a53caa..62ea502511a 100644 --- a/tests/base/src/test/java/org/keycloak/tests/admin/SMTPConnectionTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/admin/SMTPConnectionTest.java @@ -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 Bruno Oliveira */ +@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 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 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 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 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 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 smtpMap(String host, String port, String from, String auth, String ssl, String starttls, String username, String password, String replyTo, String envelopeFrom) { Map 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") diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/examples/providersoverride/CustomDefaultEmailSenderProvider1.java b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/examples/providersoverride/CustomDefaultEmailSenderProvider1.java index 7a74514dd12..5e0ac7c1a04 100644 --- a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/examples/providersoverride/CustomDefaultEmailSenderProvider1.java +++ b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/examples/providersoverride/CustomDefaultEmailSenderProvider1.java @@ -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 Marek Posolda */ public class CustomDefaultEmailSenderProvider1 extends DefaultEmailSenderProvider { - public CustomDefaultEmailSenderProvider1(KeycloakSession session) { - super(session); + public CustomDefaultEmailSenderProvider1(KeycloakSession session, Map authenticators) { + super(session, authenticators); } } diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/examples/providersoverride/CustomDefaultEmailSenderProvider2.java b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/examples/providersoverride/CustomDefaultEmailSenderProvider2.java index 39ea1ee6331..aec705331bb 100644 --- a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/examples/providersoverride/CustomDefaultEmailSenderProvider2.java +++ b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/examples/providersoverride/CustomDefaultEmailSenderProvider2.java @@ -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 Marek Posolda */ public class CustomDefaultEmailSenderProvider2 extends DefaultEmailSenderProvider { - public CustomDefaultEmailSenderProvider2(KeycloakSession session) { - super(session); + public CustomDefaultEmailSenderProvider2(KeycloakSession session, Map authenticators) { + super(session, authenticators); } } diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/examples/providersoverride/CustomDefaultEmailSenderProviderFactory1.java b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/examples/providersoverride/CustomDefaultEmailSenderProviderFactory1.java index cf38ae6e203..557c30d4e78 100644 --- a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/examples/providersoverride/CustomDefaultEmailSenderProviderFactory1.java +++ b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/examples/providersoverride/CustomDefaultEmailSenderProviderFactory1.java @@ -35,6 +35,6 @@ public class CustomDefaultEmailSenderProviderFactory1 extends DefaultEmailSender @Override public EmailSenderProvider create(KeycloakSession session) { - return new CustomDefaultEmailSenderProvider1(session); + return new CustomDefaultEmailSenderProvider1(session, getEmailAuthenticators()); } } diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/examples/providersoverride/CustomDefaultEmailSenderProviderFactory2.java b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/examples/providersoverride/CustomDefaultEmailSenderProviderFactory2.java index b11dda02c6f..e9a0b438c69 100644 --- a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/examples/providersoverride/CustomDefaultEmailSenderProviderFactory2.java +++ b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/examples/providersoverride/CustomDefaultEmailSenderProviderFactory2.java @@ -35,6 +35,6 @@ public class CustomDefaultEmailSenderProviderFactory2 extends DefaultEmailSender @Override public EmailSenderProvider create(KeycloakSession session) { - return new CustomDefaultEmailSenderProvider2(session); + return new CustomDefaultEmailSenderProvider2(session, getEmailAuthenticators()); } }