Commit c0dfa4861ba231a0a2542c6be4c2bc9396370a0c

Authored by VoBa
Committed by GitHub
1 parent 7013377d

[WIP] [3.0] Added OAuth2 Support (#2709)

* Added base impl for OAuth-2

* Added basic and custom OAuth2 user mappers

* Removed comment line

* Refactoring to review. Added tenantId and customerId. Added email tenant name strategy

* Revert debug logger

* Fixed compilation

* Test fixed

* Create UI for OAuthService

* Revert package-lock.json

* Add translate login es_ES

Co-authored-by: Vladyslav_Prykhodko <vprykhodko@thingsboard.io>
Showing 48 changed files with 1117 additions and 54 deletions
... ... @@ -133,6 +133,18 @@
133 133 <artifactId>spring-boot-starter-websocket</artifactId>
134 134 </dependency>
135 135 <dependency>
  136 + <groupId>org.springframework.cloud</groupId>
  137 + <artifactId>spring-cloud-starter-oauth2</artifactId>
  138 + </dependency>
  139 + <dependency>
  140 + <groupId>org.springframework.security</groupId>
  141 + <artifactId>spring-security-oauth2-client</artifactId>
  142 + </dependency>
  143 + <dependency>
  144 + <groupId>org.springframework.security</groupId>
  145 + <artifactId>spring-security-oauth2-jose</artifactId>
  146 + </dependency>
  147 + <dependency>
136 148 <groupId>io.jsonwebtoken</groupId>
137 149 <artifactId>jjwt</artifactId>
138 150 </dependency>
... ...
... ... @@ -39,6 +39,7 @@ import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
39 39 import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
40 40 import org.springframework.web.filter.CorsFilter;
41 41 import org.thingsboard.server.dao.audit.AuditLogLevelFilter;
  42 +import org.thingsboard.server.dao.oauth2.OAuth2Configuration;
42 43 import org.thingsboard.server.exception.ThingsboardErrorResponseHandler;
43 44 import org.thingsboard.server.service.security.auth.jwt.JwtAuthenticationProvider;
44 45 import org.thingsboard.server.service.security.auth.jwt.JwtTokenAuthenticationProcessingFilter;
... ... @@ -73,12 +74,22 @@ public class ThingsboardSecurityConfiguration extends WebSecurityConfigurerAdapt
73 74 public static final String WS_TOKEN_BASED_AUTH_ENTRY_POINT = "/api/ws/**";
74 75
75 76 @Autowired private ThingsboardErrorResponseHandler restAccessDeniedHandler;
76   - @Autowired private AuthenticationSuccessHandler successHandler;
  77 +
  78 + @Autowired(required = false)
  79 + @Qualifier("oauth2AuthenticationSuccessHandler")
  80 + private AuthenticationSuccessHandler oauth2AuthenticationSuccessHandler;
  81 +
  82 + @Autowired
  83 + @Qualifier("defaultAuthenticationSuccessHandler")
  84 + private AuthenticationSuccessHandler successHandler;
  85 +
77 86 @Autowired private AuthenticationFailureHandler failureHandler;
78 87 @Autowired private RestAuthenticationProvider restAuthenticationProvider;
79 88 @Autowired private JwtAuthenticationProvider jwtAuthenticationProvider;
80 89 @Autowired private RefreshTokenAuthenticationProvider refreshTokenAuthenticationProvider;
81 90
  91 + @Autowired(required = false) OAuth2Configuration oauth2Configuration;
  92 +
82 93 @Autowired
83 94 @Qualifier("jwtHeaderTokenExtractor")
84 95 private TokenExtractor jwtHeaderTokenExtractor;
... ... @@ -189,6 +200,12 @@ public class ThingsboardSecurityConfiguration extends WebSecurityConfigurerAdapt
189 200 .addFilterBefore(buildRefreshTokenProcessingFilter(), UsernamePasswordAuthenticationFilter.class)
190 201 .addFilterBefore(buildWsJwtTokenAuthenticationProcessingFilter(), UsernamePasswordAuthenticationFilter.class)
191 202 .addFilterAfter(rateLimitProcessingFilter, UsernamePasswordAuthenticationFilter.class);
  203 + if (oauth2Configuration != null && oauth2Configuration.isEnabled()) {
  204 + http.oauth2Login()
  205 + .loginPage("/oauth2Login")
  206 + .loginProcessingUrl(oauth2Configuration.getLoginProcessingUrl())
  207 + .successHandler(oauth2AuthenticationSuccessHandler);
  208 + }
192 209 }
193 210
194 211
... ...
... ... @@ -38,8 +38,10 @@ import org.thingsboard.server.common.data.audit.ActionType;
38 38 import org.thingsboard.server.common.data.exception.ThingsboardErrorCode;
39 39 import org.thingsboard.server.common.data.exception.ThingsboardException;
40 40 import org.thingsboard.server.common.data.id.TenantId;
  41 +import org.thingsboard.server.common.data.oauth2.OAuth2ClientInfo;
41 42 import org.thingsboard.server.common.data.security.UserCredentials;
42 43 import org.thingsboard.server.dao.audit.AuditLogService;
  44 +import org.thingsboard.server.dao.oauth2.OAuth2Service;
43 45 import org.thingsboard.server.queue.util.TbCoreComponent;
44 46 import org.thingsboard.server.service.security.auth.jwt.RefreshTokenRepository;
45 47 import org.thingsboard.server.service.security.auth.rest.RestAuthenticationDetails;
... ... @@ -55,6 +57,7 @@ import ua_parser.Client;
55 57 import javax.servlet.http.HttpServletRequest;
56 58 import java.net.URI;
57 59 import java.net.URISyntaxException;
  60 +import java.util.List;
58 61
59 62 @RestController
60 63 @TbCoreComponent
... ... @@ -80,6 +83,9 @@ public class AuthController extends BaseController {
80 83 @Autowired
81 84 private AuditLogService auditLogService;
82 85
  86 + @Autowired
  87 + private OAuth2Service oauth2Service;
  88 +
83 89 @PreAuthorize("isAuthenticated()")
84 90 @RequestMapping(value = "/auth/user", method = RequestMethod.GET)
85 91 public @ResponseBody User getUser() throws ThingsboardException {
... ... @@ -330,4 +336,13 @@ public class AuthController extends BaseController {
330 336 }
331 337 }
332 338
  339 + @RequestMapping(value = "/noauth/oauth2Clients", method = RequestMethod.POST)
  340 + @ResponseBody
  341 + public List<OAuth2ClientInfo> getOath2Clients() throws ThingsboardException {
  342 + try {
  343 + return oauth2Service.getOAuth2Clients();
  344 + } catch (Exception e) {
  345 + throw handleException(e);
  346 + }
  347 + }
333 348 }
... ...
  1 +/**
  2 + * Copyright © 2016-2020 The Thingsboard Authors
  3 + *
  4 + * Licensed under the Apache License, Version 2.0 (the "License");
  5 + * you may not use this file except in compliance with the License.
  6 + * You may obtain a copy of the License at
  7 + *
  8 + * http://www.apache.org/licenses/LICENSE-2.0
  9 + *
  10 + * Unless required by applicable law or agreed to in writing, software
  11 + * distributed under the License is distributed on an "AS IS" BASIS,
  12 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13 + * See the License for the specific language governing permissions and
  14 + * limitations under the License.
  15 + */
  16 +package org.thingsboard.server.service.security.auth.oauth2;
  17 +
  18 +import lombok.extern.slf4j.Slf4j;
  19 +import org.springframework.beans.factory.annotation.Autowired;
  20 +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
  21 +import org.springframework.security.core.userdetails.UsernameNotFoundException;
  22 +import org.springframework.util.StringUtils;
  23 +import org.thingsboard.server.common.data.Customer;
  24 +import org.thingsboard.server.common.data.Tenant;
  25 +import org.thingsboard.server.common.data.User;
  26 +import org.thingsboard.server.common.data.id.CustomerId;
  27 +import org.thingsboard.server.common.data.id.TenantId;
  28 +import org.thingsboard.server.common.data.page.PageLink;
  29 +import org.thingsboard.server.common.data.security.Authority;
  30 +import org.thingsboard.server.dao.customer.CustomerService;
  31 +import org.thingsboard.server.dao.oauth2.OAuth2User;
  32 +import org.thingsboard.server.dao.tenant.TenantService;
  33 +import org.thingsboard.server.dao.user.UserService;
  34 +import org.thingsboard.server.service.security.model.SecurityUser;
  35 +import org.thingsboard.server.service.security.model.UserPrincipal;
  36 +
  37 +import java.util.List;
  38 +import java.util.Optional;
  39 +import java.util.concurrent.locks.Lock;
  40 +import java.util.concurrent.locks.ReentrantLock;
  41 +
  42 +@Slf4j
  43 +public abstract class AbstractOAuth2ClientMapper {
  44 +
  45 + @Autowired
  46 + private UserService userService;
  47 +
  48 + @Autowired
  49 + private TenantService tenantService;
  50 +
  51 + @Autowired
  52 + private CustomerService customerService;
  53 +
  54 + private final Lock userCreationLock = new ReentrantLock();
  55 +
  56 + protected SecurityUser getOrCreateSecurityUserFromOAuth2User(OAuth2User oauth2User, boolean allowUserCreation) {
  57 + UserPrincipal principal = new UserPrincipal(UserPrincipal.Type.USER_NAME, oauth2User.getEmail());
  58 +
  59 + User user = userService.findUserByEmail(TenantId.SYS_TENANT_ID, oauth2User.getEmail());
  60 +
  61 + if (user == null && !allowUserCreation) {
  62 + throw new UsernameNotFoundException("User not found: " + oauth2User.getEmail());
  63 + }
  64 +
  65 + if (user == null) {
  66 + userCreationLock.lock();
  67 + try {
  68 + user = userService.findUserByEmail(TenantId.SYS_TENANT_ID, oauth2User.getEmail());
  69 + if (user == null) {
  70 + user = new User();
  71 + if (oauth2User.getCustomerId() == null && StringUtils.isEmpty(oauth2User.getCustomerName())) {
  72 + user.setAuthority(Authority.TENANT_ADMIN);
  73 + } else {
  74 + user.setAuthority(Authority.CUSTOMER_USER);
  75 + }
  76 + TenantId tenantId = oauth2User.getTenantId() != null ?
  77 + oauth2User.getTenantId() : getTenantId(oauth2User.getTenantName());
  78 + user.setTenantId(tenantId);
  79 + CustomerId customerId = oauth2User.getCustomerId() != null ?
  80 + oauth2User.getCustomerId() : getCustomerId(user.getTenantId(), oauth2User.getCustomerName());
  81 + user.setCustomerId(customerId);
  82 + user.setEmail(oauth2User.getEmail());
  83 + user.setFirstName(oauth2User.getFirstName());
  84 + user.setLastName(oauth2User.getLastName());
  85 + user = userService.saveUser(user);
  86 + }
  87 + } finally {
  88 + userCreationLock.unlock();
  89 + }
  90 + }
  91 +
  92 + try {
  93 + SecurityUser securityUser = new SecurityUser(user, true, principal);
  94 + return (SecurityUser) new UsernamePasswordAuthenticationToken(securityUser, null, securityUser.getAuthorities()).getPrincipal();
  95 + } catch (Exception e) {
  96 + log.error("Can't get or create security user from oauth2 user", e);
  97 + throw new RuntimeException("Can't get or create security user from oauth2 user", e);
  98 + }
  99 + }
  100 +
  101 + private TenantId getTenantId(String tenantName) {
  102 + List<Tenant> tenants = tenantService.findTenants(new PageLink(1, 0, tenantName)).getData();
  103 + Tenant tenant;
  104 + if (tenants == null || tenants.isEmpty()) {
  105 + tenant = new Tenant();
  106 + tenant.setTitle(tenantName);
  107 + tenant = tenantService.saveTenant(tenant);
  108 + } else {
  109 + tenant = tenants.get(0);
  110 + }
  111 + return tenant.getTenantId();
  112 + }
  113 +
  114 + private CustomerId getCustomerId(TenantId tenantId, String customerName) {
  115 + if (StringUtils.isEmpty(customerName)) {
  116 + return null;
  117 + }
  118 + Optional<Customer> customerOpt = customerService.findCustomerByTenantIdAndTitle(tenantId, customerName);
  119 + if (customerOpt.isPresent()) {
  120 + return customerOpt.get().getId();
  121 + } else {
  122 + Customer customer = new Customer();
  123 + customer.setTenantId(tenantId);
  124 + customer.setTitle(customerName);
  125 + return customerService.saveCustomer(customer).getId();
  126 + }
  127 + }
  128 +}
... ...
  1 +/**
  2 + * Copyright © 2016-2020 The Thingsboard Authors
  3 + *
  4 + * Licensed under the Apache License, Version 2.0 (the "License");
  5 + * you may not use this file except in compliance with the License.
  6 + * You may obtain a copy of the License at
  7 + *
  8 + * http://www.apache.org/licenses/LICENSE-2.0
  9 + *
  10 + * Unless required by applicable law or agreed to in writing, software
  11 + * distributed under the License is distributed on an "AS IS" BASIS,
  12 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13 + * See the License for the specific language governing permissions and
  14 + * limitations under the License.
  15 + */
  16 +package org.thingsboard.server.service.security.auth.oauth2;
  17 +
  18 +import lombok.extern.slf4j.Slf4j;
  19 +import org.apache.commons.lang3.text.StrSubstitutor;
  20 +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
  21 +import org.springframework.stereotype.Service;
  22 +import org.springframework.util.StringUtils;
  23 +import org.thingsboard.server.dao.oauth2.OAuth2ClientMapperConfig;
  24 +import org.thingsboard.server.dao.oauth2.OAuth2User;
  25 +import org.thingsboard.server.service.security.model.SecurityUser;
  26 +
  27 +import java.util.Map;
  28 +
  29 +@Service(value = "basicOAuth2ClientMapper")
  30 +@Slf4j
  31 +public class BasicOAuth2ClientMapper extends AbstractOAuth2ClientMapper implements OAuth2ClientMapper {
  32 +
  33 + private static final String START_PLACEHOLDER_PREFIX = "%{";
  34 + private static final String END_PLACEHOLDER_PREFIX = "}";
  35 + private static final String EMAIL_TENANT_STRATEGY = "email";
  36 + private static final String DOMAIN_TENANT_STRATEGY = "domain";
  37 + private static final String CUSTOM_TENANT_STRATEGY = "custom";
  38 +
  39 + @Override
  40 + public SecurityUser getOrCreateUserByClientPrincipal(OAuth2AuthenticationToken token, OAuth2ClientMapperConfig config) {
  41 + OAuth2User oauth2User = new OAuth2User();
  42 + Map<String, Object> attributes = token.getPrincipal().getAttributes();
  43 + String email = getStringAttributeByKey(attributes, config.getBasic().getEmailAttributeKey());
  44 + oauth2User.setEmail(email);
  45 + oauth2User.setTenantName(getTenantName(attributes, config));
  46 + if (!StringUtils.isEmpty(config.getBasic().getLastNameAttributeKey())) {
  47 + String lastName = getStringAttributeByKey(attributes, config.getBasic().getLastNameAttributeKey());
  48 + oauth2User.setLastName(lastName);
  49 + }
  50 + if (!StringUtils.isEmpty(config.getBasic().getFirstNameAttributeKey())) {
  51 + String firstName = getStringAttributeByKey(attributes, config.getBasic().getFirstNameAttributeKey());
  52 + oauth2User.setFirstName(firstName);
  53 + }
  54 + if (!StringUtils.isEmpty(config.getBasic().getCustomerNameStrategyPattern())) {
  55 + StrSubstitutor sub = new StrSubstitutor(attributes, START_PLACEHOLDER_PREFIX, END_PLACEHOLDER_PREFIX);
  56 + String customerName = sub.replace(config.getBasic().getCustomerNameStrategyPattern());
  57 + oauth2User.setCustomerName(customerName);
  58 + }
  59 + return getOrCreateSecurityUserFromOAuth2User(oauth2User, config.getBasic().isAllowUserCreation());
  60 + }
  61 +
  62 + private String getTenantName(Map<String, Object> attributes, OAuth2ClientMapperConfig config) {
  63 + switch (config.getBasic().getTenantNameStrategy()) {
  64 + case EMAIL_TENANT_STRATEGY:
  65 + return getStringAttributeByKey(attributes, config.getBasic().getEmailAttributeKey());
  66 + case DOMAIN_TENANT_STRATEGY:
  67 + String email = getStringAttributeByKey(attributes, config.getBasic().getEmailAttributeKey());
  68 + return email.substring(email .indexOf("@") + 1);
  69 + case CUSTOM_TENANT_STRATEGY:
  70 + StrSubstitutor sub = new StrSubstitutor(attributes, START_PLACEHOLDER_PREFIX, END_PLACEHOLDER_PREFIX);
  71 + return sub.replace(config.getBasic().getTenantNameStrategyPattern());
  72 + default:
  73 + throw new RuntimeException("Tenant Name Strategy with type " + config.getBasic().getTenantNameStrategy() + " is not supported!");
  74 + }
  75 + }
  76 +
  77 + private String getStringAttributeByKey(Map<String, Object> attributes, String key) {
  78 + String result = null;
  79 + try {
  80 + result = (String) attributes.get(key);
  81 +
  82 + } catch (Exception e) {
  83 + log.warn("Can't convert attribute to String by key " + key);
  84 + }
  85 + return result;
  86 + }
  87 +}
... ...
  1 +/**
  2 + * Copyright © 2016-2020 The Thingsboard Authors
  3 + *
  4 + * Licensed under the Apache License, Version 2.0 (the "License");
  5 + * you may not use this file except in compliance with the License.
  6 + * You may obtain a copy of the License at
  7 + *
  8 + * http://www.apache.org/licenses/LICENSE-2.0
  9 + *
  10 + * Unless required by applicable law or agreed to in writing, software
  11 + * distributed under the License is distributed on an "AS IS" BASIS,
  12 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13 + * See the License for the specific language governing permissions and
  14 + * limitations under the License.
  15 + */
  16 +package org.thingsboard.server.service.security.auth.oauth2;
  17 +
  18 +import com.fasterxml.jackson.core.JsonProcessingException;
  19 +import com.fasterxml.jackson.databind.ObjectMapper;
  20 +import lombok.extern.slf4j.Slf4j;
  21 +import org.springframework.boot.web.client.RestTemplateBuilder;
  22 +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
  23 +import org.springframework.stereotype.Service;
  24 +import org.springframework.util.StringUtils;
  25 +import org.springframework.web.client.RestTemplate;
  26 +import org.thingsboard.server.dao.oauth2.OAuth2ClientMapperConfig;
  27 +import org.thingsboard.server.dao.oauth2.OAuth2User;
  28 +import org.thingsboard.server.service.security.model.SecurityUser;
  29 +
  30 +@Service(value = "customOAuth2ClientMapper")
  31 +@Slf4j
  32 +public class CustomOAuth2ClientMapper extends AbstractOAuth2ClientMapper implements OAuth2ClientMapper {
  33 +
  34 + private static final ObjectMapper json = new ObjectMapper();
  35 +
  36 + private RestTemplateBuilder restTemplateBuilder = new RestTemplateBuilder();
  37 +
  38 + @Override
  39 + public SecurityUser getOrCreateUserByClientPrincipal(OAuth2AuthenticationToken token, OAuth2ClientMapperConfig config) {
  40 + OAuth2User oauth2User = getOAuth2User(token, config.getCustom());
  41 + return getOrCreateSecurityUserFromOAuth2User(oauth2User, config.getBasic().isAllowUserCreation());
  42 + }
  43 +
  44 + public OAuth2User getOAuth2User(OAuth2AuthenticationToken token, OAuth2ClientMapperConfig.CustomOAuth2ClientMapperConfig custom) {
  45 + if (!StringUtils.isEmpty(custom.getUsername()) && !StringUtils.isEmpty(custom.getPassword())) {
  46 + restTemplateBuilder = restTemplateBuilder.basicAuthentication(custom.getUsername(), custom.getPassword());
  47 + }
  48 + RestTemplate restTemplate = restTemplateBuilder.build();
  49 + String request;
  50 + try {
  51 + request = json.writeValueAsString(token.getPrincipal());
  52 + } catch (JsonProcessingException e) {
  53 + log.error("Can't convert principal to JSON string", e);
  54 + throw new RuntimeException("Can't convert principal to JSON string", e);
  55 + }
  56 + try {
  57 + return restTemplate.postForEntity(custom.getUrl(), request, OAuth2User.class).getBody();
  58 + } catch (Exception e) {
  59 + log.error("Can't connect to custom mapper endpoint", e);
  60 + throw new RuntimeException("Can't connect to custom mapper endpoint", e);
  61 + }
  62 + }
  63 +}
... ...
  1 +/**
  2 + * Copyright © 2016-2020 The Thingsboard Authors
  3 + *
  4 + * Licensed under the Apache License, Version 2.0 (the "License");
  5 + * you may not use this file except in compliance with the License.
  6 + * You may obtain a copy of the License at
  7 + *
  8 + * http://www.apache.org/licenses/LICENSE-2.0
  9 + *
  10 + * Unless required by applicable law or agreed to in writing, software
  11 + * distributed under the License is distributed on an "AS IS" BASIS,
  12 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13 + * See the License for the specific language governing permissions and
  14 + * limitations under the License.
  15 + */
  16 +package org.thingsboard.server.service.security.auth.oauth2;
  17 +
  18 +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
  19 +import org.thingsboard.server.dao.oauth2.OAuth2ClientMapperConfig;
  20 +import org.thingsboard.server.service.security.model.SecurityUser;
  21 +
  22 +public interface OAuth2ClientMapper {
  23 + SecurityUser getOrCreateUserByClientPrincipal(OAuth2AuthenticationToken token, OAuth2ClientMapperConfig config);
  24 +}
... ...
  1 +/**
  2 + * Copyright © 2016-2020 The Thingsboard Authors
  3 + *
  4 + * Licensed under the Apache License, Version 2.0 (the "License");
  5 + * you may not use this file except in compliance with the License.
  6 + * You may obtain a copy of the License at
  7 + *
  8 + * http://www.apache.org/licenses/LICENSE-2.0
  9 + *
  10 + * Unless required by applicable law or agreed to in writing, software
  11 + * distributed under the License is distributed on an "AS IS" BASIS,
  12 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13 + * See the License for the specific language governing permissions and
  14 + * limitations under the License.
  15 + */
  16 +package org.thingsboard.server.service.security.auth.oauth2;
  17 +
  18 +import lombok.extern.slf4j.Slf4j;
  19 +import org.springframework.beans.factory.annotation.Autowired;
  20 +import org.springframework.beans.factory.annotation.Qualifier;
  21 +import org.springframework.stereotype.Component;
  22 +
  23 +@Component
  24 +@Slf4j
  25 +public class OAuth2ClientMapperProvider {
  26 +
  27 + @Autowired
  28 + @Qualifier("basicOAuth2ClientMapper")
  29 + private OAuth2ClientMapper basicOAuth2ClientMapper;
  30 +
  31 + @Autowired
  32 + @Qualifier("customOAuth2ClientMapper")
  33 + private OAuth2ClientMapper customOAuth2ClientMapper;
  34 +
  35 + public OAuth2ClientMapper getOAuth2ClientMapperByType(String oauth2ClientType) {
  36 + switch (oauth2ClientType) {
  37 + case "custom":
  38 + return customOAuth2ClientMapper;
  39 + case "basic":
  40 + return basicOAuth2ClientMapper;
  41 + default:
  42 + throw new RuntimeException("OAuth2ClientMapper with type " + oauth2ClientType + " is not supported!");
  43 + }
  44 + }
  45 +}
... ...
  1 +/**
  2 + * Copyright © 2016-2020 The Thingsboard Authors
  3 + *
  4 + * Licensed under the Apache License, Version 2.0 (the "License");
  5 + * you may not use this file except in compliance with the License.
  6 + * You may obtain a copy of the License at
  7 + *
  8 + * http://www.apache.org/licenses/LICENSE-2.0
  9 + *
  10 + * Unless required by applicable law or agreed to in writing, software
  11 + * distributed under the License is distributed on an "AS IS" BASIS,
  12 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13 + * See the License for the specific language governing permissions and
  14 + * limitations under the License.
  15 + */
  16 +package org.thingsboard.server.service.security.auth.oauth2;
  17 +
  18 +import org.springframework.beans.factory.annotation.Autowired;
  19 +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
  20 +import org.springframework.security.core.Authentication;
  21 +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
  22 +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
  23 +import org.springframework.stereotype.Component;
  24 +import org.thingsboard.server.dao.oauth2.OAuth2Client;
  25 +import org.thingsboard.server.dao.oauth2.OAuth2Configuration;
  26 +import org.thingsboard.server.service.security.auth.jwt.RefreshTokenRepository;
  27 +import org.thingsboard.server.service.security.model.SecurityUser;
  28 +import org.thingsboard.server.service.security.model.token.JwtToken;
  29 +import org.thingsboard.server.service.security.model.token.JwtTokenFactory;
  30 +
  31 +import javax.servlet.http.HttpServletRequest;
  32 +import javax.servlet.http.HttpServletResponse;
  33 +import java.io.IOException;
  34 +
  35 +@Component(value = "oauth2AuthenticationSuccessHandler")
  36 +@ConditionalOnProperty(prefix = "security.oauth2", value = "enabled", havingValue = "true")
  37 +public class Oauth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
  38 +
  39 + private final JwtTokenFactory tokenFactory;
  40 + private final RefreshTokenRepository refreshTokenRepository;
  41 + private final OAuth2ClientMapperProvider oauth2ClientMapperProvider;
  42 + private final OAuth2Configuration oauth2Configuration;
  43 +
  44 + @Autowired
  45 + public Oauth2AuthenticationSuccessHandler(final JwtTokenFactory tokenFactory,
  46 + final RefreshTokenRepository refreshTokenRepository,
  47 + final OAuth2ClientMapperProvider oauth2ClientMapperProvider,
  48 + final OAuth2Configuration oauth2Configuration) {
  49 + this.tokenFactory = tokenFactory;
  50 + this.refreshTokenRepository = refreshTokenRepository;
  51 + this.oauth2ClientMapperProvider = oauth2ClientMapperProvider;
  52 + this.oauth2Configuration = oauth2Configuration;
  53 + }
  54 +
  55 + @Override
  56 + public void onAuthenticationSuccess(HttpServletRequest request,
  57 + HttpServletResponse response,
  58 + Authentication authentication) throws IOException {
  59 + OAuth2AuthenticationToken token = (OAuth2AuthenticationToken) authentication;
  60 +
  61 + OAuth2Client oauth2Client = oauth2Configuration.getClientByRegistrationId(token.getAuthorizedClientRegistrationId());
  62 + OAuth2ClientMapper mapper = oauth2ClientMapperProvider.getOAuth2ClientMapperByType(oauth2Client.getMapperConfig().getType());
  63 + SecurityUser securityUser = mapper.getOrCreateUserByClientPrincipal(token, oauth2Client.getMapperConfig());
  64 +
  65 + JwtToken accessToken = tokenFactory.createAccessJwtToken(securityUser);
  66 + JwtToken refreshToken = refreshTokenRepository.requestRefreshToken(securityUser);
  67 +
  68 + getRedirectStrategy().sendRedirect(request, response, "/?accessToken=" + accessToken.getToken() + "&refreshToken=" + refreshToken.getToken());
  69 + }
  70 +}
\ No newline at end of file
... ...
... ... @@ -36,7 +36,7 @@ import java.io.IOException;
36 36 import java.util.HashMap;
37 37 import java.util.Map;
38 38
39   -@Component
  39 +@Component(value = "defaultAuthenticationSuccessHandler")
40 40 public class RestAwareAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
41 41 private final ObjectMapper mapper;
42 42 private final JwtTokenFactory tokenFactory;
... ...
... ... @@ -97,6 +97,41 @@ security:
97 97 allowClaimingByDefault: "${SECURITY_CLAIM_ALLOW_CLAIMING_BY_DEFAULT:true}"
98 98 # Time allowed to claim the device in milliseconds
99 99 duration: "${SECURITY_CLAIM_DURATION:60000}" # 1 minute, note this value must equal claimDevices.timeToLiveInMinutes value
  100 + basic:
  101 + enabled: "${SECURITY_BASIC_ENABLED:false}"
  102 + oauth2:
  103 + enabled: "${SECURITY_OAUTH2_ENABLED:false}"
  104 + loginProcessingUrl: "${SECURITY_OAUTH2_LOGIN_PROCESSING_URL:/login/oauth2/code/}"
  105 + clients:
  106 + default:
  107 + loginButtonLabel: "${SECURITY_OAUTH2_DEFAULT_LOGIN_BUTTON_LABEL:Default}" # Label that going to be show on login screen
  108 + loginButtonIcon: "${SECURITY_OAUTH2_DEFAULT_LOGIN_BUTTON_ICON:}" # Icon that going to be show on login screen. Material design icon ID (https://material.angularjs.org/latest/api/directive/mdIcon)
  109 + clientName: "${SECURITY_OAUTH2_DEFAULT_CLIENT_NAME:ClientName}"
  110 + clientId: "${SECURITY_OAUTH2_DEFAULT_CLIENT_ID:}"
  111 + clientSecret: "${SECURITY_OAUTH2_DEFAULT_CLIENT_SECRET:}"
  112 + accessTokenUri: "${SECURITY_OAUTH2_DEFAULT_ACCESS_TOKEN_URI:}"
  113 + authorizationUri: "${SECURITY_OAUTH2_DEFAULT_AUTHORIZATION_URI:}"
  114 + scope: "${SECURITY_OAUTH2_DEFAULT_SCOPE:}"
  115 + redirectUriTemplate: "${SECURITY_OAUTH2_DEFAULT_REDIRECT_URI_TEMPLATE:http://localhost:8080/login/oauth2/code/}" # Must be in sync with security.oauth2.loginProcessingUrl
  116 + jwkSetUri: "${SECURITY_OAUTH2_DEFAULT_JWK_SET_URI:}"
  117 + authorizationGrantType: "${SECURITY_OAUTH2_DEFAULT_AUTHORIZATION_GRANT_TYPE:authorization_code}" # authorization_code, implicit, refresh_token or client_credentials
  118 + clientAuthenticationMethod: "${SECURITY_OAUTH2_DEFAULT_CLIENT_AUTHENTICATION_METHOD:post}" # basic or post
  119 + userInfoUri: "${SECURITY_OAUTH2_DEFAULT_USER_INFO_URI:}"
  120 + userNameAttributeName: "${SECURITY_OAUTH2_DEFAULT_USER_NAME_ATTRIBUTE_NAME:email}"
  121 + mapperConfig:
  122 + type: "${SECURITY_OAUTH2_DEFAULT_MAPPER_TYPE:basic}" # basic or custom
  123 + basic:
  124 + allowUserCreation: "${SECURITY_OAUTH2_DEFAULT_MAPPER_BASIC_ALLOW_USER_CREATION:true}" # Allows to create user if it not exists
  125 + emailAttributeKey: "${SECURITY_OAUTH2_DEFAULT_MAPPER_BASIC_EMAIL_ATTRIBUTE_KEY:email}" # Attribute key to use as email for the user
  126 + firstNameAttributeKey: "${SECURITY_OAUTH2_DEFAULT_MAPPER_BASIC_FIRST_NAME_ATTRIBUTE_KEY:}"
  127 + lastNameAttributeKey: "${SECURITY_OAUTH2_DEFAULT_MAPPER_BASIC_LAST_NAME_ATTRIBUTE_KEY:}"
  128 + tenantNameStrategy: "${SECURITY_OAUTH2_DEFAULT_MAPPER_BASIC_TENANT_NAME_STRATEGY:domain}" # domain, email or custom
  129 + tenantNameStrategyPattern: "${SECURITY_OAUTH2_DEFAULT_MAPPER_BASIC_TENANT_NAME_STRATEGY_PATTERN:}"
  130 + customerNameStrategyPattern: "${SECURITY_OAUTH2_DEFAULT_MAPPER_BASIC_CUSTOMER_NAME_STRATEGY_PATTERN:}"
  131 + custom:
  132 + url: "${SECURITY_OAUTH2_DEFAULT_MAPPER_CUSTOM_URL:}"
  133 + username: "${SECURITY_OAUTH2_DEFAULT_MAPPER_CUSTOM_USERNAME:}"
  134 + password: "${SECURITY_OAUTH2_DEFAULT_MAPPER_CUSTOM_PASSWORD:}"
100 135
101 136 # Dashboard parameters
102 137 dashboard:
... ...
  1 +/**
  2 + * Copyright © 2016-2020 The Thingsboard Authors
  3 + *
  4 + * Licensed under the Apache License, Version 2.0 (the "License");
  5 + * you may not use this file except in compliance with the License.
  6 + * You may obtain a copy of the License at
  7 + *
  8 + * http://www.apache.org/licenses/LICENSE-2.0
  9 + *
  10 + * Unless required by applicable law or agreed to in writing, software
  11 + * distributed under the License is distributed on an "AS IS" BASIS,
  12 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13 + * See the License for the specific language governing permissions and
  14 + * limitations under the License.
  15 + */
  16 +package org.thingsboard.server.dao.oauth2;
  17 +
  18 +import org.thingsboard.server.common.data.oauth2.OAuth2ClientInfo;
  19 +
  20 +import java.util.List;
  21 +
  22 +public interface OAuth2Service {
  23 +
  24 + List<OAuth2ClientInfo> getOAuth2Clients();
  25 +}
... ...
  1 +/**
  2 + * Copyright © 2016-2020 The Thingsboard Authors
  3 + *
  4 + * Licensed under the Apache License, Version 2.0 (the "License");
  5 + * you may not use this file except in compliance with the License.
  6 + * You may obtain a copy of the License at
  7 + *
  8 + * http://www.apache.org/licenses/LICENSE-2.0
  9 + *
  10 + * Unless required by applicable law or agreed to in writing, software
  11 + * distributed under the License is distributed on an "AS IS" BASIS,
  12 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13 + * See the License for the specific language governing permissions and
  14 + * limitations under the License.
  15 + */
  16 +package org.thingsboard.server.dao.oauth2;
  17 +
  18 +import lombok.Data;
  19 +import org.thingsboard.server.common.data.id.CustomerId;
  20 +import org.thingsboard.server.common.data.id.TenantId;
  21 +
  22 +@Data
  23 +public class OAuth2User {
  24 + private String tenantName;
  25 + private TenantId tenantId;
  26 + private String customerName;
  27 + private CustomerId customerId;
  28 + private String email;
  29 + private String firstName;
  30 + private String lastName;
  31 +}
... ...
  1 +/**
  2 + * Copyright © 2016-2020 The Thingsboard Authors
  3 + *
  4 + * Licensed under the Apache License, Version 2.0 (the "License");
  5 + * you may not use this file except in compliance with the License.
  6 + * You may obtain a copy of the License at
  7 + *
  8 + * http://www.apache.org/licenses/LICENSE-2.0
  9 + *
  10 + * Unless required by applicable law or agreed to in writing, software
  11 + * distributed under the License is distributed on an "AS IS" BASIS,
  12 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13 + * See the License for the specific language governing permissions and
  14 + * limitations under the License.
  15 + */
  16 +package org.thingsboard.server.common.data.id;
  17 +
  18 +import com.fasterxml.jackson.annotation.JsonCreator;
  19 +import com.fasterxml.jackson.annotation.JsonProperty;
  20 +
  21 +import java.util.UUID;
  22 +
  23 +public class OAuth2IntegrationId extends UUIDBased {
  24 +
  25 + private static final long serialVersionUID = 1L;
  26 +
  27 + @JsonCreator
  28 + public OAuth2IntegrationId(@JsonProperty("id") UUID id) {
  29 + super(id);
  30 + }
  31 +
  32 + public static OAuth2IntegrationId fromString(String oauth2IntegrationId) {
  33 + return new OAuth2IntegrationId(UUID.fromString(oauth2IntegrationId));
  34 + }
  35 +}
... ...
  1 +/**
  2 + * Copyright © 2016-2020 The Thingsboard Authors
  3 + *
  4 + * Licensed under the Apache License, Version 2.0 (the "License");
  5 + * you may not use this file except in compliance with the License.
  6 + * You may obtain a copy of the License at
  7 + *
  8 + * http://www.apache.org/licenses/LICENSE-2.0
  9 + *
  10 + * Unless required by applicable law or agreed to in writing, software
  11 + * distributed under the License is distributed on an "AS IS" BASIS,
  12 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13 + * See the License for the specific language governing permissions and
  14 + * limitations under the License.
  15 + */
  16 +package org.thingsboard.server.common.data.oauth2;
  17 +
  18 +import lombok.Data;
  19 +import lombok.EqualsAndHashCode;
  20 +import org.thingsboard.server.common.data.BaseData;
  21 +import org.thingsboard.server.common.data.id.OAuth2IntegrationId;
  22 +
  23 +@EqualsAndHashCode(callSuper = true)
  24 +@Data
  25 +public class OAuth2ClientInfo extends BaseData<OAuth2IntegrationId> {
  26 +
  27 + private String name;
  28 + private String icon;
  29 + private String url;
  30 +
  31 + public OAuth2ClientInfo() {
  32 + super();
  33 + }
  34 +
  35 + public OAuth2ClientInfo(OAuth2IntegrationId id) {
  36 + super(id);
  37 + }
  38 +
  39 + public OAuth2ClientInfo(OAuth2ClientInfo oauth2ClientInfo) {
  40 + super(oauth2ClientInfo);
  41 + this.name = oauth2ClientInfo.getName();
  42 + this.icon = oauth2ClientInfo.getIcon();
  43 + this.url = oauth2ClientInfo.getUrl();
  44 + }
  45 +}
... ...
... ... @@ -116,6 +116,10 @@
116 116 <artifactId>spring-web</artifactId>
117 117 <scope>provided</scope>
118 118 </dependency>
  119 + <dependency>
  120 + <groupId>org.springframework.security</groupId>
  121 + <artifactId>spring-security-oauth2-client</artifactId>
  122 + </dependency>
119 123 <dependency>
120 124 <groupId>com.datastax.cassandra</groupId>
121 125 <artifactId>cassandra-driver-core</artifactId>
... ...
  1 +/**
  2 + * Copyright © 2016-2020 The Thingsboard Authors
  3 + *
  4 + * Licensed under the Apache License, Version 2.0 (the "License");
  5 + * you may not use this file except in compliance with the License.
  6 + * You may obtain a copy of the License at
  7 + *
  8 + * http://www.apache.org/licenses/LICENSE-2.0
  9 + *
  10 + * Unless required by applicable law or agreed to in writing, software
  11 + * distributed under the License is distributed on an "AS IS" BASIS,
  12 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13 + * See the License for the specific language governing permissions and
  14 + * limitations under the License.
  15 + */
  16 +package org.thingsboard.server.dao.oauth2;
  17 +
  18 +import lombok.Data;
  19 +
  20 +@Data
  21 +public class OAuth2Client {
  22 +
  23 + private String loginButtonLabel;
  24 + private String loginButtonIcon;
  25 + private String clientName;
  26 + private String clientId;
  27 + private String clientSecret;
  28 + private String accessTokenUri;
  29 + private String authorizationUri;
  30 + private String scope;
  31 + private String redirectUriTemplate;
  32 + private String jwkSetUri;
  33 + private String authorizationGrantType;
  34 + private String clientAuthenticationMethod;
  35 + private String userInfoUri;
  36 + private String userNameAttributeName;
  37 + private OAuth2ClientMapperConfig mapperConfig;
  38 +
  39 +}
... ...
  1 +/**
  2 + * Copyright © 2016-2020 The Thingsboard Authors
  3 + *
  4 + * Licensed under the Apache License, Version 2.0 (the "License");
  5 + * you may not use this file except in compliance with the License.
  6 + * You may obtain a copy of the License at
  7 + *
  8 + * http://www.apache.org/licenses/LICENSE-2.0
  9 + *
  10 + * Unless required by applicable law or agreed to in writing, software
  11 + * distributed under the License is distributed on an "AS IS" BASIS,
  12 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13 + * See the License for the specific language governing permissions and
  14 + * limitations under the License.
  15 + */
  16 +package org.thingsboard.server.dao.oauth2;
  17 +
  18 +import lombok.Data;
  19 +
  20 +@Data
  21 +public class OAuth2ClientMapperConfig {
  22 +
  23 + private String type;
  24 + private BasicOAuth2ClientMapperConfig basic;
  25 + private CustomOAuth2ClientMapperConfig custom;
  26 +
  27 + @Data
  28 + public static class BasicOAuth2ClientMapperConfig {
  29 + private boolean allowUserCreation;
  30 + private String emailAttributeKey;
  31 + private String firstNameAttributeKey;
  32 + private String lastNameAttributeKey;
  33 + private String tenantNameStrategy;
  34 + private String tenantNameStrategyPattern;
  35 + private String customerNameStrategyPattern;
  36 + }
  37 +
  38 + @Data
  39 + public static class CustomOAuth2ClientMapperConfig {
  40 + private String url;
  41 + private String username;
  42 + private String password;
  43 + }
  44 +}
... ...
  1 +/**
  2 + * Copyright © 2016-2020 The Thingsboard Authors
  3 + *
  4 + * Licensed under the Apache License, Version 2.0 (the "License");
  5 + * you may not use this file except in compliance with the License.
  6 + * You may obtain a copy of the License at
  7 + *
  8 + * http://www.apache.org/licenses/LICENSE-2.0
  9 + *
  10 + * Unless required by applicable law or agreed to in writing, software
  11 + * distributed under the License is distributed on an "AS IS" BASIS,
  12 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13 + * See the License for the specific language governing permissions and
  14 + * limitations under the License.
  15 + */
  16 +package org.thingsboard.server.dao.oauth2;
  17 +
  18 +import lombok.Data;
  19 +import lombok.extern.slf4j.Slf4j;
  20 +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
  21 +import org.springframework.boot.context.properties.ConfigurationProperties;
  22 +import org.springframework.context.annotation.Bean;
  23 +import org.springframework.context.annotation.Configuration;
  24 +import org.springframework.security.oauth2.client.registration.ClientRegistration;
  25 +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
  26 +import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository;
  27 +import org.springframework.security.oauth2.core.AuthorizationGrantType;
  28 +import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
  29 +
  30 +import java.util.ArrayList;
  31 +import java.util.HashMap;
  32 +import java.util.List;
  33 +import java.util.Map;
  34 +
  35 +@Configuration
  36 +@ConditionalOnProperty(prefix = "security.oauth2", value = "enabled", havingValue = "true")
  37 +@ConfigurationProperties(prefix = "security.oauth2")
  38 +@Data
  39 +@Slf4j
  40 +public class OAuth2Configuration {
  41 +
  42 + private boolean enabled;
  43 + private String loginProcessingUrl;
  44 + private Map<String, OAuth2Client> clients = new HashMap<>();
  45 +
  46 + @Bean
  47 + public ClientRegistrationRepository clientRegistrationRepository() {
  48 + List<ClientRegistration> result = new ArrayList<>();
  49 + for (Map.Entry<String, OAuth2Client> entry : clients.entrySet()) {
  50 + OAuth2Client client = entry.getValue();
  51 + ClientRegistration registration = ClientRegistration.withRegistrationId(entry.getKey())
  52 + .clientId(client.getClientId())
  53 + .authorizationUri(client.getAuthorizationUri())
  54 + .clientSecret(client.getClientSecret())
  55 + .tokenUri(client.getAccessTokenUri())
  56 + .redirectUriTemplate(client.getRedirectUriTemplate())
  57 + .scope(client.getScope().split(","))
  58 + .clientName(client.getClientName())
  59 + .authorizationGrantType(new AuthorizationGrantType(client.getAuthorizationGrantType()))
  60 + .userInfoUri(client.getUserInfoUri())
  61 + .userNameAttributeName(client.getUserNameAttributeName())
  62 + .jwkSetUri(client.getJwkSetUri())
  63 + .clientAuthenticationMethod(new ClientAuthenticationMethod(client.getClientAuthenticationMethod()))
  64 + .build();
  65 + result.add(registration);
  66 + }
  67 + return new InMemoryClientRegistrationRepository(result);
  68 + }
  69 +
  70 + public OAuth2Client getClientByRegistrationId(String registrationId) {
  71 + OAuth2Client result = null;
  72 + if (clients != null && !clients.isEmpty()) {
  73 + for (String key : clients.keySet()) {
  74 + if (key.equals(registrationId)) {
  75 + result = clients.get(key);
  76 + break;
  77 + }
  78 + }
  79 + }
  80 + return result;
  81 + }
  82 +}
... ...
  1 +/**
  2 + * Copyright © 2016-2020 The Thingsboard Authors
  3 + *
  4 + * Licensed under the Apache License, Version 2.0 (the "License");
  5 + * you may not use this file except in compliance with the License.
  6 + * You may obtain a copy of the License at
  7 + *
  8 + * http://www.apache.org/licenses/LICENSE-2.0
  9 + *
  10 + * Unless required by applicable law or agreed to in writing, software
  11 + * distributed under the License is distributed on an "AS IS" BASIS,
  12 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13 + * See the License for the specific language governing permissions and
  14 + * limitations under the License.
  15 + */
  16 +package org.thingsboard.server.dao.oauth2;
  17 +
  18 +import lombok.extern.slf4j.Slf4j;
  19 +import org.springframework.beans.factory.annotation.Autowired;
  20 +import org.springframework.stereotype.Service;
  21 +import org.thingsboard.server.common.data.oauth2.OAuth2ClientInfo;
  22 +
  23 +import java.util.ArrayList;
  24 +import java.util.Collections;
  25 +import java.util.List;
  26 +import java.util.Map;
  27 +
  28 +@Slf4j
  29 +@Service
  30 +public class OAuth2ServiceImpl implements OAuth2Service {
  31 +
  32 + @Autowired(required = false)
  33 + OAuth2Configuration oauth2Configuration;
  34 +
  35 + @Override
  36 + public List<OAuth2ClientInfo> getOAuth2Clients() {
  37 + if (oauth2Configuration == null || !oauth2Configuration.isEnabled()) {
  38 + return Collections.emptyList();
  39 + }
  40 + List<OAuth2ClientInfo> result = new ArrayList<>();
  41 + for (Map.Entry<String, OAuth2Client> entry : oauth2Configuration.getClients().entrySet()) {
  42 + OAuth2ClientInfo client = new OAuth2ClientInfo();
  43 + client.setName(entry.getValue().getLoginButtonLabel());
  44 + client.setUrl(String.format("/oauth2/authorization/%s", entry.getKey()));
  45 + client.setIcon(entry.getValue().getLoginButtonIcon());
  46 + result.add(client);
  47 + }
  48 + return result;
  49 + }
  50 +}
... ...
... ... @@ -31,6 +31,7 @@
31 31 <main.dir>${basedir}</main.dir>
32 32 <pkg.user>thingsboard</pkg.user>
33 33 <spring-boot.version>2.2.4.RELEASE</spring-boot.version>
  34 + <spring-oauth2.version>2.1.2.RELEASE</spring-oauth2.version>
34 35 <spring.version>5.2.2.RELEASE</spring.version>
35 36 <spring-security.version>5.2.2.RELEASE</spring-security.version>
36 37 <spring-data-redis.version>2.2.4.RELEASE</spring-data-redis.version>
... ... @@ -463,6 +464,21 @@
463 464 <version>${spring-boot.version}</version>
464 465 </dependency>
465 466 <dependency>
  467 + <groupId>org.springframework.cloud</groupId>
  468 + <artifactId>spring-cloud-starter-oauth2</artifactId>
  469 + <version>${spring-oauth2.version}</version>
  470 + </dependency>
  471 + <dependency>
  472 + <groupId>org.springframework.security</groupId>
  473 + <artifactId>spring-security-oauth2-client</artifactId>
  474 + <version>${spring.version}</version>
  475 + </dependency>
  476 + <dependency>
  477 + <groupId>org.springframework.security</groupId>
  478 + <artifactId>spring-security-oauth2-jose</artifactId>
  479 + <version>${spring.version}</version>
  480 + </dependency>
  481 + <dependency>
466 482 <groupId>org.springframework.boot</groupId>
467 483 <artifactId>spring-boot-starter-web</artifactId>
468 484 <version>${spring-boot.version}</version>
... ...
... ... @@ -21,7 +21,7 @@ import { HttpClient } from '@angular/common/http';
21 21 import { forkJoin, Observable, of, throwError } from 'rxjs';
22 22 import { catchError, map, mergeMap, tap } from 'rxjs/operators';
23 23
24   -import { LoginRequest, LoginResponse, PublicLoginRequest } from '@shared/models/login.models';
  24 +import { LoginRequest, LoginResponse, OAuth2Client, PublicLoginRequest } from '@shared/models/login.models';
25 25 import { ActivatedRoute, Router, UrlTree } from '@angular/router';
26 26 import { defaultHttpOptions } from '../http/http-utils';
27 27 import { ReplaySubject } from 'rxjs/internal/ReplaySubject';
... ... @@ -65,6 +65,7 @@ export class AuthService {
65 65 }
66 66
67 67 redirectUrl: string;
  68 + oauth2Clients: Array<OAuth2Client> = null;
68 69
69 70 private refreshTokenSubject: ReplaySubject<LoginResponse> = null;
70 71 private jwtHelper = new JwtHelperService();
... ... @@ -193,6 +194,15 @@ export class AuthService {
193 194 });
194 195 }
195 196
  197 + public loadOAuth2Clients(): Observable<Array<OAuth2Client>> {
  198 + return this.http.post<Array<OAuth2Client>>(`/api/noauth/oauth2Clients`,
  199 + null, defaultHttpOptions()).pipe(
  200 + tap((OAuth2Clients) => {
  201 + this.oauth2Clients = OAuth2Clients;
  202 + })
  203 + );
  204 + }
  205 +
196 206 private forceDefaultPlace(authState?: AuthState, path?: string, params?: any): boolean {
197 207 if (authState && authState.authUser) {
198 208 if (authState.authUser.authority === Authority.TENANT_ADMIN || authState.authUser.authority === Authority.CUSTOMER_USER) {
... ...
... ... @@ -20,9 +20,9 @@ import { AuthService } from '../auth/auth.service';
20 20 import { select, Store } from '@ngrx/store';
21 21 import { AppState } from '../core.state';
22 22 import { selectAuth } from '../auth/auth.selectors';
23   -import { catchError, map, skipWhile, take } from 'rxjs/operators';
  23 +import { catchError, map, mergeMap, skipWhile, take } from 'rxjs/operators';
24 24 import { AuthState } from '../auth/auth.models';
25   -import { Observable, of } from 'rxjs';
  25 +import { forkJoin, Observable, of } from 'rxjs';
26 26 import { enterZone } from '@core/operator/enterZone';
27 27 import { Authority } from '@shared/models/authority.enum';
28 28 import { DialogService } from '@core/services/dialog.service';
... ... @@ -54,7 +54,7 @@ export class AuthGuard implements CanActivate, CanActivateChild {
54 54 state: RouterStateSnapshot) {
55 55
56 56 return this.getAuthState().pipe(
57   - map((authState) => {
  57 + mergeMap((authState) => {
58 58 const url: string = state.url;
59 59
60 60 let lastChild = state.root;
... ... @@ -78,13 +78,21 @@ export class AuthGuard implements CanActivate, CanActivateChild {
78 78 if (publicId && publicId.length > 0) {
79 79 this.authService.setUserFromJwtToken(null, null, false);
80 80 this.authService.reloadUser();
81   - return false;
  81 + return of(false);
82 82 } else if (!isPublic) {
83 83 this.authService.redirectUrl = url;
84 84 // this.authService.gotoDefaultPlace(false);
85   - return this.authService.defaultUrl(false);
  85 + return of(this.authService.defaultUrl(false));
86 86 } else {
87   - return true;
  87 + const tasks: Observable<any>[] = [];
  88 + if (path === 'login') {
  89 + tasks.push(this.authService.loadOAuth2Clients());
  90 + }
  91 + return forkJoin(tasks).pipe(
  92 + map(() => {
  93 + return true;
  94 + })
  95 + );
88 96 }
89 97 } else {
90 98 if (authState.authUser.isPublic) {
... ... @@ -95,20 +103,20 @@ export class AuthGuard implements CanActivate, CanActivateChild {
95 103 } else {
96 104 this.authService.logout();
97 105 }
98   - return false;
  106 + return of(false);
99 107 }
100 108 }
101 109 const defaultUrl = this.authService.defaultUrl(true, authState, path, params);
102 110 if (defaultUrl) {
103 111 // this.authService.gotoDefaultPlace(true);
104   - return defaultUrl;
  112 + return of(defaultUrl);
105 113 } else {
106 114 const authority = Authority[authState.authUser.authority];
107 115 if (data.auth && data.auth.indexOf(authority) === -1) {
108 116 this.dialogService.forbidden();
109   - return false;
  117 + return of(false);
110 118 } else {
111   - return true;
  119 + return of(true);
112 120 }
113 121 }
114 122 }
... ...
... ... @@ -16,7 +16,7 @@
16 16
17 17 -->
18 18 <div class="tb-login-content mat-app-background tb-dark" fxFlex fxLayoutAlign="center center">
19   - <mat-card style="height: 100%; max-height: 525px; overflow-y: auto;">
  19 + <mat-card style="max-height: 80vh; overflow-y: auto;">
20 20 <mat-card-content>
21 21 <form class="tb-login-form" [formGroup]="loginFormGroup" (ngSubmit)="login()">
22 22 <fieldset [disabled]="isLoading$ | async" fxLayout="column">
... ... @@ -49,6 +49,19 @@
49 49 <button mat-raised-button color="accent" [disabled]="(isLoading$ | async)"
50 50 type="submit">{{ 'login.login' | translate }}</button>
51 51 </div>
  52 + <div class="oauth-container" fxLayout="column" fxLayoutGap="16px" *ngIf="oauth2Clients?.length">
  53 + <div class="container-divider">
  54 + <div class="line"><mat-divider></mat-divider></div>
  55 + <div class="text mat-typography">{{ "login.or" | translate | uppercase }}</div>
  56 + <div class="line"><mat-divider></mat-divider></div>
  57 + </div>
  58 + <ng-container *ngFor="let oauth2Client of oauth2Clients">
  59 + <button mat-raised-button color="primary" class="centered" routerLink="{{ oauth2Client.url }}">
  60 + <mat-icon class="material-icons md-18" svgIcon="{{ oauth2Client.icon }}"></mat-icon>
  61 + {{ 'login.login-with' | translate: {name: oauth2Client.name} }}
  62 + </button>
  63 + </ng-container>
  64 + </div>
52 65 </div>
53 66 </fieldset>
54 67 </form>
... ...
... ... @@ -32,8 +32,44 @@
32 32 }
33 33
34 34 .tb-action-button{
35   - padding-top: 20px;
36   - padding-bottom: 20px;
  35 + padding: 20px 0 16px;
  36 + }
  37 + }
  38 +
  39 + .oauth-container{
  40 + padding: 0;
  41 +
  42 + .container-divider {
  43 + display: flex;
  44 + flex-direction: row;
  45 + align-items: center;
  46 + justify-content: center;
  47 + width: 100%;
  48 +
  49 + .line {
  50 + flex: 1;
  51 + }
  52 +
  53 + .mat-divider-horizontal{
  54 + position: relative;
  55 + }
  56 +
  57 + .text {
  58 + padding-right: 10px;
  59 + padding-left: 10px;
  60 + }
  61 + }
  62 +
  63 + .material-icons{
  64 + width: 20px;
  65 + min-width: 20px;
  66 + }
  67 +
  68 +
  69 + .centered ::ng-deep .mat-button-wrapper {
  70 + display: flex;
  71 + justify-content: center;
  72 + align-items: center;
37 73 }
38 74 }
39 75 }
... ...
... ... @@ -23,6 +23,7 @@ import { FormBuilder } from '@angular/forms';
23 23 import { HttpErrorResponse } from '@angular/common/http';
24 24 import { Constants } from '@shared/models/constants';
25 25 import { Router } from '@angular/router';
  26 +import { OAuth2Client } from '@shared/models/login.models';
26 27
27 28 @Component({
28 29 selector: 'tb-login',
... ... @@ -35,6 +36,7 @@ export class LoginComponent extends PageComponent implements OnInit {
35 36 username: '',
36 37 password: ''
37 38 });
  39 + oauth2Clients: Array<OAuth2Client> = null;
38 40
39 41 constructor(protected store: Store<AppState>,
40 42 private authService: AuthService,
... ... @@ -44,6 +46,7 @@ export class LoginComponent extends PageComponent implements OnInit {
44 46 }
45 47
46 48 ngOnInit() {
  49 + this.oauth2Clients = this.authService.oauth2Clients;
47 50 }
48 51
49 52 login(): void {
... ...
... ... @@ -27,3 +27,9 @@ export interface LoginResponse {
27 27 token: string;
28 28 refreshToken: string;
29 29 }
  30 +
  31 +export interface OAuth2Client {
  32 + name: string;
  33 + icon?: string;
  34 + url: string;
  35 +}
... ...
... ... @@ -1137,7 +1137,7 @@
1137 1137 "total": "celkem"
1138 1138 },
1139 1139 "login": {
1140   - "login": "Přihlásit",
  1140 + "login": "Přihlásit se",
1141 1141 "request-password-reset": "Vyžádat reset hesla",
1142 1142 "reset-password": "Reset hesla",
1143 1143 "create-password": "Vytvořit heslo",
... ... @@ -1151,7 +1151,9 @@
1151 1151 "new-password": "Nové heslo",
1152 1152 "new-password-again": "Nové heslo znovu",
1153 1153 "password-link-sent-message": "Odkaz pro reset hesla byl úspěšně odeslán!",
1154   - "email": "Email"
  1154 + "email": "Email",
  1155 + "login-with": "Přihlásit se přes {{name}}",
  1156 + "or": "nebo"
1155 1157 },
1156 1158 "position": {
1157 1159 "top": "Nahoře",
... ...
... ... @@ -1150,7 +1150,7 @@
1150 1150 "total": "Gesamt"
1151 1151 },
1152 1152 "login": {
1153   - "login": "Login",
  1153 + "login": "Anmelden",
1154 1154 "request-password-reset": "Passwortzurücksetzung anfordern",
1155 1155 "reset-password": "Passwort zurücksetzen",
1156 1156 "create-password": "Passwort erstellen",
... ... @@ -1164,7 +1164,9 @@
1164 1164 "new-password": "Neues Passwort",
1165 1165 "new-password-again": "Neues Passwort wiederholen",
1166 1166 "password-link-sent-message": "Der Link zum Zurücksetzen des Passworts wurde erfolgreich versendet!",
1167   - "email": "E-Mail"
  1167 + "email": "E-Mail",
  1168 + "login-with": "Mit {{name}} anmelden",
  1169 + "or": "oder"
1168 1170 },
1169 1171 "position": {
1170 1172 "top": "Oben",
... ...
... ... @@ -1706,7 +1706,9 @@
1706 1706 "password-link-sent-message": "Ο σύνδεσμος επαναφοράς κωδικού πρόσβασης στάλθηκε με επιτυχία!",
1707 1707 "email": "Email",
1708 1708 "no-account": "Δεν έχετε λογαριασμό;",
1709   - "create-account": "Δημιουργία λογαριασμού"
  1709 + "create-account": "Δημιουργία λογαριασμού",
  1710 + "login-with": "Σύνδεση μέσω {{name}}",
  1711 + "or": "ή"
1710 1712 },
1711 1713 "signup": {
1712 1714 "firstname": "Όνομα",
... ...
... ... @@ -1353,7 +1353,9 @@
1353 1353 "new-password": "New password",
1354 1354 "new-password-again": "New password again",
1355 1355 "password-link-sent-message": "Password reset link was successfully sent!",
1356   - "email": "Email"
  1356 + "email": "Email",
  1357 + "login-with": "Login with {{name}}",
  1358 + "or": "or"
1357 1359 },
1358 1360 "position": {
1359 1361 "top": "Top",
... ...
... ... @@ -1230,7 +1230,9 @@
1230 1230 "new-password": "Nueva contraseña",
1231 1231 "new-password-again": "Repita la nueva contraseña",
1232 1232 "password-link-sent-message": "¡El enlace para el restablecer la contraseña fue enviado correctamente!",
1233   - "email": "Correo electrónico"
  1233 + "email": "Correo electrónico",
  1234 + "login-with": "Iniciar sesión con {{name}}",
  1235 + "or": "o"
1234 1236 },
1235 1237 "position": {
1236 1238 "top": "Superior",
... ...
... ... @@ -1198,7 +1198,7 @@
1198 1198 "create-password": "Créer un mot de passe",
1199 1199 "email": "Email",
1200 1200 "forgot-password": "Mot de passe oublié?",
1201   - "login": "Login",
  1201 + "login": "Connexion",
1202 1202 "new-password": "Nouveau mot de passe",
1203 1203 "new-password-again": "nouveau mot de passe",
1204 1204 "password-again": "Mot de passe à nouveau",
... ... @@ -1209,7 +1209,9 @@
1209 1209 "request-password-reset": "Demander la réinitialisation du mot de passe",
1210 1210 "reset-password": "Réinitialiser le mot de passe",
1211 1211 "sign-in": "Veuillez vous connecter",
1212   - "username": "Nom d'utilisateur (courriel)"
  1212 + "username": "Nom d'utilisateur (courriel)",
  1213 + "login-with": "Se connecter avec {{name}}",
  1214 + "or": "ou"
1213 1215 },
1214 1216 "position": {
1215 1217 "bottom": "Bas",
... ...
... ... @@ -1161,7 +1161,7 @@
1161 1161 "total": "totale"
1162 1162 },
1163 1163 "login": {
1164   - "login": "Login",
  1164 + "login": "Accedi",
1165 1165 "request-password-reset": "Richiesta reset password",
1166 1166 "reset-password": "Reset Password",
1167 1167 "create-password": "Crea Password",
... ... @@ -1175,7 +1175,9 @@
1175 1175 "new-password": "Nuova password",
1176 1176 "new-password-again": "Ripeti nuova password",
1177 1177 "password-link-sent-message": "Link reset password inviato con successo!",
1178   - "email": "Email"
  1178 + "email": "Email",
  1179 + "login-with": "Accedi con {{name}}",
  1180 + "or": "o"
1179 1181 },
1180 1182 "position": {
1181 1183 "top": "Alto",
... ...
... ... @@ -1025,7 +1025,9 @@
1025 1025 "new-password": "新しいパスワード",
1026 1026 "new-password-again": "新しいパスワードを再入力",
1027 1027 "password-link-sent-message": "パスワードリセットリンクが正常に送信されました!",
1028   - "email": "Eメール"
  1028 + "email": "Eメール",
  1029 + "login-with": "{{name}}でログイン",
  1030 + "or": "または"
1029 1031 },
1030 1032 "position": {
1031 1033 "top": "上",
... ...
... ... @@ -934,7 +934,9 @@
934 934 "new-password": "새 비밀번호",
935 935 "new-password-again": "새 비밀번호 확인",
936 936 "password-link-sent-message": "비밀번호 재설정 링크가 성공적으로 전송되었습니다!",
937   - "email": "이메일"
  937 + "email": "이메일",
  938 + "login-with": "{{name}}으로 로그인",
  939 + "or": "또는"
938 940 },
939 941 "position": {
940 942 "top": "상단",
... ...
... ... @@ -1231,7 +1231,7 @@
1231 1231 }
1232 1232 },
1233 1233 "login": {
1234   - "login": "Intră în Cont",
  1234 + "login": "Conectare",
1235 1235 "request-password-reset": "Solicită Resetarea Parolei",
1236 1236 "reset-password": "Resetează Parolă",
1237 1237 "create-password": "Creează Parolă",
... ... @@ -1246,7 +1246,9 @@
1246 1246 "new-password": "Parolă nouă",
1247 1247 "new-password-again": "Verificare parolă nouă",
1248 1248 "password-link-sent-message": "Ți-am trimis pe eMail un link pentru resetarea parolei",
1249   - "email": "eMail"
  1249 + "email": "eMail",
  1250 + "login-with": "Conectare cu {{name}}",
  1251 + "or": "sau"
1250 1252 },
1251 1253 "position": {
1252 1254 "top": "Sus",
... ...
... ... @@ -1247,7 +1247,9 @@
1247 1247 "new-password": "Новый пароль",
1248 1248 "new-password-again": "Повторите новый пароль",
1249 1249 "password-link-sent-message": "Ссылка для сброса пароля была успешно отправлена!",
1250   - "email": "Эл. адрес"
  1250 + "email": "Эл. адрес",
  1251 + "login-with": "Войти через {{name}}",
  1252 + "or": "или"
1251 1253 },
1252 1254 "position": {
1253 1255 "top": "Верх",
... ...
... ... @@ -1091,7 +1091,7 @@
1091 1091 "total": "toplam"
1092 1092 },
1093 1093 "login": {
1094   - "login": "Oturum aç",
  1094 + "login": "Giriş Yap",
1095 1095 "request-password-reset": "Parola Sıfırlama İsteği Gönder",
1096 1096 "reset-password": "Parola Sıfırla",
1097 1097 "create-password": "Parola Oluştur",
... ... @@ -1105,7 +1105,9 @@
1105 1105 "new-password": "Yeni parola",
1106 1106 "new-password-again": "Yeni parola tekrarı",
1107 1107 "password-link-sent-message": "Parola sıfırlama e-postası başarıyla gönderildi!",
1108   - "email": "E-posta"
  1108 + "email": "E-posta",
  1109 + "login-with": "{{name}} ile Giriş Yap",
  1110 + "or": "ya da"
1109 1111 },
1110 1112 "position": {
1111 1113 "top": "Üst",
... ...
... ... @@ -1647,7 +1647,7 @@
1647 1647 }
1648 1648 },
1649 1649 "login": {
1650   - "login": "Вхід",
  1650 + "login": "Увійти",
1651 1651 "request-password-reset": "Запит скидання пароля",
1652 1652 "reset-password": "Скинути пароль",
1653 1653 "create-password": "Створити пароль",
... ... @@ -1662,7 +1662,9 @@
1662 1662 "new-password": "Новий пароль",
1663 1663 "new-password-again": "Повторіть новий пароль",
1664 1664 "password-link-sent-message": "Посилання для скидання пароля було успішно надіслано!",
1665   - "email": "Електронна пошта"
  1665 + "email": "Електронна пошта",
  1666 + "login-with": "Увійти через {{name}}",
  1667 + "or": "або"
1666 1668 },
1667 1669 "position": {
1668 1670 "top": "Угорі",
... ...
... ... @@ -18,7 +18,7 @@ export default angular.module('thingsboard.api.login', [])
18 18 .name;
19 19
20 20 /*@ngInject*/
21   -function LoginService($http, $q) {
  21 +function LoginService($http, $q, $rootScope) {
22 22
23 23 var service = {
24 24 activate: activate,
... ... @@ -28,6 +28,7 @@ function LoginService($http, $q) {
28 28 publicLogin: publicLogin,
29 29 resetPassword: resetPassword,
30 30 sendResetPasswordLink: sendResetPasswordLink,
  31 + loadOAuth2Clients: loadOAuth2Clients
31 32 }
32 33
33 34 return service;
... ... @@ -109,4 +110,16 @@ function LoginService($http, $q) {
109 110 });
110 111 return deferred.promise;
111 112 }
  113 +
  114 + function loadOAuth2Clients(){
  115 + var deferred = $q.defer();
  116 + var url = '/api/noauth/oauth2Clients';
  117 + $http.post(url).then(function success(response) {
  118 + $rootScope.oauth2Clients = response.data;
  119 + deferred.resolve();
  120 + }, function fail() {
  121 + deferred.reject();
  122 + });
  123 + return deferred.promise;
  124 + }
112 125 }
... ...
... ... @@ -17,7 +17,7 @@ import Flow from '@flowjs/ng-flow/dist/ng-flow-standalone.min';
17 17 import UrlHandler from './url.handler';
18 18
19 19 /*@ngInject*/
20   -export default function AppRun($rootScope, $window, $injector, $location, $log, $state, $mdDialog, $filter, loginService, userService, $translate) {
  20 +export default function AppRun($rootScope, $window, $injector, $location, $log, $state, $mdDialog, $filter, $q, loginService, userService, $translate) {
21 21
22 22 $window.Flow = Flow;
23 23 var frame = null;
... ... @@ -41,11 +41,13 @@ export default function AppRun($rootScope, $window, $injector, $location, $log,
41 41 }
42 42
43 43 initWatchers();
44   -
  44 +
  45 + var skipStateChange = false;
  46 +
45 47 function initWatchers() {
46 48 $rootScope.unauthenticatedHandle = $rootScope.$on('unauthenticated', function (event, doLogout) {
47 49 if (doLogout) {
48   - $state.go('login');
  50 + gotoPublicModule('login');
49 51 } else {
50 52 UrlHandler($injector, $location);
51 53 }
... ... @@ -61,6 +63,11 @@ export default function AppRun($rootScope, $window, $injector, $location, $log,
61 63
62 64 $rootScope.stateChangeStartHandle = $rootScope.$on('$stateChangeStart', function (evt, to, params) {
63 65
  66 + if (skipStateChange) {
  67 + skipStateChange = false;
  68 + return;
  69 + }
  70 +
64 71 function waitForUserLoaded() {
65 72 if ($rootScope.userLoadedHandle) {
66 73 $rootScope.userLoadedHandle();
... ... @@ -128,7 +135,10 @@ export default function AppRun($rootScope, $window, $injector, $location, $log,
128 135 redirectParams.toName = to.name;
129 136 redirectParams.params = params;
130 137 userService.setRedirectParams(redirectParams);
131   - $state.go('login', params);
  138 + gotoPublicModule('login', params);
  139 + } else {
  140 + evt.preventDefault();
  141 + gotoPublicModule(to.name, params);
132 142 }
133 143 }
134 144 } else {
... ... @@ -158,6 +168,23 @@ export default function AppRun($rootScope, $window, $injector, $location, $log,
158 168 userService.gotoDefaultPlace(params);
159 169 }
160 170
  171 + function gotoPublicModule(name, params) {
  172 + let tasks = [];
  173 + if (name === "login") {
  174 + tasks.push(loginService.loadOAuth2Clients());
  175 + }
  176 + $q.all(tasks).then(
  177 + () => {
  178 + skipStateChange = true;
  179 + $state.go(name, params);
  180 + },
  181 + () => {
  182 + skipStateChange = true;
  183 + $state.go(name, params);
  184 + }
  185 + );
  186 + }
  187 +
161 188 function showForbiddenDialog() {
162 189 if (forbiddenDialog === null) {
163 190 $translate(['access.access-forbidden',
... ...
... ... @@ -1136,7 +1136,7 @@
1136 1136 "total": "celkem"
1137 1137 },
1138 1138 "login": {
1139   - "login": "Přihlásit",
  1139 + "login": "Přihlásit se",
1140 1140 "request-password-reset": "Vyžádat reset hesla",
1141 1141 "reset-password": "Reset hesla",
1142 1142 "create-password": "Vytvořit heslo",
... ... @@ -1150,7 +1150,9 @@
1150 1150 "new-password": "Nové heslo",
1151 1151 "new-password-again": "Nové heslo znovu",
1152 1152 "password-link-sent-message": "Odkaz pro reset hesla byl úspěšně odeslán!",
1153   - "email": "Email"
  1153 + "email": "Email",
  1154 + "login-with": "Přihlásit se přes {{name}}",
  1155 + "or": "nebo"
1154 1156 },
1155 1157 "position": {
1156 1158 "top": "Nahoře",
... ...
... ... @@ -1317,7 +1317,7 @@
1317 1317 }
1318 1318 },
1319 1319 "login": {
1320   - "login": "Login",
  1320 + "login": "Log in",
1321 1321 "request-password-reset": "Request Password Reset",
1322 1322 "reset-password": "Reset Password",
1323 1323 "create-password": "Create Password",
... ... @@ -1332,7 +1332,9 @@
1332 1332 "new-password": "New password",
1333 1333 "new-password-again": "New password again",
1334 1334 "password-link-sent-message": "Password reset link was successfully sent!",
1335   - "email": "Email"
  1335 + "email": "Email",
  1336 + "login-with": "Login with {{name}}",
  1337 + "or": "or"
1336 1338 },
1337 1339 "position": {
1338 1340 "top": "Top",
... ...
... ... @@ -1246,7 +1246,9 @@
1246 1246 "new-password": "Новый пароль",
1247 1247 "new-password-again": "Повторите новый пароль",
1248 1248 "password-link-sent-message": "Ссылка для сброса пароля была успешно отправлена!",
1249   - "email": "Эл. адрес"
  1249 + "email": "Эл. адрес",
  1250 + "login-with": "Войти через {{name}}",
  1251 + "or": "или"
1250 1252 },
1251 1253 "position": {
1252 1254 "top": "Верх",
... ...
... ... @@ -1646,7 +1646,7 @@
1646 1646 }
1647 1647 },
1648 1648 "login": {
1649   - "login": "Вхід",
  1649 + "login": "Увійти",
1650 1650 "request-password-reset": "Запит скидання пароля",
1651 1651 "reset-password": "Скинути пароль",
1652 1652 "create-password": "Створити пароль",
... ... @@ -1661,7 +1661,9 @@
1661 1661 "new-password": "Новий пароль",
1662 1662 "new-password-again": "Повторіть новий пароль",
1663 1663 "password-link-sent-message": "Посилання для скидання пароля було успішно надіслано!",
1664   - "email": "Електронна пошта"
  1664 + "email": "Електронна пошта",
  1665 + "login-with": "Увійти через {{name}}",
  1666 + "or": "або"
1665 1667 },
1666 1668 "position": {
1667 1669 "top": "Угорі",
... ...
... ... @@ -22,6 +22,10 @@ md-card.tb-login-card {
22 22 width: 450px !important;
23 23 }
24 24
  25 + .tb-padding {
  26 + padding: 8px;
  27 + }
  28 +
25 29 md-card-title {
26 30 img.tb-login-logo {
27 31 height: 50px;
... ... @@ -31,4 +35,36 @@ md-card.tb-login-card {
31 35 md-card-content {
32 36 margin-top: -50px;
33 37 }
  38 +
  39 + md-input-container .md-errors-spacer {
  40 + display: none;
  41 + }
  42 +
  43 + .oauth-container{
  44 + .container-divider {
  45 + display: flex;
  46 + flex-direction: row;
  47 + align-items: center;
  48 + justify-content: center;
  49 + width: 100%;
  50 + margin: 10px 0;
  51 +
  52 + .line {
  53 + flex: 1;
  54 + }
  55 +
  56 + .text {
  57 + padding-right: 10px;
  58 + padding-left: 10px;
  59 + }
  60 + }
  61 +
  62 + .material-icons{
  63 + width: 20px;
  64 + min-width: 20px;
  65 + height: 20px;
  66 + min-height: 20px;
  67 + margin: 0 4px;
  68 + }
  69 + }
34 70 }
... ...
... ... @@ -24,7 +24,7 @@
24 24 md-mode="indeterminate" ng-disabled="!$root.loading" ng-show="$root.loading"></md-progress-linear>
25 25 <md-card-content>
26 26 <form class="login-form" ng-submit="vm.login()">
27   - <div layout="column" layout-padding="" id="toast-parent">
  27 + <div layout="column" class="tb-padding" id="toast-parent">
28 28 <span style="height: 50px;"></span>
29 29 <md-input-container class="md-block">
30 30 <label translate>login.username</label>
... ... @@ -40,13 +40,23 @@
40 40 </md-icon>
41 41 <input id="password-input" type="password" ng-model="vm.user.password"/>
42 42 </md-input-container>
43   - <div layout-gt-sm="column" layout-align="space-between stretch">
44   - <div layout-gt-sm="column" layout-align="space-between end">
45   - <md-button ui-sref="login.resetPasswordRequest">{{ 'login.forgot-password' | translate }}
46   - </md-button>
47   - </div>
  43 + <div layout-gt-sm="column" layout-align="center end" class="tb-padding">
  44 + <md-button ui-sref="login.resetPasswordRequest">{{ 'login.forgot-password' | translate }}
  45 + </md-button>
48 46 </div>
49 47 <md-button class="md-raised" type="submit">{{ 'login.login' | translate }}</md-button>
  48 + <div class="oauth-container" layout="column" ng-if="oauth2Clients.length">
  49 + <div class="container-divider">
  50 + <div class="line"><md-divider></md-divider></div>
  51 + <div class="text mat-typography">{{ "login.or" | translate | uppercase }}</div>
  52 + <div class="line"><md-divider></md-divider></div>
  53 + </div>
  54 + <md-button ng-repeat="oauth2Client in oauth2Clients" class="md-raised"
  55 + layout="row" layout-align="center center" ng-href="{{ oauth2Client.url }}" target="_self">
  56 + <md-icon class="material-icons md-18" md-svg-icon="{{ oauth2Client.icon }}"></md-icon>
  57 + {{ 'login.login-with' | translate: {name: oauth2Client.name} }}
  58 + </md-button>
  59 + </div>
50 60 </div>
51 61 </form>
52 62 </md-card-content>
... ...