From bc6efa5e1e339b07e8b20ac2b59d26eb2713cd4f Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Mon, 15 Mar 2021 17:11:23 +0200 Subject: [PATCH] [3.3] [PROD-685] Provide user's session expiration when his auth data is changed (#4201) * Provide user's session expiration when his auth data is changed * Provide mock TokenOutdatingService for dao tests * Increase time gap when checking if token is outdated * Add license header for TokenOutdatingTest * Refactor tokens outdating functionality to events usage * Reset tokens on front-end after changing password --- .../server/config/JwtSettings.java | 2 +- .../server/controller/AuthController.java | 61 +++--- .../server/controller/UserController.java | 28 +-- .../security/auth/TokenOutdatingService.java | 85 ++++++++ .../auth/jwt/JwtAuthenticationProvider.java | 17 +- .../RefreshTokenAuthenticationProvider.java | 21 +- .../auth/jwt/RefreshTokenRepository.java | 2 +- .../Oauth2AuthenticationSuccessHandler.java | 3 +- ...RestAwareAuthenticationSuccessHandler.java | 2 +- .../exception/JwtExpiredTokenException.java | 2 +- .../security/model/token/AccessJwtToken.java | 1 + .../security/model/token/JwtTokenFactory.java | 26 ++- .../model/token/RawAccessJwtToken.java | 33 +--- .../src/main/resources/thingsboard.yml | 3 + .../security/auth/TokenOutdatingTest.java | 184 ++++++++++++++++++ .../server/common/data/CacheConstants.java | 1 + .../event/UserAuthDataChangedEvent.java | 30 +++ .../common/data/security/model}/JwtToken.java | 2 +- .../server/dao/user/UserServiceImpl.java | 39 ++-- ui-ngx/src/app/core/auth/auth.service.ts | 7 +- 20 files changed, 427 insertions(+), 122 deletions(-) create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/TokenOutdatingService.java create mode 100644 application/src/test/java/org/thingsboard/server/service/security/auth/TokenOutdatingTest.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/security/event/UserAuthDataChangedEvent.java rename {application/src/main/java/org/thingsboard/server/service/security/model/token => common/data/src/main/java/org/thingsboard/server/common/data/security/model}/JwtToken.java (92%) diff --git a/application/src/main/java/org/thingsboard/server/config/JwtSettings.java b/application/src/main/java/org/thingsboard/server/config/JwtSettings.java index 6e19cfb729..04a6f5a9b4 100644 --- a/application/src/main/java/org/thingsboard/server/config/JwtSettings.java +++ b/application/src/main/java/org/thingsboard/server/config/JwtSettings.java @@ -17,7 +17,7 @@ package org.thingsboard.server.config; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Configuration; -import org.thingsboard.server.service.security.model.token.JwtToken; +import org.thingsboard.server.common.data.security.model.JwtToken; @Configuration @ConfigurationProperties(prefix = "security.jwt") diff --git a/application/src/main/java/org/thingsboard/server/controller/AuthController.java b/application/src/main/java/org/thingsboard/server/controller/AuthController.java index 3f77da93f7..d281de9009 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AuthController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AuthController.java @@ -18,8 +18,9 @@ package org.thingsboard.server.controller; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -32,6 +33,7 @@ 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.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.api.MailService; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.audit.ActionType; @@ -39,48 +41,37 @@ 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.security.UserCredentials; +import org.thingsboard.server.common.data.security.event.UserAuthDataChangedEvent; +import org.thingsboard.server.common.data.security.model.JwtToken; +import org.thingsboard.server.common.data.security.model.SecuritySettings; +import org.thingsboard.server.common.data.security.model.UserPasswordPolicy; import org.thingsboard.server.dao.audit.AuditLogService; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.security.auth.jwt.RefreshTokenRepository; import org.thingsboard.server.service.security.auth.rest.RestAuthenticationDetails; -import org.thingsboard.server.common.data.security.model.SecuritySettings; import org.thingsboard.server.service.security.model.SecurityUser; -import org.thingsboard.server.common.data.security.model.UserPasswordPolicy; import org.thingsboard.server.service.security.model.UserPrincipal; -import org.thingsboard.server.service.security.model.token.JwtToken; import org.thingsboard.server.service.security.model.token.JwtTokenFactory; import org.thingsboard.server.service.security.system.SystemSecurityService; -import org.thingsboard.server.utils.MiscUtils; import ua_parser.Client; import javax.servlet.http.HttpServletRequest; import java.net.URI; import java.net.URISyntaxException; -import java.util.List; @RestController @TbCoreComponent @RequestMapping("/api") @Slf4j +@RequiredArgsConstructor public class AuthController extends BaseController { - - @Autowired - private BCryptPasswordEncoder passwordEncoder; - - @Autowired - private JwtTokenFactory tokenFactory; - - @Autowired - private RefreshTokenRepository refreshTokenRepository; - - @Autowired - private MailService mailService; - - @Autowired - private SystemSecurityService systemSecurityService; - - @Autowired - private AuditLogService auditLogService; + private final BCryptPasswordEncoder passwordEncoder; + private final JwtTokenFactory tokenFactory; + private final RefreshTokenRepository refreshTokenRepository; + private final MailService mailService; + private final SystemSecurityService systemSecurityService; + private final AuditLogService auditLogService; + private final ApplicationEventPublisher eventPublisher; @PreAuthorize("isAuthenticated()") @RequestMapping(value = "/auth/user", method = RequestMethod.GET) @@ -103,8 +94,7 @@ public class AuthController extends BaseController { @PreAuthorize("isAuthenticated()") @RequestMapping(value = "/auth/changePassword", method = RequestMethod.POST) @ResponseStatus(value = HttpStatus.OK) - public void changePassword ( - @RequestBody JsonNode changePasswordRequest) throws ThingsboardException { + public ObjectNode changePassword(@RequestBody JsonNode changePasswordRequest) throws ThingsboardException { try { String currentPassword = changePasswordRequest.get("currentPassword").asText(); String newPassword = changePasswordRequest.get("newPassword").asText(); @@ -119,6 +109,12 @@ public class AuthController extends BaseController { } userCredentials.setPassword(passwordEncoder.encode(newPassword)); userService.replaceUserCredentials(securityUser.getTenantId(), userCredentials); + + eventPublisher.publishEvent(new UserAuthDataChangedEvent(securityUser.getId())); + ObjectNode response = JacksonUtil.newObjectNode(); + response.put("token", tokenFactory.createAccessJwtToken(securityUser).getToken()); + response.put("refreshToken", tokenFactory.createRefreshToken(securityUser).getToken()); + return response; } catch (Exception e) { throw handleException(e); } @@ -135,7 +131,7 @@ public class AuthController extends BaseController { throw handleException(e); } } - + @RequestMapping(value = "/noauth/activate", params = { "activateToken" }, method = RequestMethod.GET) public ResponseEntity checkActivateToken( @RequestParam(value = "activateToken") String activateToken) { @@ -157,7 +153,7 @@ public class AuthController extends BaseController { } return new ResponseEntity<>(headers, responseStatus); } - + @RequestMapping(value = "/noauth/resetPasswordByEmail", method = RequestMethod.POST) @ResponseStatus(value = HttpStatus.OK) public void requestResetPasswordByEmail ( @@ -170,13 +166,13 @@ public class AuthController extends BaseController { String baseUrl = systemSecurityService.getBaseUrl(user.getTenantId(), user.getCustomerId(), request); String resetUrl = String.format("%s/api/noauth/resetPassword?resetToken=%s", baseUrl, userCredentials.getResetToken()); - + mailService.sendResetPasswordEmail(resetUrl, email); } catch (Exception e) { throw handleException(e); } } - + @RequestMapping(value = "/noauth/resetPassword", params = { "resetToken" }, method = RequestMethod.GET) public ResponseEntity checkResetToken( @RequestParam(value = "resetToken") String resetToken) { @@ -198,7 +194,7 @@ public class AuthController extends BaseController { } return new ResponseEntity<>(headers, responseStatus); } - + @RequestMapping(value = "/noauth/activate", method = RequestMethod.POST) @ResponseStatus(value = HttpStatus.OK) @ResponseBody @@ -240,7 +236,7 @@ public class AuthController extends BaseController { throw handleException(e); } } - + @RequestMapping(value = "/noauth/resetPassword", method = RequestMethod.POST) @ResponseStatus(value = HttpStatus.OK) @ResponseBody @@ -268,6 +264,7 @@ public class AuthController extends BaseController { String email = user.getEmail(); mailService.sendPasswordWasResetEmail(loginUrl, email); + eventPublisher.publishEvent(new UserAuthDataChangedEvent(securityUser.getId())); JwtToken accessToken = tokenFactory.createAccessJwtToken(securityUser); JwtToken refreshToken = refreshTokenRepository.requestRefreshToken(securityUser); diff --git a/application/src/main/java/org/thingsboard/server/controller/UserController.java b/application/src/main/java/org/thingsboard/server/controller/UserController.java index e1c3328176..ca3ecdc36c 100644 --- a/application/src/main/java/org/thingsboard/server/controller/UserController.java +++ b/application/src/main/java/org/thingsboard/server/controller/UserController.java @@ -19,8 +19,9 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import lombok.Getter; -import org.springframework.beans.factory.annotation.Autowired; +import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.http.HttpStatus; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.PathVariable; @@ -44,11 +45,12 @@ import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.common.data.security.UserCredentials; +import org.thingsboard.server.common.data.security.event.UserAuthDataChangedEvent; +import org.thingsboard.server.common.data.security.model.JwtToken; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.security.auth.jwt.RefreshTokenRepository; import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.model.UserPrincipal; -import org.thingsboard.server.service.security.model.token.JwtToken; import org.thingsboard.server.service.security.model.token.JwtTokenFactory; import org.thingsboard.server.service.security.permission.Operation; import org.thingsboard.server.service.security.permission.Resource; @@ -56,6 +58,7 @@ import org.thingsboard.server.service.security.system.SystemSecurityService; import javax.servlet.http.HttpServletRequest; +@RequiredArgsConstructor @RestController @TbCoreComponent @RequestMapping("/api") @@ -69,18 +72,11 @@ public class UserController extends BaseController { @Getter private boolean userTokenAccessEnabled; - @Autowired - private MailService mailService; - - @Autowired - private JwtTokenFactory tokenFactory; - - @Autowired - private RefreshTokenRepository refreshTokenRepository; - - @Autowired - private SystemSecurityService systemSecurityService; - + private final MailService mailService; + private final JwtTokenFactory tokenFactory; + private final RefreshTokenRepository refreshTokenRepository; + private final SystemSecurityService systemSecurityService; + private final ApplicationEventPublisher eventPublisher; @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @RequestMapping(value = "/user/{userId}", method = RequestMethod.GET) @@ -341,6 +337,10 @@ public class UserController extends BaseController { User user = checkUserId(userId, Operation.WRITE); TenantId tenantId = getCurrentUser().getTenantId(); userService.setUserCredentialsEnabled(tenantId, userId, userCredentialsEnabled); + + if (!userCredentialsEnabled) { + eventPublisher.publishEvent(new UserAuthDataChangedEvent(userId)); + } } catch (Exception e) { throw handleException(e); } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/TokenOutdatingService.java b/application/src/main/java/org/thingsboard/server/service/security/auth/TokenOutdatingService.java new file mode 100644 index 0000000000..175242b09a --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/TokenOutdatingService.java @@ -0,0 +1,85 @@ +/** + * Copyright © 2016-2021 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.security.auth; + +import io.jsonwebtoken.Claims; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.CacheConstants; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.security.event.UserAuthDataChangedEvent; +import org.thingsboard.server.common.data.security.model.JwtToken; +import org.thingsboard.server.config.JwtSettings; +import org.thingsboard.server.service.security.model.token.JwtTokenFactory; + +import javax.annotation.PostConstruct; +import java.util.Optional; + +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static java.util.concurrent.TimeUnit.SECONDS; + +@Service +@RequiredArgsConstructor +public class TokenOutdatingService { + private final CacheManager cacheManager; + private final JwtTokenFactory tokenFactory; + private final JwtSettings jwtSettings; + private Cache tokenOutdatageTimeCache; + + @PostConstruct + protected void initCache() { + tokenOutdatageTimeCache = cacheManager.getCache(CacheConstants.TOKEN_OUTDATAGE_TIME_CACHE); + } + + @EventListener(classes = UserAuthDataChangedEvent.class) + public void onUserAuthDataChanged(UserAuthDataChangedEvent userAuthDataChangedEvent) { + outdateOldUserTokens(userAuthDataChangedEvent.getUserId()); + } + + public boolean isOutdated(JwtToken token, UserId userId) { + Claims claims = tokenFactory.parseTokenClaims(token).getBody(); + long issueTime = claims.getIssuedAt().getTime(); + + return Optional.ofNullable(tokenOutdatageTimeCache.get(toKey(userId), Long.class)) + .map(outdatageTime -> { + if (System.currentTimeMillis() - outdatageTime <= SECONDS.toMillis(jwtSettings.getRefreshTokenExpTime())) { + return MILLISECONDS.toSeconds(issueTime) < MILLISECONDS.toSeconds(outdatageTime); + } else { + /* + * Means that since the outdating has passed more than + * the lifetime of refresh token (the longest lived) + * and there is no need to store outdatage time anymore + * as all the tokens issued before the outdatage time + * are now expired by themselves + * */ + tokenOutdatageTimeCache.evict(toKey(userId)); + return false; + } + }) + .orElse(false); + } + + public void outdateOldUserTokens(UserId userId) { + tokenOutdatageTimeCache.put(toKey(userId), System.currentTimeMillis()); + } + + private String toKey(UserId userId) { + return userId.getId().toString(); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/JwtAuthenticationProvider.java b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/JwtAuthenticationProvider.java index ac11e8b48d..68291c9ac0 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/JwtAuthenticationProvider.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/JwtAuthenticationProvider.java @@ -15,31 +15,34 @@ */ package org.thingsboard.server.service.security.auth.jwt; -import org.springframework.beans.factory.annotation.Autowired; +import lombok.RequiredArgsConstructor; import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.stereotype.Component; +import org.thingsboard.server.service.security.auth.TokenOutdatingService; import org.thingsboard.server.service.security.auth.JwtAuthenticationToken; +import org.thingsboard.server.service.security.exception.JwtExpiredTokenException; import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.model.token.JwtTokenFactory; import org.thingsboard.server.service.security.model.token.RawAccessJwtToken; @Component -@SuppressWarnings("unchecked") +@RequiredArgsConstructor public class JwtAuthenticationProvider implements AuthenticationProvider { private final JwtTokenFactory tokenFactory; - - @Autowired - public JwtAuthenticationProvider(JwtTokenFactory tokenFactory) { - this.tokenFactory = tokenFactory; - } + private final TokenOutdatingService tokenOutdatingService; @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { RawAccessJwtToken rawAccessToken = (RawAccessJwtToken) authentication.getCredentials(); SecurityUser securityUser = tokenFactory.parseAccessJwtToken(rawAccessToken); + + if (tokenOutdatingService.isOutdated(rawAccessToken, securityUser.getId())) { + throw new JwtExpiredTokenException("Token is outdated"); + } + return new JwtAuthenticationToken(securityUser); } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenAuthenticationProvider.java b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenAuthenticationProvider.java index e697ada876..7899c15b70 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenAuthenticationProvider.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenAuthenticationProvider.java @@ -15,9 +15,10 @@ */ package org.thingsboard.server.service.security.auth.jwt; -import org.springframework.beans.factory.annotation.Autowired; +import lombok.RequiredArgsConstructor; import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.CredentialsExpiredException; import org.springframework.security.authentication.DisabledException; import org.springframework.security.authentication.InsufficientAuthenticationException; import org.springframework.security.core.Authentication; @@ -32,6 +33,7 @@ import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.service.security.auth.TokenOutdatingService; import org.thingsboard.server.common.data.security.UserCredentials; import org.thingsboard.server.dao.customer.CustomerService; import org.thingsboard.server.dao.user.UserService; @@ -44,18 +46,12 @@ import org.thingsboard.server.service.security.model.token.RawAccessJwtToken; import java.util.UUID; @Component +@RequiredArgsConstructor public class RefreshTokenAuthenticationProvider implements AuthenticationProvider { - private final JwtTokenFactory tokenFactory; private final UserService userService; private final CustomerService customerService; - - @Autowired - public RefreshTokenAuthenticationProvider(final UserService userService, final CustomerService customerService, final JwtTokenFactory tokenFactory) { - this.userService = userService; - this.customerService = customerService; - this.tokenFactory = tokenFactory; - } + private final TokenOutdatingService tokenOutdatingService; @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { @@ -63,12 +59,18 @@ public class RefreshTokenAuthenticationProvider implements AuthenticationProvide RawAccessJwtToken rawAccessToken = (RawAccessJwtToken) authentication.getCredentials(); SecurityUser unsafeUser = tokenFactory.parseRefreshToken(rawAccessToken); UserPrincipal principal = unsafeUser.getUserPrincipal(); + SecurityUser securityUser; if (principal.getType() == UserPrincipal.Type.USER_NAME) { securityUser = authenticateByUserId(unsafeUser.getId()); } else { securityUser = authenticateByPublicId(principal.getValue()); } + + if (tokenOutdatingService.isOutdated(rawAccessToken, securityUser.getId())) { + throw new CredentialsExpiredException("Token is outdated"); + } + return new RefreshAuthenticationToken(securityUser); } @@ -91,7 +93,6 @@ public class RefreshTokenAuthenticationProvider implements AuthenticationProvide if (user.getAuthority() == null) throw new InsufficientAuthenticationException("User has no authority assigned"); UserPrincipal userPrincipal = new UserPrincipal(UserPrincipal.Type.USER_NAME, user.getEmail()); - SecurityUser securityUser = new SecurityUser(user, userCredentials.isEnabled(), userPrincipal); return securityUser; diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenRepository.java b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenRepository.java index be24f927c8..0860229e28 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenRepository.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenRepository.java @@ -17,8 +17,8 @@ package org.thingsboard.server.service.security.auth.jwt; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.security.model.JwtToken; import org.thingsboard.server.service.security.model.SecurityUser; -import org.thingsboard.server.service.security.model.token.JwtToken; import org.thingsboard.server.service.security.model.token.JwtTokenFactory; @Component diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationSuccessHandler.java b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationSuccessHandler.java index 3cb75501d9..72a6c65f7c 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationSuccessHandler.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationSuccessHandler.java @@ -26,13 +26,12 @@ 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.oauth2.OAuth2ClientRegistrationInfo; +import org.thingsboard.server.common.data.security.model.JwtToken; import org.thingsboard.server.dao.oauth2.OAuth2Service; import org.thingsboard.server.service.security.auth.jwt.RefreshTokenRepository; import org.thingsboard.server.service.security.model.SecurityUser; -import org.thingsboard.server.service.security.model.token.JwtToken; import org.thingsboard.server.service.security.model.token.JwtTokenFactory; import org.thingsboard.server.service.security.system.SystemSecurityService; -import org.thingsboard.server.utils.MiscUtils; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAwareAuthenticationSuccessHandler.java b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAwareAuthenticationSuccessHandler.java index a393161292..116fbe09e4 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAwareAuthenticationSuccessHandler.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAwareAuthenticationSuccessHandler.java @@ -23,9 +23,9 @@ import org.springframework.security.core.Authentication; import org.springframework.security.web.WebAttributes; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.security.model.JwtToken; import org.thingsboard.server.service.security.auth.jwt.RefreshTokenRepository; import org.thingsboard.server.service.security.model.SecurityUser; -import org.thingsboard.server.service.security.model.token.JwtToken; import org.thingsboard.server.service.security.model.token.JwtTokenFactory; import javax.servlet.ServletException; diff --git a/application/src/main/java/org/thingsboard/server/service/security/exception/JwtExpiredTokenException.java b/application/src/main/java/org/thingsboard/server/service/security/exception/JwtExpiredTokenException.java index 47bd5220a3..dc4b04e87e 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/exception/JwtExpiredTokenException.java +++ b/application/src/main/java/org/thingsboard/server/service/security/exception/JwtExpiredTokenException.java @@ -16,7 +16,7 @@ package org.thingsboard.server.service.security.exception; import org.springframework.security.core.AuthenticationException; -import org.thingsboard.server.service.security.model.token.JwtToken; +import org.thingsboard.server.common.data.security.model.JwtToken; public class JwtExpiredTokenException extends AuthenticationException { private static final long serialVersionUID = -5959543783324224864L; diff --git a/application/src/main/java/org/thingsboard/server/service/security/model/token/AccessJwtToken.java b/application/src/main/java/org/thingsboard/server/service/security/model/token/AccessJwtToken.java index 4bf16d135b..a8ab344295 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/model/token/AccessJwtToken.java +++ b/application/src/main/java/org/thingsboard/server/service/security/model/token/AccessJwtToken.java @@ -17,6 +17,7 @@ package org.thingsboard.server.service.security.model.token; import com.fasterxml.jackson.annotation.JsonIgnore; import io.jsonwebtoken.Claims; +import org.thingsboard.server.common.data.security.model.JwtToken; public final class AccessJwtToken implements JwtToken { private final String rawToken; diff --git a/application/src/main/java/org/thingsboard/server/service/security/model/token/JwtTokenFactory.java b/application/src/main/java/org/thingsboard/server/service/security/model/token/JwtTokenFactory.java index cfbf051cad..9315b3bcc1 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/model/token/JwtTokenFactory.java +++ b/application/src/main/java/org/thingsboard/server/service/security/model/token/JwtTokenFactory.java @@ -16,18 +16,26 @@ package org.thingsboard.server.service.security.model.token; import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.Jws; import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.SignatureException; +import io.jsonwebtoken.UnsupportedJwtException; +import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.core.GrantedAuthority; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.common.data.security.model.JwtToken; import org.thingsboard.server.config.JwtSettings; +import org.thingsboard.server.service.security.exception.JwtExpiredTokenException; import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.model.UserPrincipal; @@ -39,6 +47,7 @@ import java.util.UUID; import java.util.stream.Collectors; @Component +@Slf4j public class JwtTokenFactory { private static final String SCOPES = "scopes"; @@ -97,7 +106,7 @@ public class JwtTokenFactory { } public SecurityUser parseAccessJwtToken(RawAccessJwtToken rawAccessToken) { - Jws jwsClaims = rawAccessToken.parseClaims(settings.getTokenSigningKey()); + Jws jwsClaims = parseTokenClaims(rawAccessToken); Claims claims = jwsClaims.getBody(); String subject = claims.getSubject(); @SuppressWarnings("unchecked") @@ -153,7 +162,7 @@ public class JwtTokenFactory { } public SecurityUser parseRefreshToken(RawAccessJwtToken rawAccessToken) { - Jws jwsClaims = rawAccessToken.parseClaims(settings.getTokenSigningKey()); + Jws jwsClaims = parseTokenClaims(rawAccessToken); Claims claims = jwsClaims.getBody(); String subject = claims.getSubject(); @SuppressWarnings("unchecked") @@ -171,4 +180,17 @@ public class JwtTokenFactory { return securityUser; } + public Jws parseTokenClaims(JwtToken token) { + try { + return Jwts.parser() + .setSigningKey(settings.getTokenSigningKey()) + .parseClaimsJws(token.getToken()); + } catch (UnsupportedJwtException | MalformedJwtException | IllegalArgumentException | SignatureException ex) { + log.debug("Invalid JWT Token", ex); + throw new BadCredentialsException("Invalid JWT token: ", ex); + } catch (ExpiredJwtException expiredEx) { + log.debug("JWT Token is expired", expiredEx); + throw new JwtExpiredTokenException(token, "JWT Token expired", expiredEx); + } + } } diff --git a/application/src/main/java/org/thingsboard/server/service/security/model/token/RawAccessJwtToken.java b/application/src/main/java/org/thingsboard/server/service/security/model/token/RawAccessJwtToken.java index 0e966fe678..af67706659 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/model/token/RawAccessJwtToken.java +++ b/application/src/main/java/org/thingsboard/server/service/security/model/token/RawAccessJwtToken.java @@ -15,22 +15,10 @@ */ package org.thingsboard.server.service.security.model.token; -import io.jsonwebtoken.Claims; -import io.jsonwebtoken.ExpiredJwtException; -import io.jsonwebtoken.Jws; -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.MalformedJwtException; -import io.jsonwebtoken.SignatureException; -import io.jsonwebtoken.UnsupportedJwtException; -import lombok.extern.slf4j.Slf4j; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.security.authentication.BadCredentialsException; -import org.thingsboard.server.service.security.exception.JwtExpiredTokenException; +import org.thingsboard.server.common.data.security.model.JwtToken; import java.io.Serializable; -@Slf4j public class RawAccessJwtToken implements JwtToken, Serializable { private static final long serialVersionUID = -797397445703066079L; @@ -41,25 +29,6 @@ public class RawAccessJwtToken implements JwtToken, Serializable { this.token = token; } - /** - * Parses and validates JWT Token signature. - * - * @throws BadCredentialsException - * @throws JwtExpiredTokenException - * - */ - public Jws parseClaims(String signingKey) { - try { - return Jwts.parser().setSigningKey(signingKey).parseClaimsJws(this.token); - } catch (UnsupportedJwtException | MalformedJwtException | IllegalArgumentException | SignatureException ex) { - log.debug("Invalid JWT Token", ex); - throw new BadCredentialsException("Invalid JWT token: ", ex); - } catch (ExpiredJwtException expiredEx) { - log.debug("JWT Token is expired", expiredEx); - throw new JwtExpiredTokenException(this, "JWT Token expired", expiredEx); - } - } - @Override public String getToken() { return token; diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index ea471b0e11..15243ec96a 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -362,6 +362,9 @@ caffeine: attributes: timeToLiveInMinutes: 1440 maxSize: 100000 + tokensOutdatageTime: + timeToLiveInMinutes: 20000 + maxSize: 10000 redis: # standalone or cluster diff --git a/application/src/test/java/org/thingsboard/server/service/security/auth/TokenOutdatingTest.java b/application/src/test/java/org/thingsboard/server/service/security/auth/TokenOutdatingTest.java new file mode 100644 index 0000000000..39abc09365 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/security/auth/TokenOutdatingTest.java @@ -0,0 +1,184 @@ +/** + * Copyright © 2016-2021 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.security.auth; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.cache.concurrent.ConcurrentMapCacheManager; +import org.springframework.security.authentication.CredentialsExpiredException; +import org.thingsboard.server.common.data.CacheConstants; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.common.data.security.UserCredentials; +import org.thingsboard.server.common.data.security.model.JwtToken; +import org.thingsboard.server.config.JwtSettings; +import org.thingsboard.server.dao.customer.CustomerService; +import org.thingsboard.server.dao.user.UserService; +import org.thingsboard.server.service.security.auth.jwt.JwtAuthenticationProvider; +import org.thingsboard.server.service.security.auth.jwt.RefreshTokenAuthenticationProvider; +import org.thingsboard.server.service.security.exception.JwtExpiredTokenException; +import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.service.security.model.UserPrincipal; +import org.thingsboard.server.service.security.model.token.JwtTokenFactory; +import org.thingsboard.server.service.security.model.token.RawAccessJwtToken; + +import java.util.UUID; + +import static java.util.concurrent.TimeUnit.DAYS; +import static java.util.concurrent.TimeUnit.MINUTES; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class TokenOutdatingTest { + private JwtAuthenticationProvider accessTokenAuthenticationProvider; + private RefreshTokenAuthenticationProvider refreshTokenAuthenticationProvider; + + private TokenOutdatingService tokenOutdatingService; + private ConcurrentMapCacheManager cacheManager; + private JwtTokenFactory tokenFactory; + private JwtSettings jwtSettings; + + private UserId userId; + + @BeforeEach + public void setUp() { + jwtSettings = new JwtSettings(); + jwtSettings.setTokenIssuer("test.io"); + jwtSettings.setTokenExpirationTime((int) MINUTES.toSeconds(10)); + jwtSettings.setRefreshTokenExpTime((int) DAYS.toSeconds(7)); + jwtSettings.setTokenSigningKey("secret"); + tokenFactory = new JwtTokenFactory(jwtSettings); + + cacheManager = new ConcurrentMapCacheManager(); + tokenOutdatingService = new TokenOutdatingService(cacheManager, tokenFactory, jwtSettings); + tokenOutdatingService.initCache(); + + userId = new UserId(UUID.randomUUID()); + + UserService userService = mock(UserService.class); + + User user = new User(); + user.setId(userId); + user.setAuthority(Authority.TENANT_ADMIN); + user.setEmail("email"); + when(userService.findUserById(any(), eq(userId))).thenReturn(user); + + UserCredentials userCredentials = new UserCredentials(); + userCredentials.setEnabled(true); + when(userService.findUserCredentialsByUserId(any(), eq(userId))).thenReturn(userCredentials); + + accessTokenAuthenticationProvider = new JwtAuthenticationProvider(tokenFactory, tokenOutdatingService); + refreshTokenAuthenticationProvider = new RefreshTokenAuthenticationProvider(tokenFactory, userService, mock(CustomerService.class), tokenOutdatingService); + } + + @Test + public void testOutdateOldUserTokens() throws Exception { + JwtToken jwtToken = createAccessJwtToken(userId); + + SECONDS.sleep(1); // need to wait before outdating so that outdatage time is strictly after token issue time + tokenOutdatingService.outdateOldUserTokens(userId); + assertTrue(tokenOutdatingService.isOutdated(jwtToken, userId)); + + SECONDS.sleep(1); + + JwtToken newJwtToken = tokenFactory.createAccessJwtToken(createMockSecurityUser(userId)); + assertFalse(tokenOutdatingService.isOutdated(newJwtToken, userId)); + } + + @Test + public void testAuthenticateWithOutdatedAccessToken() throws InterruptedException { + RawAccessJwtToken accessJwtToken = getRawJwtToken(createAccessJwtToken(userId)); + + assertDoesNotThrow(() -> { + accessTokenAuthenticationProvider.authenticate(new JwtAuthenticationToken(accessJwtToken)); + }); + + SECONDS.sleep(1); + tokenOutdatingService.outdateOldUserTokens(userId); + + assertThrows(JwtExpiredTokenException.class, () -> { + accessTokenAuthenticationProvider.authenticate(new JwtAuthenticationToken(accessJwtToken)); + }); + } + + @Test + public void testAuthenticateWithOutdatedRefreshToken() throws InterruptedException { + RawAccessJwtToken refreshJwtToken = getRawJwtToken(createRefreshJwtToken(userId)); + + assertDoesNotThrow(() -> { + refreshTokenAuthenticationProvider.authenticate(new RefreshAuthenticationToken(refreshJwtToken)); + }); + + SECONDS.sleep(1); + tokenOutdatingService.outdateOldUserTokens(userId); + + assertThrows(CredentialsExpiredException.class, () -> { + refreshTokenAuthenticationProvider.authenticate(new RefreshAuthenticationToken(refreshJwtToken)); + }); + } + + @Test + public void testTokensOutdatageTimeRemovalFromCache() throws Exception { + JwtToken jwtToken = createAccessJwtToken(userId); + + SECONDS.sleep(1); + tokenOutdatingService.outdateOldUserTokens(userId); + + int refreshTokenExpirationTime = 5; + jwtSettings.setRefreshTokenExpTime(refreshTokenExpirationTime); + + SECONDS.sleep(refreshTokenExpirationTime - 2); + + assertTrue(tokenOutdatingService.isOutdated(jwtToken, userId)); + assertNotNull(cacheManager.getCache(CacheConstants.TOKEN_OUTDATAGE_TIME_CACHE).get(userId.getId().toString())); + + SECONDS.sleep(3); + + assertFalse(tokenOutdatingService.isOutdated(jwtToken, userId)); + assertNull(cacheManager.getCache(CacheConstants.TOKEN_OUTDATAGE_TIME_CACHE).get(userId.getId().toString())); + } + + private JwtToken createAccessJwtToken(UserId userId) { + return tokenFactory.createAccessJwtToken(createMockSecurityUser(userId)); + } + + private JwtToken createRefreshJwtToken(UserId userId) { + return tokenFactory.createRefreshToken(createMockSecurityUser(userId)); + } + + private RawAccessJwtToken getRawJwtToken(JwtToken token) { + return new RawAccessJwtToken(token.getToken()); + } + + private SecurityUser createMockSecurityUser(UserId userId) { + SecurityUser securityUser = new SecurityUser(); + securityUser.setEmail("email"); + securityUser.setUserPrincipal(new UserPrincipal(UserPrincipal.Type.USER_NAME, securityUser.getEmail())); + securityUser.setAuthority(Authority.CUSTOMER_USER); + securityUser.setId(userId); + return securityUser; + } +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java b/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java index c3490aa85f..62f625f285 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java @@ -27,4 +27,5 @@ public class CacheConstants { public static final String TENANT_PROFILE_CACHE = "tenantProfiles"; public static final String DEVICE_PROFILE_CACHE = "deviceProfiles"; public static final String ATTRIBUTES_CACHE = "attributes"; + public static final String TOKEN_OUTDATAGE_TIME_CACHE = "tokensOutdatageTime"; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/event/UserAuthDataChangedEvent.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/event/UserAuthDataChangedEvent.java new file mode 100644 index 0000000000..27a43670b5 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/event/UserAuthDataChangedEvent.java @@ -0,0 +1,30 @@ +/** + * Copyright © 2016-2021 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.security.event; + +import org.thingsboard.server.common.data.id.UserId; + +public class UserAuthDataChangedEvent { + private final UserId userId; + + public UserAuthDataChangedEvent(UserId userId) { + this.userId = userId; + } + + public UserId getUserId() { + return userId; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/model/token/JwtToken.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/JwtToken.java similarity index 92% rename from application/src/main/java/org/thingsboard/server/service/security/model/token/JwtToken.java rename to common/data/src/main/java/org/thingsboard/server/common/data/security/model/JwtToken.java index ca4c969bcf..d11522cf9a 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/model/token/JwtToken.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/JwtToken.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.service.security.model.token; +package org.thingsboard.server.common.data.security.model; import java.io.Serializable; diff --git a/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java index 5cb4c693fc..ada6208b6b 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java @@ -22,8 +22,8 @@ import com.google.common.util.concurrent.ListenableFuture; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.RandomStringUtils; import org.apache.commons.lang3.StringUtils; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.Customer; @@ -38,6 +38,7 @@ import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.common.data.security.UserCredentials; +import org.thingsboard.server.common.data.security.event.UserAuthDataChangedEvent; import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; import org.thingsboard.server.dao.customer.CustomerDao; import org.thingsboard.server.dao.entity.AbstractEntityService; @@ -75,21 +76,26 @@ public class UserServiceImpl extends AbstractEntityService implements UserServic @Value("${security.user_login_case_sensitive:true}") private boolean userLoginCaseSensitive; - @Autowired - private UserDao userDao; - - @Autowired - private UserCredentialsDao userCredentialsDao; - - @Autowired - private TenantDao tenantDao; - - @Autowired - private CustomerDao customerDao; - - @Autowired - @Lazy - private TbTenantProfileCache tenantProfileCache; + private final UserDao userDao; + private final UserCredentialsDao userCredentialsDao; + private final TenantDao tenantDao; + private final CustomerDao customerDao; + private final TbTenantProfileCache tenantProfileCache; + private final ApplicationEventPublisher eventPublisher; + + public UserServiceImpl(UserDao userDao, + UserCredentialsDao userCredentialsDao, + TenantDao tenantDao, + CustomerDao customerDao, + @Lazy TbTenantProfileCache tenantProfileCache, + ApplicationEventPublisher eventPublisher) { + this.userDao = userDao; + this.userCredentialsDao = userCredentialsDao; + this.tenantDao = tenantDao; + this.customerDao = customerDao; + this.tenantProfileCache = tenantProfileCache; + this.eventPublisher = eventPublisher; + } @Override public User findUserByEmail(TenantId tenantId, String email) { @@ -225,6 +231,7 @@ public class UserServiceImpl extends AbstractEntityService implements UserServic userCredentialsDao.removeById(tenantId, userCredentials.getUuidId()); deleteEntityRelations(tenantId, userId); userDao.removeById(tenantId, userId.getId()); + eventPublisher.publishEvent(new UserAuthDataChangedEvent(userId)); } @Override diff --git a/ui-ngx/src/app/core/auth/auth.service.ts b/ui-ngx/src/app/core/auth/auth.service.ts index 073f60654e..50160ad2d3 100644 --- a/ui-ngx/src/app/core/auth/auth.service.ts +++ b/ui-ngx/src/app/core/auth/auth.service.ts @@ -149,8 +149,11 @@ export class AuthService { } public changePassword(currentPassword: string, newPassword: string) { - return this.http.post('/api/auth/changePassword', - {currentPassword, newPassword}, defaultHttpOptions()); + return this.http.post('/api/auth/changePassword', {currentPassword, newPassword}, defaultHttpOptions()).pipe( + tap((loginResponse: LoginResponse) => { + this.setUserFromJwtToken(loginResponse.token, loginResponse.refreshToken, false); + } + )); } public activateByEmailCode(emailCode: string): Observable {