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..e81d9945d1 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. " + + "Issues a fresh reset token, invalidating any previously issued reset token for this user. " + + "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. " + + "Issues a fresh reset token, invalidating any previously issued reset token for this user. " + + "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); + } + @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..1b67a97322 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,20 @@ public class DefaultUserService extends AbstractTbEntityService implements TbUse } } + @Override + public UserPasswordResetLink getPasswordResetLink(TenantId tenantId, CustomerId customerId, UserId userId, HttpServletRequest request) 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.generatePasswordResetToken(userCredentials); + userCredentials = userService.saveUserCredentials(tenantId, userCredentials); + String baseUrl = systemSecurityService.getBaseUrl(tenantId, customerId, request); + String link = baseUrl + "/api/noauth/resetPassword?resetToken=" + userCredentials.getResetToken(); + 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..442ce0b842 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) 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 9126c42f2f..4b509d6cc2 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,93 @@ 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 testGetPasswordResetLinkRegeneratesToken() throws Exception { + loginTenantAdmin(); + User user = new User(); + user.setAuthority(Authority.TENANT_ADMIN); + user.setEmail("tenant-admin-reset-regen@thingsboard.org"); + User savedUser = createUserAndLogin(user, "initialPassword1"); + loginTenantAdmin(); + + UserPasswordResetLink first = getPasswordResetLinkInfo(savedUser); + UserPasswordResetLink second = getPasswordResetLinkInfo(savedUser); + assertThat(second.value()).isNotEqualTo(first.value()); + + // first token must now be invalid — second one wins + String firstToken = StringUtils.substringAfterLast(first.value(), "resetToken="); + doPost("/api/noauth/resetPassword", JacksonUtil.newObjectNode() + .put("resetToken", firstToken) + .put("password", "newPassword1")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message", is("Invalid reset token!"))); + } + + @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 +410,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/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/ui-ngx/src/app/core/http/user.service.ts b/ui-ngx/src/app/core/http/user.service.ts index f1c1e1d72b..b18d6ae9b4 100644 --- a/ui-ngx/src/app/core/http/user.service.ts +++ b/ui-ngx/src/app/core/http/user.service.ts @@ -16,7 +16,7 @@ import { Injectable } from '@angular/core'; import { defaultHttpOptionsFromConfig, RequestConfig } from './http-utils'; -import { ActivationLinkInfo, User, UserEmailInfo } from '@shared/models/user.model'; +import { ActivationLinkInfo, PasswordResetLinkInfo, User, UserEmailInfo } from '@shared/models/user.model'; import { Observable } from 'rxjs'; import { HttpClient, HttpParams } from '@angular/common/http'; import { PageLink } from '@shared/models/page/page-link'; @@ -81,6 +81,10 @@ export class UserService { return this.http.get(`/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 }} +