Add admin password reset functionality for users and customers

This commit is contained in:
Mazyar
2026-05-29 20:15:09 +03:30
parent 42cd0452ce
commit bb221f3cda
20 changed files with 686 additions and 16 deletions
+1
View File
@@ -41,6 +41,7 @@ type User struct {
DeviceToken []string `bson:"device_token"`
IsVerified bool `bson:"is_verified"`
LastLoginAt *int64 `bson:"last_login_at"`
UpdatedBy *string `bson:"updated_by,omitempty"`
}
// SetID sets the user ID (implements IDSetter interface)
+5
View File
@@ -79,6 +79,11 @@ type UpdateUserStatusForm struct {
Status string `json:"status" valid:"required,in(active|inactive|suspended)"`
}
// AdminResetPasswordResponse is returned when an admin resets another user's password.
type AdminResetPasswordResponse struct {
Password string `json:"password"`
}
type UpdateUserRoleForm struct {
Role string `json:"role" valid:"required,in(admin|manager|operator|viewer)"`
}
+45
View File
@@ -458,3 +458,48 @@ func (h *Handler) UpdateUserStatus(c echo.Context) error {
"message": "User status updated successfully",
}, "User status updated successfully")
}
// ResetUserPassword resets an admin user's password (admin only).
// @Summary Reset admin user password
// @Description Generate a new password for an admin user. The plaintext is returned once for manual delivery.
// @Tags Admin-Users
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "User ID"
// @Success 200 {object} response.APIResponse{data=AdminResetPasswordResponse}
// @Failure 400 {object} response.APIResponse
// @Failure 401 {object} response.APIResponse
// @Failure 403 {object} response.APIResponse
// @Failure 404 {object} response.APIResponse
// @Failure 409 {object} response.APIResponse
// @Failure 429 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse
// @Router /admin/v1/users/{id}/reset-password [post]
func (h *Handler) ResetUserPassword(c echo.Context) error {
c.Response().Header().Set("Cache-Control", "no-store")
actorID, err := GetUserIDFromContext(c)
if err != nil {
return response.Unauthorized(c, "Unauthorized")
}
targetID := c.Param("id")
result, err := h.service.AdminResetPassword(c.Request().Context(), actorID, targetID)
if err != nil {
switch err.Error() {
case errInvalidUserID.Error():
return response.BadRequest(c, "Invalid id", "")
case errAdminUserNotFound.Error():
return response.NotFound(c, "Admin not found")
case errCannotResetPassword.Error():
return response.Conflict(c, "Cannot reset password for this user")
case errResetPasswordRateHit.Error():
return response.TooManyRequests(c, "Too many password reset requests")
default:
return response.InternalServerError(c, "Failed to reset password")
}
}
return response.Success(c, result, "Password has been reset successfully")
}
+1
View File
@@ -139,6 +139,7 @@ func (r *userRepository) Update(ctx context.Context, user *User) error {
"is_verified": user.IsVerified,
"last_login_at": user.LastLoginAt,
"updated_at": user.UpdatedAt,
"updated_by": user.UpdatedBy,
},
}
legacyResult, legacyErr := r.collection.UpdateOne(ctx, bson.M{"_id": legacyFilterID}, updateDoc)
+122
View File
@@ -0,0 +1,122 @@
package user
import (
"context"
"errors"
"fmt"
"time"
"tm/pkg/audit"
"tm/pkg/password"
"tm/pkg/ratelimit"
"go.mongodb.org/mongo-driver/v2/bson"
"golang.org/x/crypto/bcrypt"
)
var (
errInvalidUserID = errors.New("invalid id")
errAdminUserNotFound = errors.New("admin not found")
errCannotResetPassword = errors.New("cannot reset password for this user")
errResetPasswordFailed = errors.New("failed to reset password")
errResetPasswordRateHit = errors.New("too many password reset requests")
)
const adminPasswordResetRateLimit = 10
// AdminResetPassword generates a new password for the target admin user.
func (s *userService) AdminResetPassword(ctx context.Context, actorID, targetID string) (*AdminResetPasswordResponse, error) {
if _, err := bson.ObjectIDFromHex(targetID); err != nil {
return nil, errInvalidUserID
}
if err := s.checkPasswordResetRateLimit(ctx, actorID, "admin"); err != nil {
return nil, err
}
user, err := s.repository.GetByID(ctx, targetID)
if err != nil {
if err.Error() == "user not found" {
return nil, errAdminUserNotFound
}
s.logger.Error("Failed to load user for password reset", map[string]interface{}{
"error": err.Error(),
"target_id": targetID,
})
return nil, errResetPasswordFailed
}
if user.Status != UserStatusActive {
return nil, errCannotResetPassword
}
plainPassword, err := password.GenerateSecure()
if err != nil {
s.logger.Error("Failed to generate password", map[string]interface{}{
"error": err.Error(),
"target_id": targetID,
})
return nil, errResetPasswordFailed
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(plainPassword), bcrypt.DefaultCost)
if err != nil {
s.logger.Error("Failed to hash generated password", map[string]interface{}{
"error": err.Error(),
"target_id": targetID,
})
return nil, errResetPasswordFailed
}
user.Password = string(hashedPassword)
user.UpdatedBy = &actorID
if err := s.repository.Update(ctx, user); err != nil {
s.logger.Error("Failed to persist reset password", map[string]interface{}{
"error": err.Error(),
"target_id": targetID,
})
return nil, errResetPasswordFailed
}
if err := s.authService.InvalidateUserSessions(targetID); err != nil {
s.logger.Error("Failed to invalidate user sessions after password reset", map[string]interface{}{
"error": err.Error(),
"target_id": targetID,
})
return nil, errResetPasswordFailed
}
s.auditLogger.Log(ctx, audit.Entry{
ActorID: actorID,
TargetType: audit.TargetTypeAdmin,
TargetID: targetID,
Action: audit.ActionPasswordReset,
})
s.logger.Info("Admin password reset completed", map[string]interface{}{
"actor_id": actorID,
"target_id": targetID,
})
return &AdminResetPasswordResponse{Password: plainPassword}, nil
}
func (s *userService) checkPasswordResetRateLimit(ctx context.Context, actorID, resource string) error {
if s.redisClient == nil {
return nil
}
key := fmt.Sprintf("ratelimit:password_reset:%s:%s", actorID, resource)
allowed, err := ratelimit.Allow(ctx, s.redisClient, key, adminPasswordResetRateLimit, time.Minute)
if err != nil {
s.logger.Warn("Password reset rate limit check failed", map[string]interface{}{
"actor_id": actorID,
"error": err.Error(),
})
return nil
}
if !allowed {
return errResetPasswordRateHit
}
return nil
}
+8 -1
View File
@@ -7,9 +7,11 @@ import (
"slices"
"strings"
"time"
"tm/pkg/audit"
"tm/pkg/authorization"
"tm/pkg/logger"
"tm/pkg/notification"
"tm/pkg/redis"
"tm/pkg/response"
"go.mongodb.org/mongo-driver/v2/bson"
@@ -26,6 +28,7 @@ type Service interface {
GetUsersByIDs(ctx context.Context, userIDs []string) (*UserListResponse, error)
Search(ctx context.Context, form *SearchUsersForm, pagination *response.Pagination) (*UserListResponse, error)
Delete(ctx context.Context, id string) error
AdminResetPassword(ctx context.Context, actorID, targetID string) (*AdminResetPasswordResponse, error)
// Profile
Login(ctx context.Context, form *LoginForm) (*AuthResponse, error)
@@ -43,16 +46,20 @@ type userService struct {
authService authorization.AuthorizationService
validator ValidationService
notification notification.SDK
redisClient redis.Client
auditLogger audit.Logger
}
// NewService creates a new user service
func NewService(repository Repository, logger logger.Logger, authService authorization.AuthorizationService, validator ValidationService, notificationSDK notification.SDK) Service {
func NewService(repository Repository, logger logger.Logger, authService authorization.AuthorizationService, validator ValidationService, notificationSDK notification.SDK, redisClient redis.Client, auditLogger audit.Logger) Service {
return &userService{
repository: repository,
logger: logger,
authService: authService,
validator: validator,
notification: notificationSDK,
redisClient: redisClient,
auditLogger: auditLogger,
}
}