Browse Source

Reuse still-valid password reset token instead of regenerating on every call

Mirror the activation-link approach (checkUserPasswordResetToken): repeated
calls return the existing token and regenerate only near expiry, so prefetch
or a double-click no longer invalidates an already-shared link. Also log
reset-link generation as a CREDENTIALS_READ audit action and expose the new
endpoints in RestClient.
pull/15648/head
Oleksandra Matviienko 2 months ago
parent
commit
8120fcc9e1
  1. 6
      application/src/main/java/org/thingsboard/server/controller/UserController.java
  2. 8
      application/src/main/java/org/thingsboard/server/service/entitiy/user/DefaultUserService.java
  3. 2
      application/src/main/java/org/thingsboard/server/service/entitiy/user/TbUserService.java
  4. 17
      application/src/test/java/org/thingsboard/server/controller/AuthControllerTest.java
  5. 2
      common/dao-api/src/main/java/org/thingsboard/server/dao/user/UserService.java
  6. 10
      dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java
  7. 9
      rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java

6
application/src/main/java/org/thingsboard/server/controller/UserController.java

@ -254,7 +254,7 @@ public class UserController extends BaseController {
@ApiOperation(value = "Get password reset link (getPasswordResetLink)",
notes = "Generate and return a password reset link for the specified user. " +
"Issues a fresh reset token, invalidating any previously issued reset token for this user. " +
"Reuses the currently valid reset token, regenerating it only when it is about to expire. " +
"Available only for users that are already activated and whose account is enabled. " +
"The base url for the link is configurable in the general settings of system administrator. " + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH)
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')")
@ -268,7 +268,7 @@ public class UserController extends BaseController {
@ApiOperation(value = "Get password reset link info (getPasswordResetLinkInfo)",
notes = "Generate and return a password reset link info for the specified user. " +
"Issues a fresh reset token, invalidating any previously issued reset token for this user. " +
"Reuses the currently valid reset token, regenerating it only when it is about to expire. " +
"Available only for users that are already activated and whose account is enabled. " +
"The base url for the link is configurable in the general settings of system administrator. " + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH)
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')")
@ -280,7 +280,7 @@ public class UserController extends BaseController {
UserId userId = new UserId(toUUID(strUserId));
checkUserId(userId, Operation.WRITE_CREDENTIALS);
SecurityUser securityUser = getCurrentUser();
return tbUserService.getPasswordResetLink(securityUser.getTenantId(), securityUser.getCustomerId(), userId, request);
return tbUserService.getPasswordResetLink(securityUser.getTenantId(), securityUser.getCustomerId(), userId, request, securityUser);
}
@ApiOperation(value = "Delete User (deleteUser)",

8
application/src/main/java/org/thingsboard/server/service/entitiy/user/DefaultUserService.java

@ -99,7 +99,7 @@ public class DefaultUserService extends AbstractTbEntityService implements TbUse
}
@Override
public UserPasswordResetLink getPasswordResetLink(TenantId tenantId, CustomerId customerId, UserId userId, HttpServletRequest request) throws ThingsboardException {
public UserPasswordResetLink getPasswordResetLink(TenantId tenantId, CustomerId customerId, UserId userId, HttpServletRequest request, User responsibleUser) throws ThingsboardException {
UserCredentials userCredentials = userService.findUserCredentialsByUserId(tenantId, userId);
if (!userCredentials.isEnabled()) {
if (userCredentials.getActivateToken() != null) {
@ -107,10 +107,12 @@ public class DefaultUserService extends AbstractTbEntityService implements TbUse
}
throw new ThingsboardException("User account is disabled!", ThingsboardErrorCode.BAD_REQUEST_PARAMS);
}
userCredentials = userService.generatePasswordResetToken(userCredentials);
userCredentials = userService.saveUserCredentials(tenantId, userCredentials);
userCredentials = userService.checkUserPasswordResetToken(tenantId, userCredentials);
String baseUrl = systemSecurityService.getBaseUrl(tenantId, customerId, request);
String link = baseUrl + "/api/noauth/resetPassword?resetToken=" + userCredentials.getResetToken();
User user = userService.findUserById(tenantId, userId);
logEntityActionService.logEntityAction(tenantId, userId, user, user.getCustomerId(),
ActionType.CREDENTIALS_READ, responsibleUser, userId.toString());
return new UserPasswordResetLink(link, userCredentials.getResetTokenTtl());
}

2
application/src/main/java/org/thingsboard/server/service/entitiy/user/TbUserService.java

@ -32,6 +32,6 @@ public interface TbUserService {
UserActivationLink getActivationLink(TenantId tenantId, CustomerId customerId, UserId userId, HttpServletRequest request) throws ThingsboardException;
UserPasswordResetLink getPasswordResetLink(TenantId tenantId, CustomerId customerId, UserId userId, HttpServletRequest request) throws ThingsboardException;
UserPasswordResetLink getPasswordResetLink(TenantId tenantId, CustomerId customerId, UserId userId, HttpServletRequest request, User responsibleUser) throws ThingsboardException;
}

17
application/src/test/java/org/thingsboard/server/controller/AuthControllerTest.java

@ -324,25 +324,26 @@ public class AuthControllerTest extends AbstractControllerTest {
}
@Test
public void testGetPasswordResetLinkRegeneratesToken() throws Exception {
public void testGetPasswordResetLinkReusesValidToken() throws Exception {
loginTenantAdmin();
User user = new User();
user.setAuthority(Authority.TENANT_ADMIN);
user.setEmail("tenant-admin-reset-regen@thingsboard.org");
user.setEmail("tenant-admin-reset-reuse@thingsboard.org");
User savedUser = createUserAndLogin(user, "initialPassword1");
loginTenantAdmin();
// repeated calls are idempotent while the token is still valid — no churn for prefetch/double-click
UserPasswordResetLink first = getPasswordResetLinkInfo(savedUser);
UserPasswordResetLink second = getPasswordResetLinkInfo(savedUser);
assertThat(second.value()).isNotEqualTo(first.value());
assertThat(second.value()).isEqualTo(first.value());
// first token must now be invalid — second one wins
String firstToken = StringUtils.substringAfterLast(first.value(), "resetToken=");
// the reused link still works end-to-end
String resetToken = StringUtils.substringAfterLast(first.value(), "resetToken=");
Mockito.doNothing().when(mailService).sendPasswordWasResetEmail(anyString(), anyString());
doPost("/api/noauth/resetPassword", JacksonUtil.newObjectNode()
.put("resetToken", firstToken)
.put("resetToken", resetToken)
.put("password", "newPassword1"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.message", is("Invalid reset token!")));
.andExpect(status().isOk());
}
@Test

2
common/dao-api/src/main/java/org/thingsboard/server/dao/user/UserService.java

@ -65,6 +65,8 @@ public interface UserService extends EntityDaoService {
UserCredentials checkUserActivationToken(TenantId tenantId, UserCredentials userCredentials);
UserCredentials checkUserPasswordResetToken(TenantId tenantId, UserCredentials userCredentials);
UserCredentials replaceUserCredentials(TenantId tenantId, UserCredentials userCredentials);
void deleteUser(TenantId tenantId, User user);

10
dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java

@ -304,6 +304,16 @@ public class UserServiceImpl extends AbstractCachedEntityService<UserCacheKey, U
return userCredentials;
}
@Override
public UserCredentials checkUserPasswordResetToken(TenantId tenantId, UserCredentials userCredentials) {
if (userCredentials.getResetTokenTtl() < TimeUnit.MINUTES.toMillis(15)) { // renew link if less than 15 minutes before expiration
userCredentials = generatePasswordResetToken(userCredentials);
userCredentials = saveUserCredentials(tenantId, userCredentials);
log.debug("[{}][{}] Regenerated expired user password reset token", tenantId, userCredentials.getUserId());
}
return userCredentials;
}
@Override
public UserCredentials replaceUserCredentials(TenantId tenantId, UserCredentials userCredentials) {
log.trace("Executing replaceUserCredentials [{}]", userCredentials);

9
rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java

@ -74,6 +74,7 @@ import org.thingsboard.server.common.data.UpdateMessage;
import org.thingsboard.server.common.data.UsageInfo;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.UserEmailInfo;
import org.thingsboard.server.common.data.UserPasswordResetLink;
import org.thingsboard.server.common.data.alarm.Alarm;
import org.thingsboard.server.common.data.alarm.AlarmComment;
import org.thingsboard.server.common.data.alarm.AlarmCommentInfo;
@ -2831,6 +2832,14 @@ public class RestClient implements Closeable {
return restTemplate.getForEntity(baseURL + "/api/user/{userId}/activationLink", String.class, userId.getId()).getBody();
}
public String getPasswordResetLink(UserId userId) {
return restTemplate.getForEntity(baseURL + "/api/user/{userId}/passwordResetLink", String.class, userId.getId()).getBody();
}
public UserPasswordResetLink getPasswordResetLinkInfo(UserId userId) {
return restTemplate.getForEntity(baseURL + "/api/user/{userId}/passwordResetLinkInfo", UserPasswordResetLink.class, userId.getId()).getBody();
}
public void deleteUser(UserId userId) {
restTemplate.delete(baseURL + "/api/user/{userId}", userId.getId());
}

Loading…
Cancel
Save