Commit 228fddb8cd92bf4ec89f16760aef7f190a45bb96

Authored by AndrewVolosytnykhThingsboard
Committed by GitHub
1 parent 3712d738

[PROD-678] Authorization and password reset vulnerability fix (#4569)

* Fixed vulnerabilities for password reset and authorization

* Improvements to check user and credentials for null

* Correct messages and logs

* Improvements

* Reset Password Test: added delay after resetting password to synchronize test with server

* Executor removed from controller

* Correct method calling

* Formatting cleaned
@@ -135,7 +135,7 @@ public class AuthController extends BaseController { @@ -135,7 +135,7 @@ public class AuthController extends BaseController {
135 } 135 }
136 } 136 }
137 137
138 - @RequestMapping(value = "/noauth/activate", params = { "activateToken" }, method = RequestMethod.GET) 138 + @RequestMapping(value = "/noauth/activate", params = {"activateToken"}, method = RequestMethod.GET)
139 public ResponseEntity<String> checkActivateToken( 139 public ResponseEntity<String> checkActivateToken(
140 @RequestParam(value = "activateToken") String activateToken) { 140 @RequestParam(value = "activateToken") String activateToken) {
141 HttpHeaders headers = new HttpHeaders(); 141 HttpHeaders headers = new HttpHeaders();
@@ -159,7 +159,7 @@ public class AuthController extends BaseController { @@ -159,7 +159,7 @@ public class AuthController extends BaseController {
159 159
160 @RequestMapping(value = "/noauth/resetPasswordByEmail", method = RequestMethod.POST) 160 @RequestMapping(value = "/noauth/resetPasswordByEmail", method = RequestMethod.POST)
161 @ResponseStatus(value = HttpStatus.OK) 161 @ResponseStatus(value = HttpStatus.OK)
162 - public void requestResetPasswordByEmail ( 162 + public void requestResetPasswordByEmail(
163 @RequestBody JsonNode resetPasswordByEmailRequest, 163 @RequestBody JsonNode resetPasswordByEmailRequest,
164 HttpServletRequest request) throws ThingsboardException { 164 HttpServletRequest request) throws ThingsboardException {
165 try { 165 try {
@@ -170,13 +170,13 @@ public class AuthController extends BaseController { @@ -170,13 +170,13 @@ public class AuthController extends BaseController {
170 String resetUrl = String.format("%s/api/noauth/resetPassword?resetToken=%s", baseUrl, 170 String resetUrl = String.format("%s/api/noauth/resetPassword?resetToken=%s", baseUrl,
171 userCredentials.getResetToken()); 171 userCredentials.getResetToken());
172 172
173 - mailService.sendResetPasswordEmail(resetUrl, email); 173 + mailService.sendResetPasswordEmailAsync(resetUrl, email);
174 } catch (Exception e) { 174 } catch (Exception e) {
175 - throw handleException(e); 175 + log.warn("Error occurred: {}", e.getMessage());
176 } 176 }
177 } 177 }
178 178
179 - @RequestMapping(value = "/noauth/resetPassword", params = { "resetToken" }, method = RequestMethod.GET) 179 + @RequestMapping(value = "/noauth/resetPassword", params = {"resetToken"}, method = RequestMethod.GET)
180 public ResponseEntity<String> checkResetToken( 180 public ResponseEntity<String> checkResetToken(
181 @RequestParam(value = "resetToken") String resetToken) { 181 @RequestParam(value = "resetToken") String resetToken) {
182 HttpHeaders headers = new HttpHeaders(); 182 HttpHeaders headers = new HttpHeaders();
@@ -25,6 +25,7 @@ import org.springframework.security.authentication.BadCredentialsException; @@ -25,6 +25,7 @@ import org.springframework.security.authentication.BadCredentialsException;
25 import org.springframework.security.authentication.DisabledException; 25 import org.springframework.security.authentication.DisabledException;
26 import org.springframework.security.authentication.LockedException; 26 import org.springframework.security.authentication.LockedException;
27 import org.springframework.security.core.AuthenticationException; 27 import org.springframework.security.core.AuthenticationException;
  28 +import org.springframework.security.core.userdetails.UsernameNotFoundException;
28 import org.springframework.security.web.access.AccessDeniedHandler; 29 import org.springframework.security.web.access.AccessDeniedHandler;
29 import org.springframework.web.bind.annotation.ExceptionHandler; 30 import org.springframework.web.bind.annotation.ExceptionHandler;
30 import org.springframework.web.bind.annotation.RestControllerAdvice; 31 import org.springframework.web.bind.annotation.RestControllerAdvice;
@@ -152,7 +153,7 @@ public class ThingsboardErrorResponseHandler extends ResponseEntityExceptionHand @@ -152,7 +153,7 @@ public class ThingsboardErrorResponseHandler extends ResponseEntityExceptionHand
152 153
153 private void handleAuthenticationException(AuthenticationException authenticationException, HttpServletResponse response) throws IOException { 154 private void handleAuthenticationException(AuthenticationException authenticationException, HttpServletResponse response) throws IOException {
154 response.setStatus(HttpStatus.UNAUTHORIZED.value()); 155 response.setStatus(HttpStatus.UNAUTHORIZED.value());
155 - if (authenticationException instanceof BadCredentialsException) { 156 + if (authenticationException instanceof BadCredentialsException || authenticationException instanceof UsernameNotFoundException) {
156 mapper.writeValue(response.getWriter(), ThingsboardErrorResponse.of("Invalid username or password", ThingsboardErrorCode.AUTHENTICATION, HttpStatus.UNAUTHORIZED)); 157 mapper.writeValue(response.getWriter(), ThingsboardErrorResponse.of("Invalid username or password", ThingsboardErrorCode.AUTHENTICATION, HttpStatus.UNAUTHORIZED));
157 } else if (authenticationException instanceof DisabledException) { 158 } else if (authenticationException instanceof DisabledException) {
158 mapper.writeValue(response.getWriter(), ThingsboardErrorResponse.of("User account is not active", ThingsboardErrorCode.AUTHENTICATION, HttpStatus.UNAUTHORIZED)); 159 mapper.writeValue(response.getWriter(), ThingsboardErrorResponse.of("User account is not active", ThingsboardErrorCode.AUTHENTICATION, HttpStatus.UNAUTHORIZED));
@@ -73,6 +73,9 @@ public class DefaultMailService implements MailService { @@ -73,6 +73,9 @@ public class DefaultMailService implements MailService {
73 @Autowired 73 @Autowired
74 private TbApiUsageStateService apiUsageStateService; 74 private TbApiUsageStateService apiUsageStateService;
75 75
  76 + @Autowired
  77 + private MailExecutorService mailExecutorService;
  78 +
76 private JavaMailSenderImpl mailSender; 79 private JavaMailSenderImpl mailSender;
77 80
78 private String mailFrom; 81 private String mailFrom;
@@ -222,6 +225,17 @@ public class DefaultMailService implements MailService { @@ -222,6 +225,17 @@ public class DefaultMailService implements MailService {
222 } 225 }
223 226
224 @Override 227 @Override
  228 + public void sendResetPasswordEmailAsync(String passwordResetLink, String email) {
  229 + mailExecutorService.execute(() -> {
  230 + try {
  231 + this.sendResetPasswordEmail(passwordResetLink, email);
  232 + } catch (ThingsboardException e) {
  233 + log.error("Error occurred: {} ", e.getMessage());
  234 + }
  235 + });
  236 + }
  237 +
  238 + @Override
225 public void sendPasswordWasResetEmail(String loginLink, String email) throws ThingsboardException { 239 public void sendPasswordWasResetEmail(String loginLink, String email) throws ThingsboardException {
226 240
227 String subject = messages.getMessage("password.was.reset.subject", null, Locale.US); 241 String subject = messages.getMessage("password.was.reset.subject", null, Locale.US);
@@ -155,6 +155,7 @@ public abstract class BaseUserControllerTest extends AbstractControllerTest { @@ -155,6 +155,7 @@ public abstract class BaseUserControllerTest extends AbstractControllerTest {
155 155
156 doPost("/api/noauth/resetPasswordByEmail", resetPasswordByEmailRequest) 156 doPost("/api/noauth/resetPasswordByEmail", resetPasswordByEmailRequest)
157 .andExpect(status().isOk()); 157 .andExpect(status().isOk());
  158 + Thread.sleep(1000);
158 doGet("/api/noauth/resetPassword?resetToken={resetToken}", TestMailService.currentResetPasswordToken) 159 doGet("/api/noauth/resetPassword?resetToken={resetToken}", TestMailService.currentResetPasswordToken)
159 .andExpect(status().isSeeOther()) 160 .andExpect(status().isSeeOther())
160 .andExpect(header().string(HttpHeaders.LOCATION, "/login/resetPassword?resetToken=" + TestMailService.currentResetPasswordToken)); 161 .andExpect(header().string(HttpHeaders.LOCATION, "/login/resetPassword?resetToken=" + TestMailService.currentResetPasswordToken));
@@ -51,7 +51,7 @@ public class TestMailService { @@ -51,7 +51,7 @@ public class TestMailService {
51 currentResetPasswordToken = passwordResetLink.split("=")[1]; 51 currentResetPasswordToken = passwordResetLink.split("=")[1];
52 return null; 52 return null;
53 } 53 }
54 - }).when(mailService).sendResetPasswordEmail(Mockito.anyString(), Mockito.anyString()); 54 + }).when(mailService).sendResetPasswordEmailAsync(Mockito.anyString(), Mockito.anyString());
55 return mailService; 55 return mailService;
56 } 56 }
57 57
@@ -25,7 +25,10 @@ import org.apache.commons.lang3.StringUtils; @@ -25,7 +25,10 @@ import org.apache.commons.lang3.StringUtils;
25 import org.springframework.beans.factory.annotation.Value; 25 import org.springframework.beans.factory.annotation.Value;
26 import org.springframework.context.ApplicationEventPublisher; 26 import org.springframework.context.ApplicationEventPublisher;
27 import org.springframework.context.annotation.Lazy; 27 import org.springframework.context.annotation.Lazy;
  28 +import org.springframework.security.authentication.DisabledException;
  29 +import org.springframework.security.core.userdetails.UsernameNotFoundException;
28 import org.springframework.stereotype.Service; 30 import org.springframework.stereotype.Service;
  31 +import org.thingsboard.common.util.JacksonUtil;
29 import org.thingsboard.server.common.data.Customer; 32 import org.thingsboard.server.common.data.Customer;
30 import org.thingsboard.server.common.data.EntityType; 33 import org.thingsboard.server.common.data.EntityType;
31 import org.thingsboard.server.common.data.Tenant; 34 import org.thingsboard.server.common.data.Tenant;
@@ -49,7 +52,6 @@ import org.thingsboard.server.dao.service.DataValidator; @@ -49,7 +52,6 @@ import org.thingsboard.server.dao.service.DataValidator;
49 import org.thingsboard.server.dao.service.PaginatedRemover; 52 import org.thingsboard.server.dao.service.PaginatedRemover;
50 import org.thingsboard.server.dao.tenant.TbTenantProfileCache; 53 import org.thingsboard.server.dao.tenant.TbTenantProfileCache;
51 import org.thingsboard.server.dao.tenant.TenantDao; 54 import org.thingsboard.server.dao.tenant.TenantDao;
52 -import org.thingsboard.common.util.JacksonUtil;  
53 55
54 import java.util.HashMap; 56 import java.util.HashMap;
55 import java.util.Map; 57 import java.util.Map;
@@ -194,11 +196,11 @@ public class UserServiceImpl extends AbstractEntityService implements UserServic @@ -194,11 +196,11 @@ public class UserServiceImpl extends AbstractEntityService implements UserServic
194 DataValidator.validateEmail(email); 196 DataValidator.validateEmail(email);
195 User user = userDao.findByEmail(tenantId, email); 197 User user = userDao.findByEmail(tenantId, email);
196 if (user == null) { 198 if (user == null) {
197 - throw new IncorrectParameterException(String.format("Unable to find user by email [%s]", email)); 199 + throw new UsernameNotFoundException(String.format("Unable to find user by email [%s]", email));
198 } 200 }
199 UserCredentials userCredentials = userCredentialsDao.findByUserId(tenantId, user.getUuidId()); 201 UserCredentials userCredentials = userCredentialsDao.findByUserId(tenantId, user.getUuidId());
200 if (!userCredentials.isEnabled()) { 202 if (!userCredentials.isEnabled()) {
201 - throw new IncorrectParameterException("Unable to reset password for inactive user"); 203 + throw new DisabledException(String.format("User credentials not enabled [%s]", email));
202 } 204 }
203 userCredentials.setResetToken(RandomStringUtils.randomAlphanumeric(DEFAULT_TOKEN_LENGTH)); 205 userCredentials.setResetToken(RandomStringUtils.randomAlphanumeric(DEFAULT_TOKEN_LENGTH));
204 return saveUserCredentials(tenantId, userCredentials); 206 return saveUserCredentials(tenantId, userCredentials);
@@ -365,7 +367,8 @@ public class UserServiceImpl extends AbstractEntityService implements UserServic @@ -365,7 +367,8 @@ public class UserServiceImpl extends AbstractEntityService implements UserServic
365 JsonNode userPasswordHistoryJson; 367 JsonNode userPasswordHistoryJson;
366 if (additionalInfo.has(USER_PASSWORD_HISTORY)) { 368 if (additionalInfo.has(USER_PASSWORD_HISTORY)) {
367 userPasswordHistoryJson = additionalInfo.get(USER_PASSWORD_HISTORY); 369 userPasswordHistoryJson = additionalInfo.get(USER_PASSWORD_HISTORY);
368 - userPasswordHistoryMap = JacksonUtil.convertValue(userPasswordHistoryJson, new TypeReference<>(){}); 370 + userPasswordHistoryMap = JacksonUtil.convertValue(userPasswordHistoryJson, new TypeReference<>() {
  371 + });
369 } 372 }
370 if (userPasswordHistoryMap != null) { 373 if (userPasswordHistoryMap != null) {
371 userPasswordHistoryMap.put(Long.toString(System.currentTimeMillis()), userCredentials.getPassword()); 374 userPasswordHistoryMap.put(Long.toString(System.currentTimeMillis()), userCredentials.getPassword());
@@ -31,22 +31,25 @@ public interface MailService { @@ -31,22 +31,25 @@ public interface MailService {
31 void updateMailConfiguration(); 31 void updateMailConfiguration();
32 32
33 void sendEmail(TenantId tenantId, String email, String subject, String message) throws ThingsboardException; 33 void sendEmail(TenantId tenantId, String email, String subject, String message) throws ThingsboardException;
34 - 34 +
35 void sendTestMail(JsonNode config, String email) throws ThingsboardException; 35 void sendTestMail(JsonNode config, String email) throws ThingsboardException;
36 - 36 +
37 void sendActivationEmail(String activationLink, String email) throws ThingsboardException; 37 void sendActivationEmail(String activationLink, String email) throws ThingsboardException;
38 - 38 +
39 void sendAccountActivatedEmail(String loginLink, String email) throws ThingsboardException; 39 void sendAccountActivatedEmail(String loginLink, String email) throws ThingsboardException;
40 - 40 +
41 void sendResetPasswordEmail(String passwordResetLink, String email) throws ThingsboardException; 41 void sendResetPasswordEmail(String passwordResetLink, String email) throws ThingsboardException;
42 42
  43 + void sendResetPasswordEmailAsync(String passwordResetLink, String email);
  44 +
43 void sendPasswordWasResetEmail(String loginLink, String email) throws ThingsboardException; 45 void sendPasswordWasResetEmail(String loginLink, String email) throws ThingsboardException;
44 46
45 - void sendAccountLockoutEmail( String lockoutEmail, String email, Integer maxFailedLoginAttempts) throws ThingsboardException; 47 + void sendAccountLockoutEmail(String lockoutEmail, String email, Integer maxFailedLoginAttempts) throws ThingsboardException;
46 48
47 void send(TenantId tenantId, CustomerId customerId, String from, String to, String cc, String bcc, String subject, String body, boolean isHtml, Map<String, String> images) throws ThingsboardException; 49 void send(TenantId tenantId, CustomerId customerId, String from, String to, String cc, String bcc, String subject, String body, boolean isHtml, Map<String, String> images) throws ThingsboardException;
48 50
49 void send(TenantId tenantId, CustomerId customerId, String from, String to, String cc, String bcc, String subject, String body, boolean isHtml, Map<String, String> images, JavaMailSender javaMailSender) throws ThingsboardException; 51 void send(TenantId tenantId, CustomerId customerId, String from, String to, String cc, String bcc, String subject, String body, boolean isHtml, Map<String, String> images, JavaMailSender javaMailSender) throws ThingsboardException;
50 52
51 void sendApiFeatureStateEmail(ApiFeature apiFeature, ApiUsageStateValue stateValue, String email, ApiUsageStateMailMessage msg) throws ThingsboardException; 53 void sendApiFeatureStateEmail(ApiFeature apiFeature, ApiUsageStateValue stateValue, String email, ApiUsageStateMailMessage msg) throws ThingsboardException;
  54 +
52 } 55 }
@@ -15,7 +15,8 @@ @@ -15,7 +15,8 @@
15 limitations under the License. 15 limitations under the License.
16 16
17 --> 17 -->
18 -<div class="tb-request-password-reset-content mat-app-background tb-dark" fxLayout="row" fxLayoutAlign="center center" style="width: 100%;"> 18 +<div class="tb-request-password-reset-content mat-app-background tb-dark" fxLayout="row" fxLayoutAlign="center center"
  19 + style="width: 100%;">
19 <mat-card fxFlex="initial" class="tb-request-password-reset-card"> 20 <mat-card fxFlex="initial" class="tb-request-password-reset-card">
20 <mat-card-title class="layout-padding"> 21 <mat-card-title class="layout-padding">
21 <span translate class="mat-headline">login.request-password-reset</span> 22 <span translate class="mat-headline">login.request-password-reset</span>
@@ -38,7 +39,7 @@ @@ -38,7 +39,7 @@
38 </mat-form-field> 39 </mat-form-field>
39 <div fxLayout="column" fxLayout.gt-xs="row" fxLayoutGap="16px" fxLayoutAlign="start center" 40 <div fxLayout="column" fxLayout.gt-xs="row" fxLayoutGap="16px" fxLayoutAlign="start center"
40 fxLayoutAlign.gt-xs="center start"> 41 fxLayoutAlign.gt-xs="center start">
41 - <button mat-raised-button color="accent" type="submit" [disabled]="(isLoading$ | async)"> 42 + <button mat-raised-button color="accent" type="submit" [disabled]="(isLoading$ | async) || this.clicked">
42 {{ 'login.request-password-reset' | translate }} 43 {{ 'login.request-password-reset' | translate }}
43 </button> 44 </button>
44 <button mat-raised-button color="primary" type="button" [disabled]="(isLoading$ | async)" 45 <button mat-raised-button color="primary" type="button" [disabled]="(isLoading$ | async)"
@@ -30,6 +30,8 @@ import { TranslateService } from '@ngx-translate/core'; @@ -30,6 +30,8 @@ import { TranslateService } from '@ngx-translate/core';
30 }) 30 })
31 export class ResetPasswordRequestComponent extends PageComponent implements OnInit { 31 export class ResetPasswordRequestComponent extends PageComponent implements OnInit {
32 32
  33 + clicked: boolean = false;
  34 +
33 requestPasswordRequest = this.fb.group({ 35 requestPasswordRequest = this.fb.group({
34 email: ['', [Validators.email, Validators.required]] 36 email: ['', [Validators.email, Validators.required]]
35 }, {updateOn: 'submit'}); 37 }, {updateOn: 'submit'});
@@ -44,8 +46,14 @@ export class ResetPasswordRequestComponent extends PageComponent implements OnIn @@ -44,8 +46,14 @@ export class ResetPasswordRequestComponent extends PageComponent implements OnIn
44 ngOnInit() { 46 ngOnInit() {
45 } 47 }
46 48
  49 + disableInputs() {
  50 + this.requestPasswordRequest.disable();
  51 + this.clicked = true;
  52 + }
  53 +
47 sendResetPasswordLink() { 54 sendResetPasswordLink() {
48 if (this.requestPasswordRequest.valid) { 55 if (this.requestPasswordRequest.valid) {
  56 + this.disableInputs();
49 this.authService.sendResetPasswordLink(this.requestPasswordRequest.get('email').value).subscribe( 57 this.authService.sendResetPasswordLink(this.requestPasswordRequest.get('email').value).subscribe(
50 () => { 58 () => {
51 this.store.dispatch(new ActionNotificationShow({ 59 this.store.dispatch(new ActionNotificationShow({
@@ -2271,7 +2271,7 @@ @@ -2271,7 +2271,7 @@
2271 "expired-password-reset-message": "Your credentials has been expired! Please create new password.", 2271 "expired-password-reset-message": "Your credentials has been expired! Please create new password.",
2272 "new-password": "New password", 2272 "new-password": "New password",
2273 "new-password-again": "New password again", 2273 "new-password-again": "New password again",
2274 - "password-link-sent-message": "Password reset link was successfully sent!", 2274 + "password-link-sent-message": "Reset link has been sent",
2275 "email": "Email", 2275 "email": "Email",
2276 "login-with": "Login with {{name}}", 2276 "login-with": "Login with {{name}}",
2277 "or": "or", 2277 "or": "or",