From a85f5b433099003d802a244daff4eed48f7b971d Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Thu, 8 Jun 2023 12:30:35 +0300 Subject: [PATCH 1/4] added initial implementation --- application/pom.xml | 4 + .../ThingsboardSecurityConfiguration.java | 5 +- .../server/controller/AdminController.java | 130 +++++- .../MailConfigTemplateController.java | 82 ++++ .../service/mail/DefaultMailService.java | 71 +-- .../DefaultTbMailConfigTemplateService.java | 19 + .../mail/RefreshTokenExpCheckService.java | 75 ++++ .../mail/TbMailConfigTemplateService.java | 9 + .../service/mail/TbMailContextComponent.java | 32 ++ .../server/service/mail/TbMailSender.java | 168 ++++++++ .../templates/mail_config_templates.json | 52 +++ .../src/main/resources/thingsboard.yml | 4 + .../server/common/data/StringUtils.java | 7 + .../common/data/mail/MailOauth2Provider.java | 31 ++ .../settings/AdminSettingsServiceImpl.java | 27 +- pom.xml | 6 + ui-ngx/src/app/core/http/admin.service.ts | 13 + .../pages/admin/mail-server.component.html | 377 ++++++++++++---- .../pages/admin/mail-server.component.scss | 89 ++++ .../home/pages/admin/mail-server.component.ts | 404 +++++++++++++++--- .../src/app/shared/models/settings.models.ts | 42 +- .../assets/locale/locale.constant-en_US.json | 24 +- 22 files changed, 1448 insertions(+), 223 deletions(-) create mode 100644 application/src/main/java/org/thingsboard/server/controller/MailConfigTemplateController.java create mode 100644 application/src/main/java/org/thingsboard/server/service/mail/DefaultTbMailConfigTemplateService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/mail/RefreshTokenExpCheckService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/mail/TbMailConfigTemplateService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/mail/TbMailContextComponent.java create mode 100644 application/src/main/java/org/thingsboard/server/service/mail/TbMailSender.java create mode 100644 application/src/main/resources/templates/mail_config_templates.json create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/mail/MailOauth2Provider.java diff --git a/application/pom.xml b/application/pom.xml index 8e7c3cd524..98b73133b6 100644 --- a/application/pom.xml +++ b/application/pom.xml @@ -354,6 +354,10 @@ com.slack.api slack-api-client + + com.google.oauth-client + google-oauth-client + diff --git a/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java b/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java index a418dc268b..793670f0ab 100644 --- a/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java +++ b/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java @@ -77,6 +77,8 @@ public class ThingsboardSecurityConfiguration { protected static final String[] NON_TOKEN_BASED_AUTH_ENTRY_POINTS = new String[] {"/index.html", "/assets/**", "/static/**", "/api/noauth/**", "/webjars/**", "/api/license/**"}; public static final String TOKEN_BASED_AUTH_ENTRY_POINT = "/api/**"; public static final String WS_TOKEN_BASED_AUTH_ENTRY_POINT = "/api/ws/**"; + public static final String MAIL_OAUTH2_PROCESSING_ENTRY_POINT = "/api/admin/mail/oauth2/code"; + @Autowired private ThingsboardErrorResponseHandler restAccessDeniedHandler; @@ -134,7 +136,7 @@ public class ThingsboardSecurityConfiguration { protected JwtTokenAuthenticationProcessingFilter buildJwtTokenAuthenticationProcessingFilter() throws Exception { List pathsToSkip = new ArrayList<>(Arrays.asList(NON_TOKEN_BASED_AUTH_ENTRY_POINTS)); pathsToSkip.addAll(Arrays.asList(WS_TOKEN_BASED_AUTH_ENTRY_POINT, TOKEN_REFRESH_ENTRY_POINT, FORM_BASED_LOGIN_ENTRY_POINT, - PUBLIC_LOGIN_ENTRY_POINT, DEVICE_API_ENTRY_POINT, WEBJARS_ENTRY_POINT)); + PUBLIC_LOGIN_ENTRY_POINT, DEVICE_API_ENTRY_POINT, WEBJARS_ENTRY_POINT, MAIL_OAUTH2_PROCESSING_ENTRY_POINT)); SkipPathRequestMatcher matcher = new SkipPathRequestMatcher(pathsToSkip, TOKEN_BASED_AUTH_ENTRY_POINT); JwtTokenAuthenticationProcessingFilter filter = new JwtTokenAuthenticationProcessingFilter(failureHandler, jwtHeaderTokenExtractor, matcher); @@ -201,6 +203,7 @@ public class ThingsboardSecurityConfiguration { .antMatchers(FORM_BASED_LOGIN_ENTRY_POINT).permitAll() // Login end-point .antMatchers(PUBLIC_LOGIN_ENTRY_POINT).permitAll() // Public login end-point .antMatchers(TOKEN_REFRESH_ENTRY_POINT).permitAll() // Token refresh end-point + .antMatchers(MAIL_OAUTH2_PROCESSING_ENTRY_POINT).permitAll() // Mail oauth2 code processing url .antMatchers(NON_TOKEN_BASED_AUTH_ENTRY_POINTS).permitAll() // static resources, user activation and password reset end-points .and() .authorizeRequests() diff --git a/application/src/main/java/org/thingsboard/server/controller/AdminController.java b/application/src/main/java/org/thingsboard/server/controller/AdminController.java index 5cf97a7cda..4019efe8ee 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AdminController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AdminController.java @@ -15,13 +15,24 @@ */ package org.thingsboard.server.controller; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.api.client.auth.oauth2.AuthorizationCodeRequestUrl; +import com.google.api.client.auth.oauth2.AuthorizationCodeTokenRequest; +import com.google.api.client.auth.oauth2.ClientParametersAuthentication; +import com.google.api.client.auth.oauth2.TokenResponse; +import com.google.api.client.http.GenericUrl; +import com.google.api.client.http.javanet.NetHttpTransport; +import com.google.api.client.json.gson.GsonFactory; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; import io.swagger.annotations.ApiOperation; import io.swagger.annotations.ApiParam; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Lazy; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; @@ -32,18 +43,25 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.context.request.async.DeferredResult; +import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.api.MailService; import org.thingsboard.rule.engine.api.SmsService; import org.thingsboard.server.common.data.AdminSettings; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.FeaturesInfo; import org.thingsboard.server.common.data.FeaturesInfo; import org.thingsboard.server.common.data.SystemInfo; import org.thingsboard.server.common.data.UpdateMessage; +import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.security.model.JwtPair; import org.thingsboard.server.common.data.security.model.JwtSettings; @@ -56,6 +74,7 @@ import org.thingsboard.server.common.data.sync.vc.VcUtils; import org.thingsboard.server.dao.audit.AuditLogService; import org.thingsboard.server.dao.settings.AdminSettingsService; import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.security.auth.oauth2.CookieUtils; import org.thingsboard.server.service.security.auth.jwt.settings.JwtSettingsService; import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.model.token.JwtTokenFactory; @@ -67,15 +86,29 @@ import org.thingsboard.server.service.sync.vc.autocommit.TbAutoCommitSettingsSer import org.thingsboard.server.service.system.SystemInfoService; import org.thingsboard.server.service.update.UpdateService; +import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.List; +import java.util.Optional; + +import static org.thingsboard.server.controller.ControllerConstants.*; import static org.thingsboard.server.controller.ControllerConstants.SYSTEM_AUTHORITY_PARAGRAPH; import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHORITY_PARAGRAPH; @RestController @TbCoreComponent +@Slf4j @RequestMapping("/api/admin") @RequiredArgsConstructor public class AdminController extends BaseController { + private static final String PREV_URI_PATH_PARAMETER = "prevUri"; + private static final String PREV_URI_COOKIE_NAME = "prev_uri"; + private static final String STATE_COOKIE_NAME = "state"; + private static final String MAIL_SETTINGS_KEY = "mail"; + private final MailService mailService; private final SmsService smsService; private final AdminSettingsService adminSettingsService; @@ -102,6 +135,7 @@ public class AdminController extends BaseController { AdminSettings adminSettings = checkNotNull(adminSettingsService.findAdminSettingsByKey(TenantId.SYS_TENANT_ID, key), "No Administration settings found for key: " + key); if (adminSettings.getKey().equals("mail")) { ((ObjectNode) adminSettings.getJsonValue()).remove("password"); + ((ObjectNode) adminSettings.getJsonValue()).remove("refreshToken"); } return adminSettings; } @@ -122,6 +156,7 @@ public class AdminController extends BaseController { if (adminSettings.getKey().equals("mail")) { mailService.updateMailConfiguration(); ((ObjectNode) adminSettings.getJsonValue()).remove("password"); + ((ObjectNode) adminSettings.getJsonValue()).remove("refreshToken"); } else if (adminSettings.getKey().equals("sms")) { smsService.updateSmsConfiguration(); } @@ -188,9 +223,20 @@ public class AdminController extends BaseController { accessControlService.checkPermission(getCurrentUser(), Resource.ADMIN_SETTINGS, Operation.READ); adminSettings = checkNotNull(adminSettings); if (adminSettings.getKey().equals("mail")) { - if (!adminSettings.getJsonValue().has("password")) { + if (adminSettings.getJsonValue().has("enableOauth2") && adminSettings.getJsonValue().get("enableOauth2").asBoolean()){ AdminSettings mailSettings = checkNotNull(adminSettingsService.findAdminSettingsByKey(TenantId.SYS_TENANT_ID, "mail")); - ((ObjectNode) adminSettings.getJsonValue()).put("password", mailSettings.getJsonValue().get("password").asText()); + JsonNode refreshToken = mailSettings.getJsonValue().get("refreshToken"); + if (refreshToken == null) { + throw new ThingsboardException("Refresh token was not generated. Please, generate refresh token.", ThingsboardErrorCode.GENERAL); + } + ObjectNode settings = (ObjectNode) adminSettings.getJsonValue(); + settings.put("refreshToken", refreshToken.asText()); + } + else { + if (!adminSettings.getJsonValue().has("password")) { + AdminSettings mailSettings = checkNotNull(adminSettingsService.findAdminSettingsByKey(TenantId.SYS_TENANT_ID, "mail")); + ((ObjectNode) adminSettings.getJsonValue()).put("password", mailSettings.getJsonValue().get("password").asText()); + } } String email = getCurrentUser().getEmail(); mailService.sendTestMail(adminSettings.getJsonValue(), email); @@ -362,4 +408,84 @@ public class AdminController extends BaseController { return systemInfoService.getFeaturesInfo(); } + @ApiOperation(value = "Get OAuth2 log in processing URL (getMailProcessingUrl)", notes = "Returns the URL enclosed in " + + "double quotes. After successful authentication with OAuth2 provider and user consent for requested scope, it makes a redirect to this path so that the platform can do " + + "further log in processing and generating access tokens. " + SYSTEM_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN')") + @RequestMapping(value = "/mail/oauth2/loginProcessingUrl", method = RequestMethod.GET) + @ResponseBody + public String getMailProcessingUrl() throws ThingsboardException { + accessControlService.checkPermission(getCurrentUser(), Resource.ADMIN_SETTINGS, Operation.READ); + return "\"/api/admin/mail/oauth2/code\""; + } + + @ApiOperation(value = "Redirect user to mail provider login page. ", notes = "After user logged in and provided access" + + "provider sends authorization code to specified redirect uri.)" ) + @PreAuthorize("hasAuthority('SYS_ADMIN')") + @RequestMapping(value = "/mail/oauth2/authorize", method = RequestMethod.GET, produces = "application/text") + public String getAuthorizationUrl(HttpServletRequest request, HttpServletResponse response) throws ThingsboardException { + String state = StringUtils.generateSafeToken(); + if (request.getParameter(PREV_URI_PATH_PARAMETER) != null) { + CookieUtils.addCookie(response, PREV_URI_COOKIE_NAME, request.getParameter(PREV_URI_PATH_PARAMETER), 180); + } + CookieUtils.addCookie(response, STATE_COOKIE_NAME, state, 180); + + accessControlService.checkPermission(getCurrentUser(), Resource.ADMIN_SETTINGS, Operation.READ); + AdminSettings adminSettings = checkNotNull(adminSettingsService.findAdminSettingsByKey(TenantId.SYS_TENANT_ID, MAIL_SETTINGS_KEY), "No Administration mail settings found"); + JsonNode jsonValue = adminSettings.getJsonValue(); + + String clientId = checkNotNull(jsonValue.get("clientId"), "No clientId was configured").asText(); + String authUri = checkNotNull(jsonValue.get("authUri"), "No authorization uri was configured").asText(); + String redirectUri = checkNotNull(jsonValue.get("redirectUri"), "No Redirect uri was configured").asText(); + List scope = JacksonUtil.convertValue(checkNotNull(jsonValue.get("scope"), "No scope was configured"), new TypeReference<>() { + }); + + return "\"" + new AuthorizationCodeRequestUrl(authUri, clientId) + .setScopes(scope) + .setState(state) + .setRedirectUri(redirectUri) + .build() + "\""; + } + + @RequestMapping(value = "/mail/oauth2/code", params = {"code", "state"}, method = RequestMethod.GET) + public void codeProcessingUrl( + @RequestParam(value = "code") String code, @RequestParam(value = "state") String state, + HttpServletRequest request, HttpServletResponse response) throws ThingsboardException, IOException { + Optional prevUrlOpt = CookieUtils.getCookie(request, PREV_URI_COOKIE_NAME); + Optional cookieState = CookieUtils.getCookie(request, STATE_COOKIE_NAME); + + String baseUrl = this.systemSecurityService.getBaseUrl(TenantId.SYS_TENANT_ID, new CustomerId(EntityId.NULL_UUID), request); + String prevUri = baseUrl + (prevUrlOpt.isPresent() ? prevUrlOpt.get().getValue(): "/settings/outgoing-mail"); + + if (cookieState.isEmpty() || !cookieState.get().getValue().equals(state)) { + CookieUtils.deleteCookie(request, response, STATE_COOKIE_NAME); + throw new ThingsboardException("Refresh token was not generated, invalid state param", ThingsboardErrorCode.BAD_REQUEST_PARAMS); + } + CookieUtils.deleteCookie(request, response, STATE_COOKIE_NAME); + CookieUtils.deleteCookie(request, response, PREV_URI_COOKIE_NAME); + + AdminSettings adminSettings = checkNotNull(adminSettingsService.findAdminSettingsByKey(TenantId.SYS_TENANT_ID, MAIL_SETTINGS_KEY), "No Administration mail settings found"); + JsonNode jsonValue = adminSettings.getJsonValue(); + + String clientId = checkNotNull(jsonValue.get("clientId"), "No clientId was configured").asText(); + String clientSecret = checkNotNull(jsonValue.get("clientSecret"), "No client secret was configured").asText(); + String clientRedirectUri = checkNotNull(jsonValue.get("redirectUri"), "No Redirect uri was configured").asText(); + String tokenUri = checkNotNull(jsonValue.get("tokenUri"), "No authorization uri was configured").asText(); + + TokenResponse tokenResponse; + try { + tokenResponse = new AuthorizationCodeTokenRequest(new NetHttpTransport(), new GsonFactory(), new GenericUrl(tokenUri), code) + .setRedirectUri(clientRedirectUri) + .setClientAuthentication(new ClientParametersAuthentication(clientId, clientSecret)) + .execute(); + } catch (IOException e) { + log.warn("Unable to retrieve refresh token: {}", e.getMessage()); + throw new ThingsboardException("Error while requesting access token: " + e.getMessage(), ThingsboardErrorCode.GENERAL); + } + ((ObjectNode)jsonValue).put("refreshToken", tokenResponse.getRefreshToken()); + ((ObjectNode)jsonValue).put("tokenGenerated", true); + + adminSettingsService.saveAdminSettings(TenantId.SYS_TENANT_ID, adminSettings); + response.sendRedirect(prevUri); + } } diff --git a/application/src/main/java/org/thingsboard/server/controller/MailConfigTemplateController.java b/application/src/main/java/org/thingsboard/server/controller/MailConfigTemplateController.java new file mode 100644 index 0000000000..fc13edd3d7 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/MailConfigTemplateController.java @@ -0,0 +1,82 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.controller; + +import com.fasterxml.jackson.databind.JsonNode; +import com.nimbusds.jose.shaded.json.JSONObject; +import io.swagger.annotations.ApiOperation; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.io.FileUtils; +import org.apache.commons.lang3.CharEncoding; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ClassPathResource; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.RestController; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.ResourceType; +import org.thingsboard.server.common.data.TbResource; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.dao.settings.AdminSettingsService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.mail.TbMailConfigTemplateService; +import org.thingsboard.server.service.security.permission.Operation; +import org.thingsboard.server.service.security.permission.Resource; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.core.io.ClassPathResource; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.RestController; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Base64; + +import static org.thingsboard.server.controller.ControllerConstants.SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH; + +@RestController +@TbCoreComponent +@RequiredArgsConstructor +@RequestMapping("/api/mail/config/template") +@Slf4j +public class MailConfigTemplateController extends BaseController { + private static final String MAIL_CONFIG_TEMPLATE_ID = "mailConfigTemplateId"; + private static final String MAIL_CONFIG_TEMPLATE_DEFINITION = "Mail configuration template is set of default smtp settings for mail server that specific provider supports"; + + private final TbMailConfigTemplateService mailConfigTemplateService; + + @ApiOperation(value = "Get the list of all OAuth2 client registration templates (getClientRegistrationTemplates)" + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH, + notes = MAIL_CONFIG_TEMPLATE_DEFINITION) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + @RequestMapping(method = RequestMethod.GET, produces = "application/json") + @ResponseBody + public JsonNode getClientRegistrationTemplates() throws ThingsboardException, IOException { + accessControlService.checkPermission(getCurrentUser(), Resource.ADMIN_SETTINGS, Operation.READ); + return mailConfigTemplateService.findAllMailConfigTemplates(); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/mail/DefaultMailService.java b/application/src/main/java/org/thingsboard/server/service/mail/DefaultMailService.java index f3fd18141a..1c6af41360 100644 --- a/application/src/main/java/org/thingsboard/server/service/mail/DefaultMailService.java +++ b/application/src/main/java/org/thingsboard/server/service/mail/DefaultMailService.java @@ -16,6 +16,7 @@ package org.thingsboard.server.service.mail; import com.fasterxml.jackson.databind.JsonNode; + import freemarker.template.Configuration; import freemarker.template.Template; import lombok.extern.slf4j.Slf4j; @@ -53,7 +54,6 @@ import java.io.ByteArrayInputStream; import java.util.HashMap; import java.util.Locale; import java.util.Map; -import java.util.Properties; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; @@ -61,7 +61,6 @@ import java.util.concurrent.TimeoutException; @Slf4j public class DefaultMailService implements MailService { - public static final String MAIL_PROP = "mail."; public static final String TARGET_EMAIL = "targetEmail"; public static final String UTF_8 = "UTF-8"; @@ -82,7 +81,10 @@ public class DefaultMailService implements MailService { @Autowired private PasswordResetExecutorService passwordResetExecutorService; - private JavaMailSenderImpl mailSender; + @Autowired + private TbMailContextComponent tbMailContextComponent; + + private TbMailSender mailSender; private String mailFrom; @@ -105,7 +107,7 @@ public class DefaultMailService implements MailService { AdminSettings settings = adminSettingsService.findAdminSettingsByKey(TenantId.SYS_TENANT_ID, "mail"); if (settings != null) { JsonNode jsonConfig = settings.getJsonValue(); - mailSender = createMailSender(jsonConfig); + mailSender = new TbMailSender(tbMailContextComponent, jsonConfig); mailFrom = jsonConfig.get("mailFrom").asText(); timeout = jsonConfig.get("timeout").asLong(DEFAULT_TIMEOUT); } else { @@ -113,65 +115,6 @@ public class DefaultMailService implements MailService { } } - private JavaMailSenderImpl createMailSender(JsonNode jsonConfig) { - JavaMailSenderImpl mailSender = new JavaMailSenderImpl(); - mailSender.setHost(jsonConfig.get("smtpHost").asText()); - mailSender.setPort(parsePort(jsonConfig.get("smtpPort").asText())); - mailSender.setUsername(jsonConfig.get("username").asText()); - mailSender.setPassword(jsonConfig.get("password").asText()); - mailSender.setJavaMailProperties(createJavaMailProperties(jsonConfig)); - return mailSender; - } - - private Properties createJavaMailProperties(JsonNode jsonConfig) { - Properties javaMailProperties = new Properties(); - String protocol = jsonConfig.get("smtpProtocol").asText(); - javaMailProperties.put("mail.transport.protocol", protocol); - javaMailProperties.put(MAIL_PROP + protocol + ".host", jsonConfig.get("smtpHost").asText()); - javaMailProperties.put(MAIL_PROP + protocol + ".port", jsonConfig.get("smtpPort").asText()); - javaMailProperties.put(MAIL_PROP + protocol + ".timeout", jsonConfig.get("timeout").asText()); - javaMailProperties.put(MAIL_PROP + protocol + ".auth", String.valueOf(StringUtils.isNotEmpty(jsonConfig.get("username").asText()))); - boolean enableTls = false; - if (jsonConfig.has("enableTls")) { - if (jsonConfig.get("enableTls").isBoolean() && jsonConfig.get("enableTls").booleanValue()) { - enableTls = true; - } else if (jsonConfig.get("enableTls").isTextual()) { - enableTls = "true".equalsIgnoreCase(jsonConfig.get("enableTls").asText()); - } - } - javaMailProperties.put(MAIL_PROP + protocol + ".starttls.enable", enableTls); - if (enableTls && jsonConfig.has("tlsVersion") && !jsonConfig.get("tlsVersion").isNull()) { - String tlsVersion = jsonConfig.get("tlsVersion").asText(); - if (StringUtils.isNoneEmpty(tlsVersion)) { - javaMailProperties.put(MAIL_PROP + protocol + ".ssl.protocols", tlsVersion); - } - } - - boolean enableProxy = jsonConfig.has("enableProxy") && jsonConfig.get("enableProxy").asBoolean(); - - if (enableProxy) { - javaMailProperties.put(MAIL_PROP + protocol + ".proxy.host", jsonConfig.get("proxyHost").asText()); - javaMailProperties.put(MAIL_PROP + protocol + ".proxy.port", jsonConfig.get("proxyPort").asText()); - String proxyUser = jsonConfig.get("proxyUser").asText(); - if (StringUtils.isNoneEmpty(proxyUser)) { - javaMailProperties.put(MAIL_PROP + protocol + ".proxy.user", proxyUser); - } - String proxyPassword = jsonConfig.get("proxyPassword").asText(); - if (StringUtils.isNoneEmpty(proxyPassword)) { - javaMailProperties.put(MAIL_PROP + protocol + ".proxy.password", proxyPassword); - } - } - return javaMailProperties; - } - - private int parsePort(String strPort) { - try { - return Integer.valueOf(strPort); - } catch (NumberFormatException e) { - throw new IncorrectParameterException(String.format("Invalid smtp port value: %s", strPort)); - } - } - @Override public void sendEmail(TenantId tenantId, String email, String subject, String message) throws ThingsboardException { sendMail(mailSender, mailFrom, email, subject, message, timeout); @@ -179,7 +122,7 @@ public class DefaultMailService implements MailService { @Override public void sendTestMail(JsonNode jsonConfig, String email) throws ThingsboardException { - JavaMailSenderImpl testMailSender = createMailSender(jsonConfig); + TbMailSender testMailSender = new TbMailSender(tbMailContextComponent, jsonConfig); String mailFrom = jsonConfig.get("mailFrom").asText(); String subject = messages.getMessage("test.message.subject", null, Locale.US); long timeout = jsonConfig.get("timeout").asLong(DEFAULT_TIMEOUT); diff --git a/application/src/main/java/org/thingsboard/server/service/mail/DefaultTbMailConfigTemplateService.java b/application/src/main/java/org/thingsboard/server/service/mail/DefaultTbMailConfigTemplateService.java new file mode 100644 index 0000000000..3d4cb261c7 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/mail/DefaultTbMailConfigTemplateService.java @@ -0,0 +1,19 @@ +package org.thingsboard.server.service.mail; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Service; +import org.thingsboard.common.util.JacksonUtil; + +import java.io.IOException; + +@Service +@Slf4j +public class DefaultTbMailConfigTemplateService implements TbMailConfigTemplateService { + + @Override + public JsonNode findAllMailConfigTemplates() throws IOException { + return JacksonUtil.toJsonNode(new ClassPathResource("/templates/mail_config_templates.json").getFile()); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/mail/RefreshTokenExpCheckService.java b/application/src/main/java/org/thingsboard/server/service/mail/RefreshTokenExpCheckService.java new file mode 100644 index 0000000000..56f3c4c4c5 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/mail/RefreshTokenExpCheckService.java @@ -0,0 +1,75 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.mail; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.api.client.auth.oauth2.ClientParametersAuthentication; +import com.google.api.client.auth.oauth2.RefreshTokenRequest; +import com.google.api.client.auth.oauth2.TokenResponse; +import com.google.api.client.http.GenericUrl; +import com.google.api.client.http.javanet.NetHttpTransport; +import com.google.api.client.json.gson.GsonFactory; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.AdminSettings; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.dao.settings.AdminSettingsService; +import org.thingsboard.server.queue.util.TbCoreComponent; + +import java.io.IOException; +import java.time.Duration; +import java.time.Instant; + +import static org.thingsboard.server.common.data.mail.MailOauth2Provider.OFFICE_365; + +@TbCoreComponent +@Service +@Slf4j +@RequiredArgsConstructor +public class RefreshTokenExpCheckService { + public static final int AZURE_DEFAULT_REFRESH_TOKEN_LIFETIME_IN_DAYS = 90; + private final AdminSettingsService adminSettingsService; + + @Scheduled(initialDelayString = "#{T(org.apache.commons.lang3.RandomUtils).nextLong(0, ${mail.oauth2.refreshTokenCheckingInterval})}", fixedDelayString = "${mail.oauth2.refreshTokenCheckingInterval}") + public void check() throws IOException { + AdminSettings settings = adminSettingsService.findAdminSettingsByKey(TenantId.SYS_TENANT_ID, "mail"); + if (settings != null && settings.getJsonValue().has("enableOauth2") && settings.getJsonValue().get("enableOauth2").asBoolean()) { + JsonNode jsonValue = settings.getJsonValue(); + if (OFFICE_365.name().equals(jsonValue.get("providerId").asText()) && jsonValue.has("refreshTokenExpires")) { + long expiresIn = jsonValue.get("refreshTokenExpires").longValue(); + if ((expiresIn - System.currentTimeMillis()) < 604800000L) { //less than 7 days + log.info("Trying to refresh refresh token."); + + String clientId = jsonValue.get("clientId").asText(); + String clientSecret = jsonValue.get("clientSecret").asText(); + String refreshToken = jsonValue.get("refreshToken").asText(); + String tokenUri = jsonValue.get("tokenUri").asText(); + + TokenResponse tokenResponse = new RefreshTokenRequest(new NetHttpTransport(), new GsonFactory(), + new GenericUrl(tokenUri), refreshToken) + .setClientAuthentication(new ClientParametersAuthentication(clientId, clientSecret)) + .execute(); + ((ObjectNode)jsonValue).put("refreshToken", tokenResponse.getRefreshToken()); + ((ObjectNode)jsonValue).put("refreshTokenExpires", Instant.now().plus(Duration.ofDays(AZURE_DEFAULT_REFRESH_TOKEN_LIFETIME_IN_DAYS)).toEpochMilli()); + adminSettingsService.saveAdminSettings(TenantId.SYS_TENANT_ID, settings); + } + } + } + } +} \ No newline at end of file diff --git a/application/src/main/java/org/thingsboard/server/service/mail/TbMailConfigTemplateService.java b/application/src/main/java/org/thingsboard/server/service/mail/TbMailConfigTemplateService.java new file mode 100644 index 0000000000..2475fe7513 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/mail/TbMailConfigTemplateService.java @@ -0,0 +1,9 @@ +package org.thingsboard.server.service.mail; + +import com.fasterxml.jackson.databind.JsonNode; + +import java.io.IOException; + +public interface TbMailConfigTemplateService { + JsonNode findAllMailConfigTemplates() throws IOException; +} diff --git a/application/src/main/java/org/thingsboard/server/service/mail/TbMailContextComponent.java b/application/src/main/java/org/thingsboard/server/service/mail/TbMailContextComponent.java new file mode 100644 index 0000000000..dce6c10b7e --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/mail/TbMailContextComponent.java @@ -0,0 +1,32 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.mail; + +import lombok.Data; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Component; +import org.thingsboard.server.dao.settings.AdminSettingsService; +import org.thingsboard.server.queue.util.TbCoreComponent; + +@Component +@Data +@Lazy +public class TbMailContextComponent { + + @Autowired + private AdminSettingsService adminSettingsService; +} \ No newline at end of file diff --git a/application/src/main/java/org/thingsboard/server/service/mail/TbMailSender.java b/application/src/main/java/org/thingsboard/server/service/mail/TbMailSender.java new file mode 100644 index 0000000000..0a9b173247 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/mail/TbMailSender.java @@ -0,0 +1,168 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.mail; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.api.client.auth.oauth2.ClientParametersAuthentication; +import com.google.api.client.auth.oauth2.RefreshTokenRequest; +import com.google.api.client.auth.oauth2.TokenResponse; +import com.google.api.client.http.GenericUrl; +import com.google.api.client.http.javanet.NetHttpTransport; +import com.google.api.client.json.gson.GsonFactory; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.springframework.lang.Nullable; +import org.springframework.mail.javamail.JavaMailSenderImpl; +import org.thingsboard.server.common.data.AdminSettings; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.mail.MailOauth2Provider; +import org.thingsboard.server.dao.exception.IncorrectParameterException; + +import javax.mail.internet.MimeMessage; +import java.time.Duration; +import java.time.Instant; +import java.util.Properties; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +import static org.thingsboard.server.service.mail.RefreshTokenExpCheckService.AZURE_DEFAULT_REFRESH_TOKEN_LIFETIME_IN_DAYS; + +@Slf4j +public class TbMailSender extends JavaMailSenderImpl { + + private static final String MAIL_PROP = "mail."; + private final TbMailContextComponent ctx; + private final Lock lock; + private final Boolean oauth2Enabled; + private volatile String accessToken; + private volatile long tokenExpires; + + public TbMailSender(TbMailContextComponent ctx, JsonNode jsonConfig) { + super(); + this.lock = new ReentrantLock(); + this.tokenExpires = 0L; + this.ctx = ctx; + this.oauth2Enabled = jsonConfig.has("enableOauth2") && jsonConfig.get("enableOauth2").asBoolean(); + + setHost(jsonConfig.get("smtpHost").asText()); + setPort(parsePort(jsonConfig.get("smtpPort").asText())); + setUsername(jsonConfig.get("username").asText()); + if (jsonConfig.has("password")) { + setPassword(jsonConfig.get("password").asText()); + } + setJavaMailProperties(createJavaMailProperties(jsonConfig)); + } + + @SneakyThrows + @Override + public void doSend(MimeMessage[] mimeMessages, @Nullable Object[] originalMessages) { + if (oauth2Enabled && (System.currentTimeMillis() > tokenExpires)){ + refreshAccessToken(); + setPassword(accessToken); + } + super.doSend(mimeMessages, originalMessages); + } + + private Properties createJavaMailProperties(JsonNode jsonConfig) { + Properties javaMailProperties = new Properties(); + String protocol = jsonConfig.get("smtpProtocol").asText(); + javaMailProperties.put("mail.transport.protocol", protocol); + javaMailProperties.put(MAIL_PROP + protocol + ".host", jsonConfig.get("smtpHost").asText()); + javaMailProperties.put(MAIL_PROP + protocol + ".port", jsonConfig.get("smtpPort").asText()); + javaMailProperties.put(MAIL_PROP + protocol + ".timeout", jsonConfig.get("timeout").asText()); + javaMailProperties.put(MAIL_PROP + protocol + ".auth", String.valueOf(StringUtils.isNotEmpty(jsonConfig.get("username").asText()))); + boolean enableTls = false; + if (jsonConfig.has("enableTls")) { + if (jsonConfig.get("enableTls").isBoolean() && jsonConfig.get("enableTls").booleanValue()) { + enableTls = true; + } else if (jsonConfig.get("enableTls").isTextual()) { + enableTls = "true".equalsIgnoreCase(jsonConfig.get("enableTls").asText()); + } + } + javaMailProperties.put(MAIL_PROP + protocol + ".starttls.enable", enableTls); + if (enableTls && jsonConfig.has("tlsVersion") && !jsonConfig.get("tlsVersion").isNull()) { + String tlsVersion = jsonConfig.get("tlsVersion").asText(); + if (StringUtils.isNoneEmpty(tlsVersion)) { + javaMailProperties.put(MAIL_PROP + protocol + ".ssl.protocols", tlsVersion); + } + } + + boolean enableProxy = jsonConfig.has("enableProxy") && jsonConfig.get("enableProxy").asBoolean(); + + if (enableProxy) { + javaMailProperties.put(MAIL_PROP + protocol + ".proxy.host", jsonConfig.get("proxyHost").asText()); + javaMailProperties.put(MAIL_PROP + protocol + ".proxy.port", jsonConfig.get("proxyPort").asText()); + String proxyUser = jsonConfig.get("proxyUser").asText(); + if (StringUtils.isNoneEmpty(proxyUser)) { + javaMailProperties.put(MAIL_PROP + protocol + ".proxy.user", proxyUser); + } + String proxyPassword = jsonConfig.get("proxyPassword").asText(); + if (StringUtils.isNoneEmpty(proxyPassword)) { + javaMailProperties.put(MAIL_PROP + protocol + ".proxy.password", proxyPassword); + } + } + + if (oauth2Enabled) { + javaMailProperties.put(MAIL_PROP + protocol + ".auth.mechanisms", "XOAUTH2"); + } + return javaMailProperties; + } + + public void refreshAccessToken() throws ThingsboardException { + lock.lock(); + try { + if (System.currentTimeMillis() > tokenExpires) { + AdminSettings settings = ctx.getAdminSettingsService().findAdminSettingsByKey(TenantId.SYS_TENANT_ID, "mail"); + JsonNode jsonValue = settings.getJsonValue(); + + String clientId = jsonValue.get("clientId").asText(); + String clientSecret = jsonValue.get("clientSecret").asText(); + String refreshToken = jsonValue.get("refreshToken").asText(); + String tokenUri = jsonValue.get("tokenUri").asText(); + String providerId = jsonValue.get("providerId").asText(); + + TokenResponse tokenResponse = new RefreshTokenRequest(new NetHttpTransport(), new GsonFactory(), + new GenericUrl(tokenUri), refreshToken) + .setClientAuthentication(new ClientParametersAuthentication(clientId, clientSecret)) + .execute(); + if (MailOauth2Provider.OFFICE_365.name().equals(providerId)) { + ((ObjectNode)jsonValue).put("refreshToken", tokenResponse.getRefreshToken()); + ((ObjectNode)jsonValue).put("refreshTokenExpires", Instant.now().plus(Duration.ofDays(AZURE_DEFAULT_REFRESH_TOKEN_LIFETIME_IN_DAYS)).toEpochMilli()); + ctx.getAdminSettingsService().saveAdminSettings(TenantId.SYS_TENANT_ID, settings); + } + accessToken = tokenResponse.getAccessToken(); + tokenExpires = System.currentTimeMillis() + (tokenResponse.getExpiresInSeconds().intValue() * 1000); + } + } catch (Exception e) { + log.warn("Unable to retrieve access token: {}", e.getMessage()); + throw new ThingsboardException("Error while retrieving access token: " + e.getMessage(), ThingsboardErrorCode.GENERAL); + } finally { + lock.unlock(); + } + } + + private int parsePort(String strPort) { + try { + return Integer.parseInt(strPort); + } catch (NumberFormatException e) { + throw new IncorrectParameterException(String.format("Invalid smtp port value: %s", strPort)); + } + } +} \ No newline at end of file diff --git a/application/src/main/resources/templates/mail_config_templates.json b/application/src/main/resources/templates/mail_config_templates.json new file mode 100644 index 0000000000..f287880a5e --- /dev/null +++ b/application/src/main/resources/templates/mail_config_templates.json @@ -0,0 +1,52 @@ +[ + { + "providerId": "SENDGRID", + "smtpProtocol": "SMTPS", + "smtpHost": "smtp.sendgrid.net", + "smtpPort": 465, + "timeout": 10000, + "enableTls": true, + "tlsVersion": "TLSv1.2", + "authorizationUri": null, + "accessTokenUri": null, + "scope": [ + "" + ], + "helpLink": null, + "name": "SendGrid" + }, + { + "providerId": "GOOGLE", + "smtpProtocol": "SMTPS", + "smtpHost": "smtp.gmail.com", + "smtpPort": 465, + "timeout": 10000, + "enableTls": true, + "tlsVersion": "TLSv1.2", + "authorizationUri": "https://accounts.google.com/o/oauth2/v2/auth?prompt=consent&access_type=offline", + "accessTokenUri": "https://oauth2.googleapis.com/token", + "scope": [ + "https://mail.google.com/" + ], + "helpLink": "https://support.google.com/googleapi/answer/6158849", + "name": "Google" + }, + { + "providerId": "OFFICE_365", + "smtpProtocol": "SMTP", + "smtpHost": "smtp.office365.com", + "smtpPort": 587, + "timeout": 10000, + "enableTls": true, + "tlsVersion": "TLSv1.2", + "authorizationUri": "https://login.microsoftonline.com/%s/oauth2/v2.0/authorize", + "accessTokenUri": "https://login.microsoftonline.com/%s/oauth2/v2.0/token", + "scope": [ + "https://outlook.office365.com/SMTP.Send", + "offline_access", + "openid" + ], + "helpLink": "https://learn.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth", + "name": "Office 365" + } +] \ No newline at end of file diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 3916d3e0be..d3d79458f3 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -135,6 +135,10 @@ security: path: "${SECURITY_JAVA_CACERTS_PATH:${java.home}/lib/security/cacerts}" password: "${SECURITY_JAVA_CACERTS_PASSWORD:changeit}" +mail: + oauth2: + refreshTokenCheckingInterval: "${REFRESH_TOKEN_EXPIRATION_CHECKING_INTERVAL:86400}" # Number of seconds (1 day). + # Usage statistics parameters usage: stats: diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/StringUtils.java b/common/data/src/main/java/org/thingsboard/server/common/data/StringUtils.java index 3f4aa11c1f..a7671f4327 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/StringUtils.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/StringUtils.java @@ -24,6 +24,9 @@ import java.util.Base64; import static org.apache.commons.lang3.StringUtils.repeat; public class StringUtils { + + private static final int DEFAULT_TOKEN_LENGTH = 8; + public static final SecureRandom RANDOM = new SecureRandom(); public static final String EMPTY = ""; @@ -205,4 +208,8 @@ public class StringUtils { return encoder.encodeToString(bytes); } + public static String generateSafeToken() { + return generateSafeToken(DEFAULT_TOKEN_LENGTH); + } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/mail/MailOauth2Provider.java b/common/data/src/main/java/org/thingsboard/server/common/data/mail/MailOauth2Provider.java new file mode 100644 index 0000000000..c0cdb92500 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/mail/MailOauth2Provider.java @@ -0,0 +1,31 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.mail; + +public enum MailOauth2Provider { + GOOGLE("Google"), OFFICE_365("Office 365"), SENDGRID("SendGrid"), CUSTOM("Custom"); + + public final String label; + + MailOauth2Provider(String label) { + this.label = label; + } + + @Override + public String toString() { + return label; + } +} \ No newline at end of file diff --git a/dao/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsServiceImpl.java index b6d6bbc0ad..6da6d96637 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsServiceImpl.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.dao.settings; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; @@ -58,10 +59,18 @@ public class AdminSettingsServiceImpl implements AdminSettingsService { public AdminSettings saveAdminSettings(TenantId tenantId, AdminSettings adminSettings) { log.trace("Executing saveAdminSettings [{}]", adminSettings); adminSettingsValidator.validate(adminSettings, data -> tenantId); - if (adminSettings.getKey().equals("mail") && !adminSettings.getJsonValue().has("password")) { + if (adminSettings.getKey().equals("mail")){ AdminSettings mailSettings = findAdminSettingsByKey(tenantId, "mail"); if (mailSettings != null) { - ((ObjectNode) adminSettings.getJsonValue()).put("password", mailSettings.getJsonValue().get("password").asText()); + JsonNode newJsonValue = adminSettings.getJsonValue(); + JsonNode oldJsonValue = mailSettings.getJsonValue(); + if (!newJsonValue.has("password") && oldJsonValue.has("password")){ + ((ObjectNode) newJsonValue).put("password", oldJsonValue.get("password").asText()); + } + if (!newJsonValue.has("refreshToken") && oldJsonValue.has("refreshToken")){ + ((ObjectNode) newJsonValue).put("refreshToken", oldJsonValue.get("refreshToken").asText()); + } + dropTokenIfProviderInfoChanged(newJsonValue, oldJsonValue); } } if (adminSettings.getTenantId() == null) { @@ -82,4 +91,18 @@ public class AdminSettingsServiceImpl implements AdminSettingsService { adminSettingsDao.removeByTenantId(tenantId.getId()); } + private void dropTokenIfProviderInfoChanged(JsonNode newJsonValue, JsonNode oldJsonValue) { + if (newJsonValue.has("enableOauth2") && newJsonValue.get("enableOauth2").asBoolean()){ + if (!newJsonValue.get("providerId").equals(oldJsonValue.get("providerId")) || + !newJsonValue.get("clientId").equals(oldJsonValue.get("clientId")) || + !newJsonValue.get("clientSecret").equals(oldJsonValue.get("clientSecret")) || + !newJsonValue.get("redirectUri").equals(oldJsonValue.get("redirectUri")) || + (newJsonValue.has("providerTenantId") && !newJsonValue.get("providerTenantId").equals(oldJsonValue.get("providerTenantId")))){ + ((ObjectNode) newJsonValue).put("tokenGenerated", false); + ((ObjectNode) newJsonValue).remove("refreshToken"); + ((ObjectNode) newJsonValue).remove("refreshTokenExpires"); + } + } + } + } diff --git a/pom.xml b/pom.xml index f4c4970d7f..59d1eff5d3 100755 --- a/pom.xml +++ b/pom.xml @@ -151,6 +151,7 @@ 2.12.0 1.12.1 6.4.2 + 1.34.1 @@ -2017,6 +2018,11 @@ oshi-core ${oshi.version} + + com.google.oauth-client + google-oauth-client + ${google-oauth-client.version} + diff --git a/ui-ngx/src/app/core/http/admin.service.ts b/ui-ngx/src/app/core/http/admin.service.ts index cd83c3c8c9..d7126df824 100644 --- a/ui-ngx/src/app/core/http/admin.service.ts +++ b/ui-ngx/src/app/core/http/admin.service.ts @@ -21,6 +21,7 @@ import { HttpClient } from '@angular/common/http'; import { AdminSettings, AutoCommitSettings, + MailConfigTemplate, FeaturesInfo, JwtSettings, MailServerSettings, @@ -136,4 +137,16 @@ export class AdminService { public getFeaturesInfo(config?: RequestConfig): Observable { return this.http.get('/api/admin/featuresInfo', defaultHttpOptionsFromConfig(config)); } + + public getLoginProcessingUrl(config?: RequestConfig): Observable { + return this.http.get(`/api/admin/mail/oauth2/loginProcessingUrl`, defaultHttpOptionsFromConfig(config)); + } + + public generateAccessToken(config?: RequestConfig): Observable { + return this.http.get(`/api/admin/mail/oauth2/authorize`, defaultHttpOptionsFromConfig(config)); + } + + public getMailConfigTemplate(config?: RequestConfig): Observable> { + return this.http.get>('/api/mail/config/template', defaultHttpOptionsFromConfig(config)); + } } diff --git a/ui-ngx/src/app/modules/home/pages/admin/mail-server.component.html b/ui-ngx/src/app/modules/home/pages/admin/mail-server.component.html index 4524880098..2762e0d43a 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/mail-server.component.html +++ b/ui-ngx/src/app/modules/home/pages/admin/mail-server.component.html @@ -37,111 +37,306 @@ {{ 'admin.mail-from-required' | translate }} + - admin.smtp-protocol - - - {{protocol.toUpperCase()}} - - - -
- - admin.smtp-host - - - {{ 'admin.smtp-host-required' | translate }} - - - - admin.smtp-port - - {{smtpPortInput.value?.length || 0}}/5 - - {{ 'admin.smtp-port-required' | translate }} - - - {{ 'admin.smtp-port-invalid' | translate }} - - -
- - admin.timeout-msec - - {{timeoutInput.value?.length || 0}}/6 - - {{ 'admin.timeout-required' | translate }} - - - {{ 'admin.timeout-invalid' | translate }} - - - - {{ 'admin.enable-tls' | translate }} - - - admin.tls-version - - - {{ tlsVersion }} + admin.oauth2.smtp-provider + + + {{ templates.get(provider)?.name || 'Custom' }} - - {{ 'admin.enable-proxy' | translate }} - -
-
- - admin.proxy-host - - - {{ 'admin.proxy-host-required' | translate }} - + + + + + admin.connection-settings + + + + + admin.smtp-protocol + + + {{protocol.toUpperCase()}} + + - - admin.proxy-port - - - {{ 'admin.proxy-port-required' | translate }} +
+ + admin.smtp-host + + + {{ 'admin.smtp-host-required' | translate }} + + + + admin.smtp-port + + {{smtpPortInput.value?.length || 0}}/5 + + {{ 'admin.smtp-port-required' | translate }} + + + {{ 'admin.smtp-port-invalid' | translate }} + + +
+ + admin.timeout-msec + + {{timeoutInput.value?.length || 0}}/6 + + {{ 'admin.timeout-required' | translate }} - - {{ 'admin.proxy-port-range' | translate }} + + {{ 'admin.timeout-invalid' | translate }} -
- - admin.proxy-user - - + + {{ 'admin.enable-tls' | translate }} + + + admin.tls-version + + + {{ tlsVersion }} + + + + + {{ 'admin.enable-proxy' | translate }} + +
+
+ + admin.proxy-host + + + {{ 'admin.proxy-host-required' | translate }} + + + + admin.proxy-port + + + {{ 'admin.proxy-port-required' | translate }} + + + {{ 'admin.proxy-port-range' | translate }} + + +
+
+ + admin.proxy-user + + + + admin.proxy-password + + + +
+
+ + + +
+ admin.oauth2.authentication - admin.proxy-password - - + common.username + -
- - common.username - - - - {{ 'admin.change-password' | translate }} - - - common.password - - - +
+ + {{ 'admin.oauth2.basic' | translate }} + {{ 'admin.oauth2.oauth2' | translate }} + +
+
+
+
+
+ + {{ 'admin.change-password' | translate }} + + + common.password + + + +
+
+
+ + admin.oauth2.client-id + + + {{ 'admin.oauth2.client-id-required' | translate }} + + + {{ 'admin.oauth2.client-id-max-length' | translate }} + + + + + admin.oauth2.client-secret + + + {{ 'admin.oauth2.client-secret-required' | translate }} + + + {{ 'admin.oauth2.client-secret-max-length' | translate }} + + +
+ + + admin.oauth2.microsoft-tenant-id + + + {{ 'admin.oauth2.microsoft-tenant-id-required' | translate }} + + + + + + tenant-profile.advanced-settings + + + +
+ + admin.oauth2.authorization-uri + + + + {{ 'admin.oauth2.access-token-uri-required' | translate }} + + + {{ 'admin.oauth2.uri-pattern-error' | translate }} + + + + + admin.oauth2.token-uri + + + + {{ 'admin.oauth2.access-token-uri-required' | translate }} + + + {{ 'admin.oauth2.uri-pattern-error' | translate }} + + +
+ + admin.oauth2.scope + + + {{scope}} + cancel + + + + + {{ 'admin.oauth2.scope-required' | translate }} + + +
+
+ +
+ admin.oauth2.redirect-uri +
+
+
+ + admin.oauth2.protocol + + + {{ domainSchemaTranslations.get(protocol) | translate | uppercase }} + + + + + admin.domain-name + + + {{ 'admin.error-verification-url' | translate }} + + + {{ 'admin.domain-name-max-length' | translate }} + + +
+ + {{ 'admin.domain-name-unique' | translate }} + +
+ +
+ + admin.oauth2.redirect-uri-template + + + + +
+
+
+
+
+
+ admin.oauth2.access-token-status + + {{ accessTokenStatus }} + +
+ +
+
-
diff --git a/ui-ngx/src/app/modules/home/pages/admin/mail-server.component.scss b/ui-ngx/src/app/modules/home/pages/admin/mail-server.component.scss index 66df772d2d..cd6c18af9e 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/mail-server.component.scss +++ b/ui-ngx/src/app/modules/home/pages/admin/mail-server.component.scss @@ -13,6 +13,95 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +@import "../../../../../theme"; + :host { + .fields-group { + padding: 0 8px 8px; + margin: 10px 0; + border: 1px groove rgba(0, 0, 0, .25); + border-radius: 4px; + legend { + margin-bottom: 8px; + color: rgba(0, 0, 0, .7); + width: fit-content; + } + } + + .token-status { + font: 400 14px / 16px Roboto, "Helvetica Neue", sans-serif; + color: rgba(0,0,0, 0.6); + letter-spacing: 0.25px; + padding: 8px 0; + } + + ::ng-deep{ + .mat-expansion-panel { + .mat-expansion-panel-header { + height: 48px; + padding: 0 12px; + &.mat-expanded { + height: 48px; + } + } + .mat-expansion-panel-body { + padding: 0 12px; + } + &.configuration-panel { + border: 1px solid rgba(0, 0, 0, 0.2); + } + } + .mat-button-toggle-group.tb-notification-unread-toggle-group { + &.mat-button-toggle-group-appearance-standard { + border: none; + border-radius: 14px; + + .mat-button-toggle + .mat-button-toggle { + border-left: none; + } + } + + .mat-button-toggle { + background: rgba(0, 0, 0, 0.06); + height: 28px; + align-items: center; + display: flex; + + .mat-button-toggle-ripple { + top: 2px; + left: 2px; + right: 2px; + bottom: 2px; + border-radius: 12px; + } + } + + .mat-button-toggle-button { + color: #959595; + } + + .mat-button-toggle-focus-overlay { + border-radius: 14px; + margin: 2px; + } + + .mat-button-toggle-checked .mat-button-toggle-button { + background-color: $tb-primary-color; + color: #fff; + border-radius: 14px; + margin-left: 2px; + margin-right: 2px; + } + + .mat-button-toggle-appearance-standard .mat-button-toggle-label-content { + line-height: 24px; + font-size: 16px; + font-weight: 500; + } + .mat-button-toggle-checked.mat-button-toggle-appearance-standard:not(.mat-button-toggle-disabled):hover .mat-button-toggle-focus-overlay { + opacity: .01; + } + } + } } diff --git a/ui-ngx/src/app/modules/home/pages/admin/mail-server.component.ts b/ui-ngx/src/app/modules/home/pages/admin/mail-server.component.ts index 7833c745f4..6815c81e31 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/mail-server.component.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/mail-server.component.ts @@ -14,20 +14,32 @@ /// limitations under the License. /// -import { Component, OnDestroy, OnInit } from '@angular/core'; +import { Component, Inject, OnDestroy, OnInit } from '@angular/core'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { PageComponent } from '@shared/components/page.component'; -import { Router } from '@angular/router'; -import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; -import { AdminSettings, MailServerSettings, smtpPortPattern } from '@shared/models/settings.models'; +import { FormBuilder, FormGroup, UntypedFormArray, Validators } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { + AdminSettings, + MailConfigTemplate, + MailServerOauth2Provider, + MailServerSettings, + smtpPortPattern, + SmtpProtocol +} from '@shared/models/settings.models'; import { AdminService } from '@core/http/admin.service'; import { ActionNotificationShow } from '@core/notification/notification.actions'; import { TranslateService } from '@ngx-translate/core'; import { HasConfirmForm } from '@core/guards/confirm-on-exit.guard'; -import { isDefinedAndNotNull, isString } from '@core/utils'; -import { Subject } from 'rxjs'; +import { isDefined, isDefinedAndNotNull, isString } from '@core/utils'; +import { forkJoin, Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; +import { DomainSchema, domainSchemaTranslations, } from '@shared/models/oauth2.models'; +import { WINDOW } from '@core/services/window.service'; +import { AuthService } from '@core/auth/auth.service'; +import { COMMA, ENTER } from '@angular/cdk/keycodes'; +import { MatChipInputEvent } from '@angular/material/chips'; @Component({ selector: 'tb-mail-server', @@ -35,40 +47,138 @@ import { takeUntil } from 'rxjs/operators'; styleUrls: ['./mail-server.component.scss', './settings-card.scss'] }) export class MailServerComponent extends PageComponent implements OnInit, OnDestroy, HasConfirmForm { - - mailSettings: UntypedFormGroup; adminSettings: AdminSettings; - smtpProtocols = ['smtp', 'smtps']; + smtpProtocols = Object.values(SmtpProtocol); showChangePassword = false; + protocols = Object.values(DomainSchema).filter(value => value !== DomainSchema.MIXED); + domainSchemaTranslations = domainSchemaTranslations; + + mailServerOauth2Provider = MailServerOauth2Provider; + tlsVersions = ['TLSv1', 'TLSv1.1', 'TLSv1.2', 'TLSv1.3']; + helpLink: string; + + templates = new Map(); + + templateProvider = ['CUSTOM']; + + readonly separatorKeysCodes: number[] = [ENTER, COMMA]; + private destroy$ = new Subject(); + private DOMAIN_AND_PORT_REGEXP = /^(?:\w+(?::\w+)?@)?[^\s/]+(?::\d+)?$/; + private URL_REGEXP = /^[A-Za-z][A-Za-z\d.+-]*:\/*(?:\w+(?::\w+)?@)?[^\s/]+(?::\d+)?(?:\/[\w#!:.,?+=&%@\-/]*)?$/; + private loginProcessingUrl: string; + + mailSettings = this.fb.group({ + mailFrom: ['', [Validators.required]], + smtpProtocol: [SmtpProtocol.SMTP], + smtpHost: ['localhost', [Validators.required]], + smtpPort: [25, [Validators.required, + Validators.pattern(smtpPortPattern), + Validators.maxLength(5)]], + timeout: [10000, [Validators.required, + Validators.pattern(/^[0-9]{1,6}$/), + Validators.maxLength(6)]], + enableTls: [false], + tlsVersion: [{ value: null, disabled: true }], + enableProxy: [false], + proxyHost: [{ value: '', disabled: true }, [Validators.required]], + proxyPort: [{ value: null, disabled: true }, [Validators.required, Validators.min(1), Validators.max(65535)]], + proxyUser: [{ value: '', disabled: true }], + proxyPassword: [{ value: '', disabled: true }], + username: [''], + changePassword: [false], + password: [''], + enableOauth2: [false], + providerId: ['CUSTOM', [Validators.required]], + clientId: [{ value:'', disabled: true }, [Validators.required, Validators.maxLength(255)]], + clientSecret: [{ value:'', disabled: true }, [Validators.required, Validators.maxLength(2048)]], + providerTenantId: [{value: '', disabled: true}, [Validators.required]], + authUri: [{value: '', disabled: true}, [Validators.required, Validators.pattern(this.URL_REGEXP)]], + tokenUri: [{value: '', disabled: true}, [Validators.required, Validators.pattern(this.URL_REGEXP)]], + scope: [], + redirectUri: [{ value:'', disabled: true}] + }); + + private defaultConfiguration = { + providerId: 'CUSTOM', + smtpProtocol: SmtpProtocol.SMTP, + smtpHost: '', + smtpPort: null, + timeout: null, + enableTls: false, + tlsVersion: null, + enableProxy: false, + proxyHost: '', + proxyPort: null, + proxyUser: '', + proxyPassword: '', + enableOauth2: false, + clientId: '', + clientSecret: '', + providerTenantId: '', + authUri: '', + tokenUri: '', + scope: [], + redirectUri: '' + }; + + domainForm = this.fb.group({ + name: [this.window.location.hostname, [ + Validators.required, Validators.maxLength(255), + Validators.pattern(this.DOMAIN_AND_PORT_REGEXP)] + ], + scheme: [DomainSchema.HTTPS, Validators.required] + }); constructor(protected store: Store, private router: Router, + private route: ActivatedRoute, private adminService: AdminService, + private authService: AuthService, private translate: TranslateService, - public fb: UntypedFormBuilder) { + public fb: FormBuilder, + @Inject(WINDOW) private window: Window) { super(store); } ngOnInit() { - this.buildMailServerSettingsForm(); - this.adminService.getAdminSettings('mail').subscribe( - (adminSettings) => { - this.adminSettings = adminSettings; - if (this.adminSettings.jsonValue && isString(this.adminSettings.jsonValue.enableTls)) { - this.adminSettings.jsonValue.enableTls = (this.adminSettings.jsonValue.enableTls as any) === 'true'; - } - this.showChangePassword = - isDefinedAndNotNull(this.adminSettings.jsonValue.showChangePassword) ? this.adminSettings.jsonValue.showChangePassword : true ; - delete this.adminSettings.jsonValue.showChangePassword; - this.mailSettings.reset(this.adminSettings.jsonValue); - this.enableMailPassword(!this.showChangePassword); - this.enableProxyChanged(); + this.mailServerSettingsForm(); + this.domainFormConfiguration(); + + forkJoin([ + this.adminService.getLoginProcessingUrl(), + this.adminService.getMailConfigTemplate(), + this.adminService.getAdminSettings('mail') + ]).subscribe(([loginProcessingUrl, mailConfigTemplate, adminSettings]) => { + this.loginProcessingUrl = loginProcessingUrl; + this.initTemplates(mailConfigTemplate); + this.adminSettings = adminSettings; + if (this.adminSettings.jsonValue && isString(this.adminSettings.jsonValue.enableTls)) { + this.adminSettings.jsonValue.enableTls = (this.adminSettings.jsonValue.enableTls as any) === 'true'; } - ); + this.showChangePassword = isDefinedAndNotNull(this.adminSettings.jsonValue.showChangePassword) + ? this.adminSettings.jsonValue.showChangePassword : true; + delete this.adminSettings.jsonValue.showChangePassword; + if (!this.adminSettings.jsonValue.providerId) { + this.adminSettings.jsonValue.providerId = 'CUSTOM'; + } + this.mailSettings.reset(this.adminSettings.jsonValue, {emitEvent: false}); + this.enableMailPassword(!this.showChangePassword); + this.enableProxyChanged(); + this.enableTls(this.adminSettings.jsonValue.enableTls); + this.helpLink = this.templates.get(this.adminSettings.jsonValue.providerId)?.helpLink || null; + if (this.adminSettings.jsonValue.enableOauth2) { + this.enableOauth2(!!this.adminSettings.jsonValue.enableOauth2); + this.enableProviderTenantIdChanged(this.adminSettings.jsonValue.providerId); + this.parseUrl(this.adminSettings.jsonValue.redirectUri); + this.mailSettings.get('redirectUri').patchValue(this.adminSettings.jsonValue.redirectUri, {emitEvent: false}); + } else { + this.mailSettings.get('enableOauth2').patchValue(false, {emitEvent: false}); + } + }); } ngOnDestroy() { @@ -77,56 +187,153 @@ export class MailServerComponent extends PageComponent implements OnInit, OnDest super.ngOnDestroy(); } - buildMailServerSettingsForm() { - this.mailSettings = this.fb.group({ - mailFrom: ['', [Validators.required]], - smtpProtocol: ['smtp'], - smtpHost: ['localhost', [Validators.required]], - smtpPort: ['25', [Validators.required, - Validators.pattern(smtpPortPattern), - Validators.maxLength(5)]], - timeout: ['10000', [Validators.required, - Validators.pattern(/^[0-9]{1,6}$/), - Validators.maxLength(6)]], - enableTls: [false], - tlsVersion: [], - enableProxy: [false, []], - proxyHost: ['', [Validators.required]], - proxyPort: ['', [Validators.required, Validators.min(1), Validators.max(65535)]], - proxyUser: [''], - proxyPassword: [''], - username: [''], - changePassword: [false], - password: [''] + private initTemplates(templates): void { + templates.map(provider => { + delete provider.additionalInfo; + this.templates.set(provider.providerId, provider); }); + this.templateProvider.push(...Array.from(this.templates.keys())); + this.templateProvider.sort(); + } + + private mailServerSettingsForm(): void { this.registerDisableOnLoadFormControl(this.mailSettings.get('smtpProtocol')); this.registerDisableOnLoadFormControl(this.mailSettings.get('enableTls')); this.registerDisableOnLoadFormControl(this.mailSettings.get('enableProxy')); this.registerDisableOnLoadFormControl(this.mailSettings.get('changePassword')); + + this.mailSettings.get('enableTls').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe(value => this.enableTls(value)); + this.mailSettings.get('enableProxy').valueChanges.pipe( takeUntil(this.destroy$) ).subscribe(() => { this.enableProxyChanged(); }); + this.mailSettings.get('changePassword').valueChanges.pipe( takeUntil(this.destroy$) ).subscribe((value) => { this.enableMailPassword(value); }); + + this.mailSettings.get('enableOauth2').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe( value => { + this.enableOauth2(value); + this.enableProviderTenantIdChanged(this.mailSettings.get('providerId').value); + if (value && !this.mailSettings.get('redirectUri').value) { + this.mailSettings.get('redirectUri').patchValue(this.redirectURI(), {emitEvent: false}); + } + }); + + this.mailSettings.get('providerTenantId').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe(tenantId => { + const authorizationUri = this.templates.get(this.mailServerOauth2Provider.OFFICE_365).authorizationUri.replace('%s', `${tenantId}`); + const accessTokenUri = this.templates.get(this.mailServerOauth2Provider.OFFICE_365).accessTokenUri.replace('%s', `${tenantId}`); + this.mailSettings.get('authUri').patchValue(authorizationUri, {emitEvent: false}); + this.mailSettings.get('tokenUri').patchValue(accessTokenUri, {emitEvent: false}); + }); + + this.mailSettings.get('providerId').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe( value => { + if (value === this.mailServerOauth2Provider.CUSTOM || !value) { + this.mailSettings.reset({...this.adminSettings.jsonValue, ...this.defaultConfiguration}, {emitEvent: false}); + } else { + const config = this.templates.get(value); + this.helpLink = config.helpLink; + this.mailSettings.patchValue({ + smtpProtocol: SmtpProtocol[config.smtpProtocol], + smtpHost: config.smtpHost, + smtpPort: config.smtpPort, + timeout: config.timeout, + enableTls: config.enableTls, + tlsVersion: config.tlsVersion, + authUri: config.authorizationUri, + tokenUri: config.accessTokenUri, + scope: config.scope, + enableOauth2: false, + enableProxy: false, + proxyHost: '', + proxyPort: null, + proxyUser: '', + proxyPassword: '', + clientId: '', + clientSecret: '', + providerTenantId: '', + redirectUri: '' + }, {emitEvent: false}); + } + this.enableTls(this.mailSettings.get('enableTls').value); + this.enableOauth2(this.mailSettings.get('enableOauth2').value); + this.enableProviderTenantIdChanged(value); + }); + } + + private domainFormConfiguration(): void { + this.domainForm.get('name').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe( + value => this.mailSettings.get('redirectUri').patchValue( + this.redirectURI(this.domainForm.get('scheme').value, value), + {emitEvent: false} + ) + ); + this.domainForm.get('scheme').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe( + (value) => this.mailSettings.get('redirectUri').patchValue(this.redirectURI(value), {emitEvent: false}) + ); } - enableProxyChanged(): void { + private enableOauth2(value: boolean): void { + if (value) { + this.mailSettings.get('clientId').enable({emitEvent: false}); + this.mailSettings.get('clientSecret').enable({emitEvent: false}); + this.mailSettings.get('redirectUri').enable({emitEvent: false}); + if (this.mailSettings.get('providerId').value === this.mailServerOauth2Provider.CUSTOM) { + this.mailSettings.get('authUri').enable({emitEvent: false}); + this.mailSettings.get('tokenUri').enable({emitEvent: false}); + } else { + this.mailSettings.get('authUri').disable({emitEvent: false}); + this.mailSettings.get('tokenUri').disable({emitEvent: false}); + } + } else { + this.mailSettings.get('clientId').disable({emitEvent: false}); + this.mailSettings.get('clientSecret').disable({emitEvent: false}); + this.mailSettings.get('redirectUri').disable({emitEvent: false}); + this.mailSettings.get('authUri').disable({emitEvent: false}); + this.mailSettings.get('tokenUri').disable({emitEvent: false}); + } + } + + private enableProviderTenantIdChanged(value: string): void { + if (value === this.mailServerOauth2Provider.OFFICE_365 && this.mailSettings.get('enableOauth2').value) { + this.mailSettings.get('providerTenantId').enable({emitEvent: false}); + } else { + this.mailSettings.get('providerTenantId').disable({emitEvent: false}); + } + } + + private enableProxyChanged(): void { const enableProxy: boolean = this.mailSettings.get('enableProxy').value; if (enableProxy) { - this.mailSettings.get('proxyHost').enable(); - this.mailSettings.get('proxyPort').enable(); + this.mailSettings.get('proxyHost').enable({emitEvent: false}); + this.mailSettings.get('proxyPort').enable({emitEvent: false}); + this.mailSettings.get('proxyUser').enable({emitEvent: false}); + this.mailSettings.get('proxyPassword').enable({emitEvent: false}); } else { - this.mailSettings.get('proxyHost').disable(); - this.mailSettings.get('proxyPort').disable(); + this.mailSettings.get('proxyHost').disable({emitEvent: false}); + this.mailSettings.get('proxyPort').disable({emitEvent: false}); + this.mailSettings.get('proxyUser').disable({emitEvent: false}); + this.mailSettings.get('proxyPassword').disable({emitEvent: false}); } } - enableMailPassword(enable: boolean) { + private enableMailPassword(enable: boolean) { if (enable) { this.mailSettings.get('password').enable({emitEvent: false}); } else { @@ -134,14 +341,21 @@ export class MailServerComponent extends PageComponent implements OnInit, OnDest } } + private enableTls(enable: boolean): void { + if (enable) { + this.mailSettings.get('tlsVersion').enable({emitEvent: false}); + } else { + this.mailSettings.get('tlsVersion').disable({emitEvent: false}); + } + } + sendTestMail(): void { this.adminSettings.jsonValue = {...this.adminSettings.jsonValue, ...this.mailSettingsFormValue}; - this.adminService.sendTestMail(this.adminSettings).subscribe( - () => { - this.store.dispatch(new ActionNotificationShow({ message: this.translate.instant('admin.test-mail-sent'), - type: 'success' })); - } - ); + this.adminService.sendTestMail(this.adminSettings).subscribe({ + next: () => this.store.dispatch(new ActionNotificationShow({ message: this.translate.instant('admin.test-mail-sent'), + type: 'success' })), + error: error => this.store.dispatch(new ActionNotificationShow({message: error.error.message, type: 'error'})) + }); } save(): void { @@ -150,18 +364,88 @@ export class MailServerComponent extends PageComponent implements OnInit, OnDest (adminSettings) => { this.adminSettings = adminSettings; this.showChangePassword = true; - this.mailSettings.reset(this.adminSettings.jsonValue); + this.mailSettings.reset(this.adminSettings.jsonValue, {emitEvent: false}); + this.domainForm.reset(this.domainForm.value); + this.parseUrl(this.adminSettings.jsonValue.redirectUri); } ); } - confirmForm(): UntypedFormGroup { + generateAccessToken(): void { + this.adminService.generateAccessToken().subscribe( + uri => this.window.location.href = uri + ); + } + + redirectURI(schema?: DomainSchema, name?: string): string { + const domainInfo = this.domainForm.value; + if (domainInfo.name !== '') { + const protocol = isDefined(schema) ? schema.toLowerCase() : domainInfo.scheme.toLowerCase(); + const domainName = isDefined(name) ? name : domainInfo.name; + return `${protocol}://${domainName}${this.loginProcessingUrl}`; + } + return ''; + } + + private parseUrl(value: string): void { + if (value) { + const url = new URL(value); + this.domainForm.get('scheme').patchValue( + url.protocol.startsWith('https') ? DomainSchema.HTTPS : DomainSchema.HTTP, {emitEvent: false} + ); + this.domainForm.get('name').patchValue(url.host, {emitEvent: false}); + } + } + + get accessTokenButtonName(): string { + return this.translate.instant( + this.adminSettings.jsonValue.tokenGenerated ? 'admin.oauth2.update-access-token' : 'admin.oauth2.generate-access-token' + ); + } + + get accessTokenStatus(): string { + return this.translate.instant( + this.adminSettings.jsonValue.tokenGenerated ? 'admin.oauth2.token-status-generated' : 'admin.oauth2.token-status-not-generated' + ); + } + + confirmForm(): FormGroup { return this.mailSettings; } private get mailSettingsFormValue(): MailServerSettings { - const formValue = this.mailSettings.value; + const formValue = this.mailSettings.getRawValue() as Required; delete formValue.changePassword; return formValue; } + + trackByParams(index: number): number { + return index; + } + + removeScope(i: number): void { + const controller = this.mailSettings.get('scope') as UntypedFormArray; + controller.removeAt(i); + controller.markAsTouched(); + controller.markAsDirty(); + } + + addScope(event: MatChipInputEvent): void { + const input = event.chipInput.inputElement; + const value = event.value; + const controller = this.mailSettings.get('scope') as UntypedFormArray; + if ((value.trim() !== '')) { + controller.push(this.fb.control(value.trim())); + controller.markAsDirty(); + } + + if (input) { + input.value = ''; + } + } + + toggleEditMode(path: string): void { + this.mailSettings.get(path).disabled ? this.mailSettings.get(path).enable() : this.mailSettings.get(path).disable(); + } + } diff --git a/ui-ngx/src/app/shared/models/settings.models.ts b/ui-ngx/src/app/shared/models/settings.models.ts index 0cb02d6257..14f601dbe4 100644 --- a/ui-ngx/src/app/shared/models/settings.models.ts +++ b/ui-ngx/src/app/shared/models/settings.models.ts @@ -17,6 +17,7 @@ import { ValidatorFn } from '@angular/forms'; import { isNotEmptyStr, isNumber } from '@core/utils'; import { VersionCreateConfig } from '@shared/models/vc.models'; +import { HasUUID } from '@shared/models/id/has-uuid'; export const smtpPortPattern: RegExp = /^([0-9]{1,4}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])$/; @@ -25,16 +26,20 @@ export interface AdminSettings { jsonValue: T; } -export declare type SmtpProtocol = 'smtp' | 'smtps'; +export enum SmtpProtocol { + SMTP = 'smtp', + SMTPS = 'smtps' +} export interface MailServerSettings { - showChangePassword: boolean; + showChangePassword?: boolean; mailFrom: string; smtpProtocol: SmtpProtocol; smtpHost: string; smtpPort: number; timeout: number; enableTls: boolean; + tlsVersion: string; username: string; changePassword?: boolean; password?: string; @@ -43,6 +48,39 @@ export interface MailServerSettings { proxyPort: number; proxyUser: string; proxyPassword: string; + enableOauth2: boolean; + providerId?: string; + clientId?: string; + clientSecret?: string; + providerTenantId?: string; + authUri?: string; + tokenUri?: string; + scope?: Array; + redirectUri?: string; + tokenGenerated?: boolean; +} + +export enum MailServerOauth2Provider { + OFFICE_365 = 'OFFICE_365', + CUSTOM = 'CUSTOM' +} + +export interface MailConfigTemplate { + id: HasUUID; + createdTime: number; + name: string; + providerId: string; + helpLink: string; + scope: Array; + accessTokenUri: string; + authorizationUri: string; + enableTls: boolean; + tlsVersion: string; + smtpProtocol: SmtpProtocol; + smtpHost: string; + smtpPort: number; + timeout: number; + additionalInfo: any; } export interface GeneralSettings { diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index c4658c442a..e46bad540d 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -174,6 +174,7 @@ "domain-name-unique": "Domain name and protocol need to unique.", "domain-name-max-length": "Domain name should be less than 256", "error-verification-url": "A domain name shouldn't contain symbols '/' and ':'. Example: thingsboard.io", + "connection-settings": "Connection settings", "oauth2": { "access-token-uri": "Access token URI", "access-token-uri-required": "Access token URI is required.", @@ -264,7 +265,28 @@ "platform-android": "Android", "platform-ios": "iOS", "all-platforms": "All platforms", - "allowed-platforms": "Allowed platforms" + "smtp-provider": "SMTP provider", + "allowed-platforms": "Allowed platforms", + "authentication": "Authentication", + "basic": "Basic", + "provider": "Provider", + "redirect-url": "Redirect URI", + "domain-name": "Domain name", + "redirect-url-template": "Redirect URI template", + "microsoft-tenant-id": "Directory (tenant) Id", + "microsoft-tenant-id-required": "Directory (tenant) Id is required", + "token-uri": "Token URI", + "token-uri-required": "Token URI is required", + "redirect-uri": "Redirect URI", + "google-provider": "Google", + "microsoft-provider": "Office 365", + "sendgrid-provider": "Sendgrid", + "custom-provider": "Custom", + "generate-access-token": "Generate access token", + "update-access-token": "Update access token", + "access-token-status": "Access token status:", + "token-status-generated": "generated", + "token-status-not-generated": "not generated" }, "smpp-provider": { "smpp-version": "SMPP version", From 10258426686119fdd8bca01806c804d0bfb76cbc Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Thu, 8 Jun 2023 16:18:45 +0300 Subject: [PATCH 2/4] fixed license --- .../mail/DefaultTbMailConfigTemplateService.java | 15 +++++++++++++++ .../service/mail/TbMailConfigTemplateService.java | 15 +++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/application/src/main/java/org/thingsboard/server/service/mail/DefaultTbMailConfigTemplateService.java b/application/src/main/java/org/thingsboard/server/service/mail/DefaultTbMailConfigTemplateService.java index 3d4cb261c7..2272a42b5b 100644 --- a/application/src/main/java/org/thingsboard/server/service/mail/DefaultTbMailConfigTemplateService.java +++ b/application/src/main/java/org/thingsboard/server/service/mail/DefaultTbMailConfigTemplateService.java @@ -1,3 +1,18 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package org.thingsboard.server.service.mail; import com.fasterxml.jackson.databind.JsonNode; diff --git a/application/src/main/java/org/thingsboard/server/service/mail/TbMailConfigTemplateService.java b/application/src/main/java/org/thingsboard/server/service/mail/TbMailConfigTemplateService.java index 2475fe7513..b69ae0977c 100644 --- a/application/src/main/java/org/thingsboard/server/service/mail/TbMailConfigTemplateService.java +++ b/application/src/main/java/org/thingsboard/server/service/mail/TbMailConfigTemplateService.java @@ -1,3 +1,18 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package org.thingsboard.server.service.mail; import com.fasterxml.jackson.databind.JsonNode; From 44c784c248693deef2a3d6b0e1b0b900e8aea092 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Thu, 8 Jun 2023 18:37:06 +0300 Subject: [PATCH 3/4] refactoring --- .../mail/DefaultTbMailConfigTemplateService.java | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/mail/DefaultTbMailConfigTemplateService.java b/application/src/main/java/org/thingsboard/server/service/mail/DefaultTbMailConfigTemplateService.java index 2272a42b5b..cb502e7e51 100644 --- a/application/src/main/java/org/thingsboard/server/service/mail/DefaultTbMailConfigTemplateService.java +++ b/application/src/main/java/org/thingsboard/server/service/mail/DefaultTbMailConfigTemplateService.java @@ -21,14 +21,22 @@ import org.springframework.core.io.ClassPathResource; import org.springframework.stereotype.Service; import org.thingsboard.common.util.JacksonUtil; +import javax.annotation.PostConstruct; import java.io.IOException; @Service @Slf4j public class DefaultTbMailConfigTemplateService implements TbMailConfigTemplateService { + private JsonNode mailConfigTemplates; + + @PostConstruct + private void postConstruct() throws IOException { + mailConfigTemplates = JacksonUtil.toJsonNode(new ClassPathResource("/templates/mail_config_templates.json").getFile()); + } + @Override - public JsonNode findAllMailConfigTemplates() throws IOException { - return JacksonUtil.toJsonNode(new ClassPathResource("/templates/mail_config_templates.json").getFile()); + public JsonNode findAllMailConfigTemplates() { + return mailConfigTemplates; } } From 9d57be98bf3a603a3b741a6f89c711dd0249367c Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Fri, 9 Jun 2023 13:06:40 +0300 Subject: [PATCH 4/4] refactoring --- .../MailConfigTemplateController.java | 26 ------------------- 1 file changed, 26 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/MailConfigTemplateController.java b/application/src/main/java/org/thingsboard/server/controller/MailConfigTemplateController.java index fc13edd3d7..09aed4fee3 100644 --- a/application/src/main/java/org/thingsboard/server/controller/MailConfigTemplateController.java +++ b/application/src/main/java/org/thingsboard/server/controller/MailConfigTemplateController.java @@ -16,45 +16,21 @@ package org.thingsboard.server.controller; import com.fasterxml.jackson.databind.JsonNode; -import com.nimbusds.jose.shaded.json.JSONObject; import io.swagger.annotations.ApiOperation; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.apache.commons.io.FileUtils; -import org.apache.commons.lang3.CharEncoding; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.core.io.ClassPathResource; -import org.springframework.http.HttpStatus; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.RestController; -import org.thingsboard.common.util.JacksonUtil; -import org.thingsboard.server.common.data.ResourceType; -import org.thingsboard.server.common.data.TbResource; import org.thingsboard.server.common.data.exception.ThingsboardException; -import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.dao.settings.AdminSettingsService; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.mail.TbMailConfigTemplateService; import org.thingsboard.server.service.security.permission.Operation; import org.thingsboard.server.service.security.permission.Resource; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.springframework.core.io.ClassPathResource; -import org.springframework.http.MediaType; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.ResponseBody; -import org.springframework.web.bind.annotation.RestController; -import java.io.File; import java.io.IOException; -import java.io.InputStream; -import java.nio.file.DirectoryStream; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.Base64; import static org.thingsboard.server.controller.ControllerConstants.SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH; @@ -64,9 +40,7 @@ import static org.thingsboard.server.controller.ControllerConstants.SYSTEM_OR_TE @RequestMapping("/api/mail/config/template") @Slf4j public class MailConfigTemplateController extends BaseController { - private static final String MAIL_CONFIG_TEMPLATE_ID = "mailConfigTemplateId"; private static final String MAIL_CONFIG_TEMPLATE_DEFINITION = "Mail configuration template is set of default smtp settings for mail server that specific provider supports"; - private final TbMailConfigTemplateService mailConfigTemplateService; @ApiOperation(value = "Get the list of all OAuth2 client registration templates (getClientRegistrationTemplates)" + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH,