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 be0528734a..c0230dc33c 100644 --- a/application/src/main/java/org/thingsboard/server/controller/UserController.java +++ b/application/src/main/java/org/thingsboard/server/controller/UserController.java @@ -44,6 +44,7 @@ import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.UserActivationLink; 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.exception.ThingsboardErrorCode; import org.thingsboard.server.common.data.exception.ThingsboardException; @@ -251,6 +252,37 @@ public class UserController extends BaseController { return tbUserService.getActivationLink(securityUser.getTenantId(), securityUser.getCustomerId(), userId, request); } + @ApiOperation(value = "Get password reset link (getPasswordResetLink)", + notes = "Generate and return a password reset link for the specified 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')") + @GetMapping(value = "/user/{userId}/passwordResetLink", produces = "text/plain") + @ResponseBody + public String getPasswordResetLink(@Parameter(description = USER_ID_PARAM_DESCRIPTION) + @PathVariable(USER_ID) String strUserId, + HttpServletRequest request) throws ThingsboardException { + return getPasswordResetLinkInfo(strUserId, request).value(); + } + + @ApiOperation(value = "Get password reset link info (getPasswordResetLinkInfo)", + notes = "Generate and return a password reset link info for the specified 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')") + @GetMapping(value = "/user/{userId}/passwordResetLinkInfo") + public UserPasswordResetLink getPasswordResetLinkInfo(@Parameter(description = USER_ID_PARAM_DESCRIPTION) + @PathVariable(USER_ID) String strUserId, + HttpServletRequest request) throws ThingsboardException { + checkParameter(USER_ID, strUserId); + UserId userId = new UserId(toUUID(strUserId)); + checkUserId(userId, Operation.WRITE_CREDENTIALS); + SecurityUser securityUser = getCurrentUser(); + return tbUserService.getPasswordResetLink(securityUser.getTenantId(), securityUser.getCustomerId(), userId, request, securityUser); + } + @ApiOperation(value = "Delete User (deleteUser)", notes = "Deletes the User, it's credentials and all the relations (from and to the User). " + "Referencing non-existing User Id will cause an error. " + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/user/DefaultUserService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/user/DefaultUserService.java index a1e06860b4..d6b9306377 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/user/DefaultUserService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/user/DefaultUserService.java @@ -23,6 +23,7 @@ import org.thingsboard.rule.engine.api.MailService; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.UserActivationLink; +import org.thingsboard.server.common.data.UserPasswordResetLink; import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; import org.thingsboard.server.common.data.exception.ThingsboardException; @@ -97,4 +98,22 @@ public class DefaultUserService extends AbstractTbEntityService implements TbUse } } + @Override + 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) { + throw new ThingsboardException("User is not yet activated!", ThingsboardErrorCode.BAD_REQUEST_PARAMS); + } + throw new ThingsboardException("User account is disabled!", ThingsboardErrorCode.BAD_REQUEST_PARAMS); + } + 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()); + } + } diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/user/TbUserService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/user/TbUserService.java index 2378aa73b1..0bf23fb55e 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/user/TbUserService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/user/TbUserService.java @@ -16,8 +16,9 @@ package org.thingsboard.server.service.entitiy.user; import jakarta.servlet.http.HttpServletRequest; -import org.thingsboard.server.common.data.UserActivationLink; import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.UserActivationLink; +import org.thingsboard.server.common.data.UserPasswordResetLink; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; @@ -31,4 +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, User responsibleUser) throws ThingsboardException; + } diff --git a/application/src/test/java/org/thingsboard/server/controller/AuthControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AuthControllerTest.java index 419f442f87..2878ee138b 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AuthControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AuthControllerTest.java @@ -27,6 +27,7 @@ import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.UserActivationLink; +import org.thingsboard.server.common.data.UserPasswordResetLink; import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.common.data.security.UserCredentials; @@ -294,6 +295,94 @@ public class AuthControllerTest extends AbstractControllerTest { assertThat(getUser(user.getId()).getAdditionalInfo().get("userActivated").asBoolean()).isTrue(); } + @Test + public void testGetPasswordResetLink() throws Exception { + loginTenantAdmin(); + User user = new User(); + user.setAuthority(Authority.TENANT_ADMIN); + user.setEmail("tenant-admin-reset@thingsboard.org"); + User savedUser = createUserAndLogin(user, "initialPassword1"); + loginTenantAdmin(); + + String resetLink = getPasswordResetLink(savedUser); + assertThat(resetLink).contains("/api/noauth/resetPassword?resetToken="); + + UserPasswordResetLink resetLinkInfo = getPasswordResetLinkInfo(savedUser); + assertThat(resetLinkInfo.value()).contains("/api/noauth/resetPassword?resetToken="); + assertThat(resetLinkInfo.ttlMs()).isPositive(); + + // verify the link works end-to-end (use the freshest one) + String resetToken = StringUtils.substringAfterLast(resetLinkInfo.value(), "resetToken="); + Mockito.doNothing().when(mailService).sendPasswordWasResetEmail(anyString(), anyString()); + doPost("/api/noauth/resetPassword", JacksonUtil.newObjectNode() + .put("resetToken", resetToken) + .put("password", "newPassword1")) + .andExpect(status().isOk()); + + resetTokens(); + loginUser(savedUser.getEmail(), "newPassword1"); + } + + @Test + public void testGetPasswordResetLinkReusesValidToken() throws Exception { + loginTenantAdmin(); + User user = new User(); + user.setAuthority(Authority.TENANT_ADMIN); + 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()).isEqualTo(first.value()); + + // 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", resetToken) + .put("password", "newPassword1")) + .andExpect(status().isOk()); + } + + @Test + public void testGetPasswordResetLinkForNotYetActivatedUser() throws Exception { + loginTenantAdmin(); + User user = new User(); + user.setAuthority(Authority.TENANT_ADMIN); + user.setEmail("tenant-admin-reset-not-activated@thingsboard.org"); + user = doPost("/api/user", user, User.class); + + doGet("/api/user/" + user.getId() + "/passwordResetLinkInfo") + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message", is("User is not yet activated!"))); + } + + @Test + public void testGetPasswordResetLinkForDisabledUser() throws Exception { + loginTenantAdmin(); + User user = new User(); + user.setAuthority(Authority.TENANT_ADMIN); + user.setEmail("tenant-admin-reset-disabled@thingsboard.org"); + User savedUser = createUserAndLogin(user, "initialPassword1"); + loginTenantAdmin(); + + doPost("/api/user/" + savedUser.getId() + "/userCredentialsEnabled?userCredentialsEnabled=false") + .andExpect(status().isOk()); + + doGet("/api/user/" + savedUser.getId() + "/passwordResetLinkInfo") + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message", is("User account is disabled!"))); + } + + @Test + public void testGetPasswordResetLinkForbiddenForCustomerUser() throws Exception { + loginCustomerUser(); + doGet("/api/user/" + customerUserId + "/passwordResetLinkInfo") + .andExpect(status().isForbidden()); + } + @Test public void testGetPageWithoutRedirect() throws Exception { doGet("/login").andExpect(status().isOk()); @@ -322,4 +411,12 @@ public class AuthControllerTest extends AbstractControllerTest { return doGet("/api/user/" + user.getId() + "/activationLinkInfo", UserActivationLink.class); } + private String getPasswordResetLink(User user) throws Exception { + return doGet("/api/user/" + user.getId() + "/passwordResetLink", String.class); + } + + private UserPasswordResetLink getPasswordResetLinkInfo(User user) throws Exception { + return doGet("/api/user/" + user.getId() + "/passwordResetLinkInfo", UserPasswordResetLink.class); + } + } diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/user/UserService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/user/UserService.java index 60a54037ab..91ea743f65 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/user/UserService.java +++ b/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); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/UserPasswordResetLink.java b/common/data/src/main/java/org/thingsboard/server/common/data/UserPasswordResetLink.java new file mode 100644 index 0000000000..0ed1e630b2 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/UserPasswordResetLink.java @@ -0,0 +1,19 @@ +/** + * Copyright © 2016-2026 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; + +public record UserPasswordResetLink(String value, long ttlMs) { +} 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 a5fb56e7eb..e82d24de20 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 @@ -304,6 +304,16 @@ public class UserServiceImpl extends AbstractCachedEntityService(`/api/user/${userId}/activationLinkInfo`, defaultHttpOptionsFromConfig(config)); } + public getPasswordResetLinkInfo(userId: string, config?: RequestConfig): Observable { + return this.http.get(`/api/user/${userId}/passwordResetLinkInfo`, defaultHttpOptionsFromConfig(config)); + } + public sendActivationEmail(email: string, config?: RequestConfig) { const encodeEmail = encodeURIComponent(email); return this.http.post(`/api/user/sendActivationMail?email=${encodeEmail}`, null, defaultHttpOptionsFromConfig(config)); diff --git a/ui-ngx/src/app/modules/home/pages/user/password-reset-link-dialog.component.html b/ui-ngx/src/app/modules/home/pages/user/password-reset-link-dialog.component.html new file mode 100644 index 0000000000..930779e6a3 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/user/password-reset-link-dialog.component.html @@ -0,0 +1,57 @@ + +
+ +

user.password-reset-link

+ + +
+ + +
+
+
+ +
+
{{ passwordResetLink }}
+ +
+
+
+
+ +
+
diff --git a/ui-ngx/src/app/modules/home/pages/user/password-reset-link-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/user/password-reset-link-dialog.component.ts new file mode 100644 index 0000000000..265ab35a97 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/user/password-reset-link-dialog.component.ts @@ -0,0 +1,69 @@ +/// +/// Copyright © 2016-2026 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. +/// + +import { Component, Inject } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { TranslateService } from '@ngx-translate/core'; +import { ActionNotificationShow } from '@core/notification/notification.actions'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { Router } from '@angular/router'; +import { PasswordResetLinkInfo } from '@shared/models/user.model'; +import { MillisecondsToTimeStringPipe } from '@shared/pipe/milliseconds-to-time-string.pipe'; + +export interface PasswordResetLinkDialogData { + passwordResetLinkInfo: PasswordResetLinkInfo; +} + +@Component({ + selector: 'tb-password-reset-link-dialog', + templateUrl: './password-reset-link-dialog.component.html', + standalone: false +}) +export class PasswordResetLinkDialogComponent extends DialogComponent { + + passwordResetLink: string; + passwordResetLinkTtl: string; + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: PasswordResetLinkDialogData, + public dialogRef: MatDialogRef, + private translate: TranslateService, + private millisecondsToTimeStringPipe: MillisecondsToTimeStringPipe) { + super(store, router, dialogRef); + this.passwordResetLink = this.data.passwordResetLinkInfo.value; + this.passwordResetLinkTtl = this.millisecondsToTimeStringPipe.transform(this.data.passwordResetLinkInfo.ttlMs); + } + + close(): void { + this.dialogRef.close(); + } + + onPasswordResetLinkCopied() { + this.store.dispatch(new ActionNotificationShow( + { + message: this.translate.instant('user.password-reset-link-copied-message'), + type: 'success', + target: 'passwordResetLinkDialogContent', + duration: 1200, + verticalPosition: 'bottom', + horizontalPosition: 'left' + })); + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/user/user.component.html b/ui-ngx/src/app/modules/home/pages/user/user.component.html index 1c3f13fcc2..2a99bbf6dd 100644 --- a/ui-ngx/src/app/modules/home/pages/user/user.component.html +++ b/ui-ngx/src/app/modules/home/pages/user/user.component.html @@ -40,6 +40,12 @@ [class.!hidden]="isEdit || isUserActivated()"> {{'user.display-activation-link' | translate }} +