From f7030b054783e5c4eefee9bbd7467988b46f9ba4 Mon Sep 17 00:00:00 2001 From: Bogdana Date: Tue, 30 Jul 2024 09:45:35 +0200 Subject: [PATCH] SONAR-22638 add OAuth authentication for sending emails --- server/sonar-server-common/build.gradle | 2 + .../email/EmailNotificationChannel.java | 27 +++++++++-- .../oauth/OAuthMicrosoftRestClient.java | 47 +++++++++++++++++++ .../oauth/ScribeMicrosoftOauth2Api.java | 45 ++++++++++++++++++ .../org/sonar/server/oauth/package-info.java | 23 +++++++++ .../email/EmailNotificationChannelTest.java | 20 ++++---- .../oauth/OAuthMicrosoftRestClientTest.java | 34 ++++++++++++++ .../oauth/ScribeMicrosoftOauth2ApiTest.java | 38 +++++++++++++++ 8 files changed, 223 insertions(+), 13 deletions(-) create mode 100644 server/sonar-server-common/src/main/java/org/sonar/server/oauth/OAuthMicrosoftRestClient.java create mode 100644 server/sonar-server-common/src/main/java/org/sonar/server/oauth/ScribeMicrosoftOauth2Api.java create mode 100644 server/sonar-server-common/src/main/java/org/sonar/server/oauth/package-info.java create mode 100644 server/sonar-server-common/src/test/java/org/sonar/server/oauth/OAuthMicrosoftRestClientTest.java create mode 100644 server/sonar-server-common/src/test/java/org/sonar/server/oauth/ScribeMicrosoftOauth2ApiTest.java diff --git a/server/sonar-server-common/build.gradle b/server/sonar-server-common/build.gradle index 083eb70991d..bc9f402f590 100644 --- a/server/sonar-server-common/build.gradle +++ b/server/sonar-server-common/build.gradle @@ -11,6 +11,8 @@ dependencies { implementation 'com.fasterxml.jackson.core:jackson-databind' api 'commons-io:commons-io' + api 'com.github.scribejava:scribejava-apis' + api 'com.github.scribejava:scribejava-core' api 'com.google.guava:guava' api 'com.squareup.okhttp3:okhttp' api 'org.apache.commons:commons-email' diff --git a/server/sonar-server-common/src/main/java/org/sonar/server/notification/email/EmailNotificationChannel.java b/server/sonar-server-common/src/main/java/org/sonar/server/notification/email/EmailNotificationChannel.java index 4c9fcc00109..9948abe4f1d 100644 --- a/server/sonar-server-common/src/main/java/org/sonar/server/notification/email/EmailNotificationChannel.java +++ b/server/sonar-server-common/src/main/java/org/sonar/server/notification/email/EmailNotificationChannel.java @@ -23,6 +23,7 @@ import java.net.MalformedURLException; import java.net.URL; import java.time.Duration; import java.util.Objects; +import java.util.Properties; import java.util.Set; import java.util.regex.Pattern; import javax.annotation.CheckForNull; @@ -41,6 +42,7 @@ import org.sonar.db.DbClient; import org.sonar.db.DbSession; import org.sonar.db.user.UserDto; import org.sonar.server.email.EmailSmtpConfiguration; +import org.sonar.server.oauth.OAuthMicrosoftRestClient; import org.sonar.server.issue.notification.EmailMessage; import org.sonar.server.issue.notification.EmailTemplate; import org.sonar.server.notification.NotificationChannel; @@ -97,6 +99,7 @@ public class EmailNotificationChannel extends NotificationChannel { private static final String SUBJECT_DEFAULT = "Notification"; private static final String SMTP_HOST_NOT_CONFIGURED_DEBUG_MSG = "SMTP host was not configured - email will not be sent"; private static final String MAIL_SENT_FROM = "%sMail sent from: %s"; + private static final String OAUTH_AUTH_METHOD = "OAUTH"; private final EmailSmtpConfiguration configuration; private final Server server; @@ -289,14 +292,32 @@ public class EmailNotificationChannel extends NotificationChannel { } } - private void setConnectionDetails(Email email) { + private void setConnectionDetails(Email email) throws EmailException { email.setHostName(configuration.getSmtpHost()); configureSecureConnection(email); + email.setSocketConnectionTimeout(SOCKET_TIMEOUT); + email.setSocketTimeout(SOCKET_TIMEOUT); + if (OAUTH_AUTH_METHOD.equals(configuration.getAuthMethod())) { + setOauthAuthentication(email); + } else if (StringUtils.isNotBlank(configuration.getSmtpUsername()) || StringUtils.isNotBlank(configuration.getSmtpPassword())) { + setBasicAuthentication(email); + } + } + + private void setOauthAuthentication(Email email) throws EmailException { + String token = OAuthMicrosoftRestClient.getAccessTokenFromClientCredentialsGrantFlow(configuration.getOAuthHost(), configuration.getOAuthClientId(), + configuration.getOAuthClientSecret(), configuration.getOAuthTenant(), configuration.getOAuthScope()); + email.setAuthentication(configuration.getSmtpUsername(), token); + Properties props = email.getMailSession().getProperties(); + props.put("mail.smtp.auth.mechanisms", "XOAUTH2"); + props.put("mail.smtp.auth.login.disable", "true"); + props.put("mail.smtp.auth.plain.disable", "true"); + } + + private void setBasicAuthentication(Email email) { if (StringUtils.isNotBlank(configuration.getSmtpUsername()) || StringUtils.isNotBlank(configuration.getSmtpPassword())) { email.setAuthentication(configuration.getSmtpUsername(), configuration.getSmtpPassword()); } - email.setSocketConnectionTimeout(SOCKET_TIMEOUT); - email.setSocketTimeout(SOCKET_TIMEOUT); } private void configureSecureConnection(Email email) { diff --git a/server/sonar-server-common/src/main/java/org/sonar/server/oauth/OAuthMicrosoftRestClient.java b/server/sonar-server-common/src/main/java/org/sonar/server/oauth/OAuthMicrosoftRestClient.java new file mode 100644 index 00000000000..3a7ada75440 --- /dev/null +++ b/server/sonar-server-common/src/main/java/org/sonar/server/oauth/OAuthMicrosoftRestClient.java @@ -0,0 +1,47 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.oauth; + +import com.github.scribejava.core.builder.ServiceBuilder; +import com.github.scribejava.core.oauth.OAuth20Service; +import java.io.IOException; +import java.util.concurrent.ExecutionException; + +public class OAuthMicrosoftRestClient { + + private OAuthMicrosoftRestClient() { + // Only static method + } + + public static String getAccessTokenFromClientCredentialsGrantFlow(String host, String clientId, String clientSecret, String tenant, String scope) { + final OAuth20Service service = new ServiceBuilder(clientId) + .apiSecret(clientSecret) + .defaultScope(scope) + .build(new ScribeMicrosoftOauth2Api(host, tenant)); + try { + return service.getAccessTokenClientCredentialsGrant().getAccessToken(); + } catch (IOException | ExecutionException e) { + throw new IllegalStateException("Unable to get a token: " + e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while getting a token: " + e); + } + } +} diff --git a/server/sonar-server-common/src/main/java/org/sonar/server/oauth/ScribeMicrosoftOauth2Api.java b/server/sonar-server-common/src/main/java/org/sonar/server/oauth/ScribeMicrosoftOauth2Api.java new file mode 100644 index 00000000000..498abdeed27 --- /dev/null +++ b/server/sonar-server-common/src/main/java/org/sonar/server/oauth/ScribeMicrosoftOauth2Api.java @@ -0,0 +1,45 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.oauth; + +import com.github.scribejava.core.builder.api.DefaultApi20; + +public class ScribeMicrosoftOauth2Api extends DefaultApi20 { + + private static final String OAUTH_V2 = "/oauth2/v2.0/"; + private final String tenant; + private final String host; + + public ScribeMicrosoftOauth2Api(String host, String tenant) { + this.tenant = tenant; + this.host = host; + } + + @Override + public String getAccessTokenEndpoint() { + return host + "/" + tenant + OAUTH_V2 + "token"; + } + + @Override + protected String getAuthorizationBaseUrl() { + return host + "/" + tenant + OAUTH_V2 + "authorize"; + } + +} diff --git a/server/sonar-server-common/src/main/java/org/sonar/server/oauth/package-info.java b/server/sonar-server-common/src/main/java/org/sonar/server/oauth/package-info.java new file mode 100644 index 00000000000..b21abe2add4 --- /dev/null +++ b/server/sonar-server-common/src/main/java/org/sonar/server/oauth/package-info.java @@ -0,0 +1,23 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +@ParametersAreNonnullByDefault +package org.sonar.server.oauth; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/server/sonar-server-common/src/test/java/org/sonar/server/notification/email/EmailNotificationChannelTest.java b/server/sonar-server-common/src/test/java/org/sonar/server/notification/email/EmailNotificationChannelTest.java index e4aad4cf909..bd524e9e6a2 100644 --- a/server/sonar-server-common/src/test/java/org/sonar/server/notification/email/EmailNotificationChannelTest.java +++ b/server/sonar-server-common/src/test/java/org/sonar/server/notification/email/EmailNotificationChannelTest.java @@ -256,9 +256,9 @@ public class EmailNotificationChannelTest { @Test public void deliverAll_has_no_effect_if_set_is_empty() { EmailSmtpConfiguration emailSettings = mock(EmailSmtpConfiguration.class); - EmailNotificationChannel underTest = new EmailNotificationChannel(emailSettings, server, null, null); + EmailNotificationChannel emailNotificationChannel = new EmailNotificationChannel(emailSettings, server, null, null); - int count = underTest.deliverAll(Collections.emptySet()); + int count = emailNotificationChannel.deliverAll(Collections.emptySet()); assertThat(count).isZero(); verifyNoInteractions(emailSettings); @@ -272,9 +272,9 @@ public class EmailNotificationChannelTest { Set requests = IntStream.range(0, 1 + new Random().nextInt(10)) .mapToObj(i -> new EmailDeliveryRequest("foo" + i + "@moo", mock(Notification.class))) .collect(toSet()); - EmailNotificationChannel underTest = new EmailNotificationChannel(emailSettings, server, null, null); + EmailNotificationChannel emailNotificationChannel = new EmailNotificationChannel(emailSettings, server, null, null); - int count = underTest.deliverAll(requests); + int count = emailNotificationChannel.deliverAll(requests); assertThat(count).isZero(); verify(emailSettings).getSmtpHost(); @@ -290,9 +290,9 @@ public class EmailNotificationChannelTest { Set requests = IntStream.range(0, 1 + new Random().nextInt(10)) .mapToObj(i -> new EmailDeliveryRequest(emptyString, mock(Notification.class))) .collect(toSet()); - EmailNotificationChannel underTest = new EmailNotificationChannel(emailSettings, server, null, null); + EmailNotificationChannel emailNotificationChannel = new EmailNotificationChannel(emailSettings, server, null, null); - int count = underTest.deliverAll(requests); + int count = emailNotificationChannel.deliverAll(requests); assertThat(count).isZero(); verify(emailSettings).getSmtpHost(); @@ -316,9 +316,9 @@ public class EmailNotificationChannelTest { Set requests = Stream.of(notification1, notification2, notification3) .map(t -> new EmailDeliveryRequest(recipientEmail, t)) .collect(toSet()); - EmailNotificationChannel underTest = new EmailNotificationChannel(configuration, server, new EmailTemplate[] {template1, template3}, null); + EmailNotificationChannel emailNotificationChannel = new EmailNotificationChannel(configuration, server, new EmailTemplate[] {template1, template3}, null); - int count = underTest.deliverAll(requests); + int count = emailNotificationChannel.deliverAll(requests); assertThat(count).isEqualTo(2); assertThat(smtpServer.getMessages()).hasSize(2); @@ -356,9 +356,9 @@ public class EmailNotificationChannelTest { when(template11.format(notification1)).thenReturn(emailMessage11); when(template12.format(notification1)).thenReturn(emailMessage12); EmailDeliveryRequest request = new EmailDeliveryRequest(recipientEmail, notification1); - EmailNotificationChannel underTest = new EmailNotificationChannel(configuration, server, new EmailTemplate[] {template11, template12}, null); + EmailNotificationChannel emailNotificationChannel = new EmailNotificationChannel(configuration, server, new EmailTemplate[] {template11, template12}, null); - int count = underTest.deliverAll(Collections.singleton(request)); + int count = emailNotificationChannel.deliverAll(Collections.singleton(request)); assertThat(count).isOne(); assertThat(smtpServer.getMessages()).hasSize(1); diff --git a/server/sonar-server-common/src/test/java/org/sonar/server/oauth/OAuthMicrosoftRestClientTest.java b/server/sonar-server-common/src/test/java/org/sonar/server/oauth/OAuthMicrosoftRestClientTest.java new file mode 100644 index 00000000000..39e796d41a8 --- /dev/null +++ b/server/sonar-server-common/src/test/java/org/sonar/server/oauth/OAuthMicrosoftRestClientTest.java @@ -0,0 +1,34 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.oauth; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class OAuthMicrosoftRestClientTest { + + @Test + void getAccessTokenFromClientCredentialsGrantFlow_throwsException() { + assertThatThrownBy(() -> OAuthMicrosoftRestClient.getAccessTokenFromClientCredentialsGrantFlow("https://localhost", "clientId", "clientSecret", "tenant", "scope")) + .isInstanceOf(IllegalStateException.class) + .hasMessageStartingWith("Unable to get a token: "); + } +} diff --git a/server/sonar-server-common/src/test/java/org/sonar/server/oauth/ScribeMicrosoftOauth2ApiTest.java b/server/sonar-server-common/src/test/java/org/sonar/server/oauth/ScribeMicrosoftOauth2ApiTest.java new file mode 100644 index 00000000000..7c52e41099a --- /dev/null +++ b/server/sonar-server-common/src/test/java/org/sonar/server/oauth/ScribeMicrosoftOauth2ApiTest.java @@ -0,0 +1,38 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.oauth; + +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + +class ScribeMicrosoftOauth2ApiTest { + + private final ScribeMicrosoftOauth2Api scribeMicrosoftOauth2Api = new ScribeMicrosoftOauth2Api("host", "tenant"); + + @Test + void getAccessToken_returnCorrectEndpoint() { + assertThat(scribeMicrosoftOauth2Api.getAccessTokenEndpoint()).isEqualTo("host/tenant/oauth2/v2.0/token"); + } + + @Test + void getAuthorizationBaseUrl_returnCorrectEndpoint() { + assertThat(scribeMicrosoftOauth2Api.getAuthorizationBaseUrl()).isEqualTo("host/tenant/oauth2/v2.0/authorize"); + } +} -- 2.39.5