Merge pull request 'Add admin password reset functionality for users and customers' (#22) from Reset-Password into develop

Reviewed-on: https://repo.ravanertebat.com/TM/tm_back/pulls/22
Reviewed-by: Hadi Barzegar <barzagarhadi@gmail.com>
This commit is contained in:
m.nazemi
2026-05-30 14:19:13 +03:30
23 changed files with 797 additions and 25 deletions
+4 -2
View File
@@ -112,6 +112,7 @@ import (
"tm/internal/tender" "tm/internal/tender"
"tm/internal/tender_approval" "tm/internal/tender_approval"
"tm/internal/user" "tm/internal/user"
"tm/pkg/audit"
"tm/pkg/filestore" "tm/pkg/filestore"
"tm/pkg/security" "tm/pkg/security"
@@ -204,10 +205,11 @@ func main() {
_ = kanban.NewValidationService() // Register hexcolor validator _ = kanban.NewValidationService() // Register hexcolor validator
// Initialize services with repositories // Initialize services with repositories
userService := user.NewService(userRepository, logger, userAuthService, userValidator, notificationSDK) auditLogger := audit.NewLogger(logger)
userService := user.NewService(userRepository, logger, userAuthService, userValidator, notificationSDK, redisClient, auditLogger)
categoryService := company_category.NewService(categoryRepository, logger) categoryService := company_category.NewService(categoryRepository, logger)
companyService := company.NewService(companyRepository, categoryService, logger) companyService := company.NewService(companyRepository, categoryService, logger)
customerService := customer.NewService(customerRepository, logger, customerAuthService, companyService, redisClient, notificationSDK, customerValidator) customerService := customer.NewService(customerRepository, logger, customerAuthService, companyService, redisClient, notificationSDK, customerValidator, auditLogger)
tenderService := tender.NewService(tenderRepository, companyService, customerService, logger, aiSummarizerClient, aiSummarizerStorage, conf.AISummarizer.DefaultLanguage) tenderService := tender.NewService(tenderRepository, companyService, customerService, logger, aiSummarizerClient, aiSummarizerStorage, conf.AISummarizer.DefaultLanguage)
feedbackService := feedback.NewService(feedbackRepo, tenderService, companyService, logger) feedbackService := feedback.NewService(feedbackRepo, tenderService, companyService, logger)
tenderApprovalService := tender_approval.NewService(tenderApprovalRepository, tenderService, logger) tenderApprovalService := tender_approval.NewService(tenderApprovalRepository, tenderService, logger)
+2
View File
@@ -47,6 +47,7 @@ func RegisterAdminRoutes(e *echo.Echo, userHandler *user.Handler, companyHandler
adminUsersGP.PUT("/:id", userHandler.UpdateUser) adminUsersGP.PUT("/:id", userHandler.UpdateUser)
adminUsersGP.DELETE("/:id", userHandler.DeleteUser) adminUsersGP.DELETE("/:id", userHandler.DeleteUser)
adminUsersGP.PUT("/:id/status", userHandler.UpdateUserStatus) adminUsersGP.PUT("/:id/status", userHandler.UpdateUserStatus)
adminUsersGP.POST("/:id/reset-password", userHandler.ResetUserPassword, userHandler.AdminMiddleware())
} }
// Admin user profile // Admin user profile
@@ -100,6 +101,7 @@ func RegisterAdminRoutes(e *echo.Echo, userHandler *user.Handler, companyHandler
customersGP.PUT("/:id", customerHandler.UpdateCustomer) customersGP.PUT("/:id", customerHandler.UpdateCustomer)
customersGP.DELETE("/:id", customerHandler.DeleteCustomer) customersGP.DELETE("/:id", customerHandler.DeleteCustomer)
customersGP.PUT("/:id/status", customerHandler.UpdateStatus) customersGP.PUT("/:id/status", customerHandler.UpdateStatus)
customersGP.POST("/:id/reset-password", customerHandler.ResetCustomerPassword)
customersGP.PATCH("/:id/role", customerHandler.AssignRole) customersGP.PATCH("/:id/role", customerHandler.AssignRole)
customersGP.PATCH("/:id/companies", customerHandler.AssignCompanies) customersGP.PATCH("/:id/companies", customerHandler.AssignCompanies)
} }
+122
View File
@@ -0,0 +1,122 @@
package customer
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 (
errInvalidCustomerID = errors.New("invalid id")
errCustomerNotFound = errors.New("customer not found")
errCannotResetCustPassword = errors.New("cannot reset password for this user")
errCustResetPasswordFailed = errors.New("failed to reset password")
errCustResetPasswordRate = errors.New("too many password reset requests")
)
const customerPasswordResetRateLimit = 10
// AdminResetPassword generates a new password for the target customer.
func (s *customerService) AdminResetPassword(ctx context.Context, actorID, targetID string) (*AdminResetPasswordResponse, error) {
if _, err := bson.ObjectIDFromHex(targetID); err != nil {
return nil, errInvalidCustomerID
}
if err := s.checkPasswordResetRateLimit(ctx, actorID, "customer"); err != nil {
return nil, err
}
customer, err := s.repository.GetByID(ctx, targetID)
if err != nil {
if errors.Is(err, ErrCustomerNotFound) {
return nil, errCustomerNotFound
}
s.logger.Error("Failed to load customer for password reset", map[string]interface{}{
"error": err.Error(),
"customer_id": targetID,
})
return nil, errCustResetPasswordFailed
}
if customer.Status != CustomerStatusActive {
return nil, errCannotResetCustPassword
}
plainPassword, err := password.GenerateSecure()
if err != nil {
s.logger.Error("Failed to generate password", map[string]interface{}{
"error": err.Error(),
"customer_id": targetID,
})
return nil, errCustResetPasswordFailed
}
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(),
"customer_id": targetID,
})
return nil, errCustResetPasswordFailed
}
if err := s.repository.UpdatePasswordReset(ctx, targetID, string(hashedPassword), actorID); err != nil {
if errors.Is(err, ErrCustomerNotFound) {
return nil, errCustomerNotFound
}
s.logger.Error("Failed to persist reset password", map[string]interface{}{
"error": err.Error(),
"customer_id": targetID,
})
return nil, errCustResetPasswordFailed
}
if err := s.authService.InvalidateUserSessions(targetID); err != nil {
s.logger.Error("Failed to invalidate customer sessions after password reset", map[string]interface{}{
"error": err.Error(),
"customer_id": targetID,
})
}
s.auditLogger.Log(ctx, audit.Entry{
ActorID: actorID,
TargetType: audit.TargetTypeCustomer,
TargetID: targetID,
Action: audit.ActionPasswordReset,
})
s.logger.Info("Customer password reset completed", map[string]interface{}{
"actor_id": actorID,
"customer_id": targetID,
})
return &AdminResetPasswordResponse{Password: plainPassword}, nil
}
func (s *customerService) 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, customerPasswordResetRateLimit, 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 errCustResetPasswordRate
}
return nil
}
+1
View File
@@ -59,6 +59,7 @@ type Customer struct {
KeywordsStatus KeywordsStatus `bson:"keywords_status,omitempty"` KeywordsStatus KeywordsStatus `bson:"keywords_status,omitempty"`
DeviceToken []string `bson:"device_token"` DeviceToken []string `bson:"device_token"`
LastLoginAt *int64 `bson:"last_login_at"` LastLoginAt *int64 `bson:"last_login_at"`
UpdatedBy *string `bson:"updated_by,omitempty"`
UpdatedAt int64 `bson:"updated_at"` UpdatedAt int64 `bson:"updated_at"`
CreatedAt int64 `bson:"created_at"` CreatedAt int64 `bson:"created_at"`
} }
+7
View File
@@ -0,0 +1,7 @@
package customer
import "errors"
var (
ErrCustomerNotFound = errors.New("customer not found")
)
+5
View File
@@ -37,6 +37,11 @@ type UpdateStatusForm struct {
Reason string `json:"reason" valid:"required,length(10|500)" example:"Customer is active"` Reason string `json:"reason" valid:"required,length(10|500)" example:"Customer is active"`
} }
// AdminResetPasswordResponse is returned when an admin resets a customer's password.
type AdminResetPasswordResponse struct {
Password string `json:"password"`
}
// ListCustomersForm represents the form for listing customers with filters // ListCustomersForm represents the form for listing customers with filters
type SearchCustomersForm struct { type SearchCustomersForm struct {
Search *string `query:"q" valid:"optional"` Search *string `query:"q" valid:"optional"`
+46
View File
@@ -1,6 +1,7 @@
package customer package customer
import ( import (
"errors"
"strings" "strings"
"tm/internal/user" "tm/internal/user"
"tm/pkg/authorization" "tm/pkg/authorization"
@@ -240,6 +241,51 @@ func (h *Handler) UpdateStatus(c echo.Context) error {
}, "Customer status updated successfully") }, "Customer status updated successfully")
} }
// ResetCustomerPassword resets a customer's password (admin only).
// @Summary Reset customer password
// @Description Generate a new password for a customer. The plaintext is returned once for manual delivery.
// @Tags Admin-Customers
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "Customer 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/customers/{id}/reset-password [post]
func (h *Handler) ResetCustomerPassword(c echo.Context) error {
c.Response().Header().Set("Cache-Control", "no-store")
actorID, err := user.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 {
case errors.Is(err, errInvalidCustomerID):
return response.BadRequest(c, "Invalid id", "")
case errors.Is(err, errCustomerNotFound):
return response.NotFound(c, "Customer not found")
case errors.Is(err, errCannotResetCustPassword):
return response.Conflict(c, "Cannot reset password for this user")
case errors.Is(err, errCustResetPasswordRate):
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")
}
// AssignRole assigns a role to a customer (Web Panel) // AssignRole assigns a role to a customer (Web Panel)
// @Summary Assign role to customer // @Summary Assign role to customer
// @Description Assign a role (admin or analyst) to a customer // @Description Assign a role (admin or analyst) to a customer
+41 -4
View File
@@ -3,12 +3,14 @@ package customer
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"time" "time"
"tm/pkg/logger" "tm/pkg/logger"
orm "tm/pkg/mongo" orm "tm/pkg/mongo"
"tm/pkg/response" "tm/pkg/response"
"go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/bson"
mongopkg "go.mongodb.org/mongo-driver/v2/mongo"
) )
// Repository defines the interface for customer data operations // Repository defines the interface for customer data operations
@@ -16,6 +18,7 @@ type Repository interface {
Login(ctx context.Context, id, deviceToken string) error Login(ctx context.Context, id, deviceToken string) error
Register(ctx context.Context, customer *Customer) error Register(ctx context.Context, customer *Customer) error
Update(ctx context.Context, customer *Customer) error Update(ctx context.Context, customer *Customer) error
UpdatePasswordReset(ctx context.Context, id, hashedPassword, updatedBy string) error
UpdateStatus(ctx context.Context, id string, status CustomerStatus) error UpdateStatus(ctx context.Context, id string, status CustomerStatus) error
GetByID(ctx context.Context, id string) (*Customer, error) GetByID(ctx context.Context, id string) (*Customer, error)
GetByEmail(ctx context.Context, email string) (*Customer, error) GetByEmail(ctx context.Context, email string) (*Customer, error)
@@ -31,6 +34,7 @@ type Repository interface {
// customerRepository implements the Repository interface using the MongoDB ORM // customerRepository implements the Repository interface using the MongoDB ORM
type customerRepository struct { type customerRepository struct {
ormRepo orm.Repository[Customer] ormRepo orm.Repository[Customer]
collection *mongopkg.Collection
logger logger.Logger logger logger.Logger
} }
@@ -55,10 +59,12 @@ func NewRepository(mongoManager *orm.ConnectionManager, logger logger.Logger) Re
} }
// Create ORM repository // Create ORM repository
ormRepo := orm.NewRepository[Customer](mongoManager.GetCollection(collection()), logger) collection := mongoManager.GetCollection(collection())
ormRepo := orm.NewRepository[Customer](collection, logger)
return &customerRepository{ return &customerRepository{
ormRepo: ormRepo, ormRepo: ormRepo,
collection: collection,
logger: logger, logger: logger,
} }
} }
@@ -128,7 +134,7 @@ func (r *customerRepository) GetByID(ctx context.Context, id string) (*Customer,
customer, err := r.ormRepo.FindByID(ctx, id) customer, err := r.ormRepo.FindByID(ctx, id)
if err != nil { if err != nil {
if errors.Is(err, orm.ErrDocumentNotFound) { if errors.Is(err, orm.ErrDocumentNotFound) {
return nil, errors.New("customer not found") return nil, ErrCustomerNotFound
} }
r.logger.Error("Failed to get customer by ID", map[string]interface{}{ r.logger.Error("Failed to get customer by ID", map[string]interface{}{
"error": err.Error(), "error": err.Error(),
@@ -146,7 +152,7 @@ func (r *customerRepository) GetByEmail(ctx context.Context, email string) (*Cus
customer, err := r.ormRepo.FindOne(ctx, filter) customer, err := r.ormRepo.FindOne(ctx, filter)
if err != nil { if err != nil {
if errors.Is(err, orm.ErrDocumentNotFound) { if errors.Is(err, orm.ErrDocumentNotFound) {
return nil, errors.New("customer not found") return nil, ErrCustomerNotFound
} }
r.logger.Error("Failed to get customer by email", map[string]interface{}{ r.logger.Error("Failed to get customer by email", map[string]interface{}{
"error": err.Error(), "error": err.Error(),
@@ -164,7 +170,7 @@ func (r *customerRepository) GetByUsername(ctx context.Context, username string)
customer, err := r.ormRepo.FindOne(ctx, filter) customer, err := r.ormRepo.FindOne(ctx, filter)
if err != nil { if err != nil {
if errors.Is(err, orm.ErrDocumentNotFound) { if errors.Is(err, orm.ErrDocumentNotFound) {
return nil, errors.New("customer not found") return nil, ErrCustomerNotFound
} }
r.logger.Error("Failed to get customer by username", map[string]interface{}{ r.logger.Error("Failed to get customer by username", map[string]interface{}{
"error": err.Error(), "error": err.Error(),
@@ -295,6 +301,37 @@ func (r *customerRepository) Update(ctx context.Context, customer *Customer) err
return nil return nil
} }
// UpdatePasswordReset atomically persists a new password hash and audit fields.
func (r *customerRepository) UpdatePasswordReset(ctx context.Context, id, hashedPassword, updatedBy string) error {
now := time.Now().Unix()
updateDoc := bson.M{
"$set": bson.M{
"password": hashedPassword,
"updated_at": now,
"updated_by": updatedBy,
},
}
objectID, err := bson.ObjectIDFromHex(id)
if err != nil {
return fmt.Errorf("invalid id format: %w", err)
}
result, err := r.collection.UpdateOne(ctx, bson.M{"_id": objectID}, updateDoc)
if err != nil {
r.logger.Error("Failed to update password reset", map[string]interface{}{
"error": err.Error(),
"customer_id": id,
})
return err
}
if result.MatchedCount == 0 {
return ErrCustomerNotFound
}
return nil
}
// Delete deletes a customer (hard delete) // Delete deletes a customer (hard delete)
func (r *customerRepository) Delete(ctx context.Context, id string) error { func (r *customerRepository) Delete(ctx context.Context, id string) error {
// Get customer first // Get customer first
+5 -1
View File
@@ -11,6 +11,7 @@ import (
"time" "time"
"tm/internal/company" "tm/internal/company"
"tm/pkg/audit"
"tm/pkg/logger" "tm/pkg/logger"
"tm/pkg/notification" "tm/pkg/notification"
"tm/pkg/redis" "tm/pkg/redis"
@@ -35,6 +36,7 @@ type Service interface {
Update(ctx context.Context, id string, form *UpdateCustomerForm) (*CustomerResponse, error) Update(ctx context.Context, id string, form *UpdateCustomerForm) (*CustomerResponse, error)
Delete(ctx context.Context, id string) error Delete(ctx context.Context, id string) error
UpdateStatus(ctx context.Context, id string, form *UpdateStatusForm) error UpdateStatus(ctx context.Context, id string, form *UpdateStatusForm) error
AdminResetPassword(ctx context.Context, actorID, targetID string) (*AdminResetPasswordResponse, error)
// Authentication operations // Authentication operations
Login(ctx context.Context, form *LoginForm) (*AuthResponse, error) Login(ctx context.Context, form *LoginForm) (*AuthResponse, error)
@@ -62,10 +64,11 @@ type customerService struct {
redisClient redis.Client redisClient redis.Client
notification notification.SDK notification notification.SDK
validator ValidationService validator ValidationService
auditLogger audit.Logger
} }
// New creates a new customer service // New creates a new customer service
func NewService(repository Repository, logger logger.Logger, authService authorization.AuthorizationService, companyService company.Service, redisClient redis.Client, notificationSDK notification.SDK, validator ValidationService) Service { func NewService(repository Repository, logger logger.Logger, authService authorization.AuthorizationService, companyService company.Service, redisClient redis.Client, notificationSDK notification.SDK, validator ValidationService, auditLogger audit.Logger) Service {
return &customerService{ return &customerService{
repository: repository, repository: repository,
logger: logger, logger: logger,
@@ -74,6 +77,7 @@ func NewService(repository Repository, logger logger.Logger, authService authori
redisClient: redisClient, redisClient: redisClient,
notification: notificationSDK, notification: notificationSDK,
validator: validator, validator: validator,
auditLogger: auditLogger,
} }
} }
+1
View File
@@ -41,6 +41,7 @@ type User struct {
DeviceToken []string `bson:"device_token"` DeviceToken []string `bson:"device_token"`
IsVerified bool `bson:"is_verified"` IsVerified bool `bson:"is_verified"`
LastLoginAt *int64 `bson:"last_login_at"` LastLoginAt *int64 `bson:"last_login_at"`
UpdatedBy *string `bson:"updated_by,omitempty"`
} }
// SetID sets the user ID (implements IDSetter interface) // SetID sets the user ID (implements IDSetter interface)
+7
View File
@@ -0,0 +1,7 @@
package user
import "errors"
var (
ErrUserNotFound = errors.New("user not found")
)
+5
View File
@@ -79,6 +79,11 @@ type UpdateUserStatusForm struct {
Status string `json:"status" valid:"required,in(active|inactive|suspended)"` 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 { type UpdateUserRoleForm struct {
Role string `json:"role" valid:"required,in(admin|manager|operator|viewer)"` Role string `json:"role" valid:"required,in(admin|manager|operator|viewer)"`
} }
+53
View File
@@ -1,6 +1,7 @@
package user package user
import ( import (
"errors"
"tm/pkg/authorization" "tm/pkg/authorization"
"tm/pkg/logger" "tm/pkg/logger"
"tm/pkg/response" "tm/pkg/response"
@@ -458,3 +459,55 @@ func (h *Handler) UpdateUserStatus(c echo.Context) error {
"message": "User status updated successfully", "message": "User status updated successfully",
}, "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")
}
actorRole, err := GetUserRoleFromContext(c)
if err != nil {
return response.Unauthorized(c, "Unauthorized")
}
targetID := c.Param("id")
result, err := h.service.AdminResetPassword(c.Request().Context(), actorID, actorRole, targetID)
if err != nil {
switch {
case errors.Is(err, errInvalidUserID):
return response.BadRequest(c, "Invalid id", "")
case errors.Is(err, errAdminUserNotFound):
return response.NotFound(c, "Admin not found")
case errors.Is(err, errCannotResetPassword):
return response.Conflict(c, "Cannot reset password for this user")
case errors.Is(err, errResetPasswordForbidden):
return response.Forbidden(c, "You are not allowed to reset this password")
case errors.Is(err, errResetPasswordRateHit):
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")
}
+40 -1
View File
@@ -19,6 +19,7 @@ type Repository interface {
Update(ctx context.Context, user *User) error Update(ctx context.Context, user *User) error
Delete(ctx context.Context, id string) error Delete(ctx context.Context, id string) error
GetByID(ctx context.Context, id string) (*User, error) GetByID(ctx context.Context, id string) (*User, error)
UpdatePasswordReset(ctx context.Context, id, hashedPassword, updatedBy string) error
GetByEmail(ctx context.Context, email string) (*User, error) GetByEmail(ctx context.Context, email string) (*User, error)
GetByUsername(ctx context.Context, username string) (*User, error) GetByUsername(ctx context.Context, username string) (*User, error)
GetByIDs(ctx context.Context, userIDs []string) ([]User, error) GetByIDs(ctx context.Context, userIDs []string) ([]User, error)
@@ -139,6 +140,7 @@ func (r *userRepository) Update(ctx context.Context, user *User) error {
"is_verified": user.IsVerified, "is_verified": user.IsVerified,
"last_login_at": user.LastLoginAt, "last_login_at": user.LastLoginAt,
"updated_at": user.UpdatedAt, "updated_at": user.UpdatedAt,
"updated_by": user.UpdatedBy,
}, },
} }
legacyResult, legacyErr := r.collection.UpdateOne(ctx, bson.M{"_id": legacyFilterID}, updateDoc) legacyResult, legacyErr := r.collection.UpdateOne(ctx, bson.M{"_id": legacyFilterID}, updateDoc)
@@ -176,7 +178,7 @@ func (r *userRepository) GetByID(ctx context.Context, id string) (*User, error)
return legacyUser, nil return legacyUser, nil
} }
if errors.Is(legacyErr, orm.ErrDocumentNotFound) { if errors.Is(legacyErr, orm.ErrDocumentNotFound) {
return nil, errors.New("user not found") return nil, ErrUserNotFound
} }
r.logger.Error("Failed to get user by legacy string ID", map[string]interface{}{ r.logger.Error("Failed to get user by legacy string ID", map[string]interface{}{
"error": legacyErr.Error(), "error": legacyErr.Error(),
@@ -194,6 +196,43 @@ func (r *userRepository) GetByID(ctx context.Context, id string) (*User, error)
return user, nil return user, nil
} }
// UpdatePasswordReset atomically persists a new password hash and audit fields.
func (r *userRepository) UpdatePasswordReset(ctx context.Context, id, hashedPassword, updatedBy string) error {
now := time.Now().Unix()
updateDoc := bson.M{
"$set": bson.M{
"password": hashedPassword,
"updated_at": now,
"updated_by": updatedBy,
},
}
objectID, err := bson.ObjectIDFromHex(id)
if err == nil {
result, updateErr := r.collection.UpdateOne(ctx, bson.M{"_id": objectID}, updateDoc)
if updateErr == nil && result.MatchedCount > 0 {
return nil
}
if updateErr != nil {
return updateErr
}
}
result, err := r.collection.UpdateOne(ctx, bson.M{"_id": id}, updateDoc)
if err != nil {
r.logger.Error("Failed to update password reset", map[string]interface{}{
"error": err.Error(),
"user_id": id,
})
return err
}
if result.MatchedCount == 0 {
return ErrUserNotFound
}
return nil
}
// GetByEmail retrieves a user by email // GetByEmail retrieves a user by email
func (r *userRepository) GetByEmail(ctx context.Context, email string) (*User, error) { func (r *userRepository) GetByEmail(ctx context.Context, email string) (*User, error) {
user, err := r.ormRepo.FindOne(ctx, bson.M{"email": email}) user, err := r.ormRepo.FindOne(ctx, bson.M{"email": email})
+127
View File
@@ -0,0 +1,127 @@
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")
errResetPasswordForbidden = errors.New("you are not allowed to reset this password")
)
const adminPasswordResetRateLimit = 10
// AdminResetPassword generates a new password for the target admin user.
func (s *userService) AdminResetPassword(ctx context.Context, actorID, actorRole, targetID string) (*AdminResetPasswordResponse, error) {
if UserRole(actorRole) != UserRoleAdmin {
return nil, errResetPasswordForbidden
}
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 errors.Is(err, ErrUserNotFound) {
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
}
if err := s.repository.UpdatePasswordReset(ctx, targetID, string(hashedPassword), actorID); err != nil {
if errors.Is(err, ErrUserNotFound) {
return nil, errAdminUserNotFound
}
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,
})
}
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" "slices"
"strings" "strings"
"time" "time"
"tm/pkg/audit"
"tm/pkg/authorization" "tm/pkg/authorization"
"tm/pkg/logger" "tm/pkg/logger"
"tm/pkg/notification" "tm/pkg/notification"
"tm/pkg/redis"
"tm/pkg/response" "tm/pkg/response"
"go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/bson"
@@ -26,6 +28,7 @@ type Service interface {
GetUsersByIDs(ctx context.Context, userIDs []string) (*UserListResponse, error) GetUsersByIDs(ctx context.Context, userIDs []string) (*UserListResponse, error)
Search(ctx context.Context, form *SearchUsersForm, pagination *response.Pagination) (*UserListResponse, error) Search(ctx context.Context, form *SearchUsersForm, pagination *response.Pagination) (*UserListResponse, error)
Delete(ctx context.Context, id string) error Delete(ctx context.Context, id string) error
AdminResetPassword(ctx context.Context, actorID, actorRole, targetID string) (*AdminResetPasswordResponse, error)
// Profile // Profile
Login(ctx context.Context, form *LoginForm) (*AuthResponse, error) Login(ctx context.Context, form *LoginForm) (*AuthResponse, error)
@@ -43,16 +46,20 @@ type userService struct {
authService authorization.AuthorizationService authService authorization.AuthorizationService
validator ValidationService validator ValidationService
notification notification.SDK notification notification.SDK
redisClient redis.Client
auditLogger audit.Logger
} }
// NewService creates a new user service // 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{ return &userService{
repository: repository, repository: repository,
logger: logger, logger: logger,
authService: authService, authService: authService,
validator: validator, validator: validator,
notification: notificationSDK, notification: notificationSDK,
redisClient: redisClient,
auditLogger: auditLogger,
} }
} }
+54
View File
@@ -0,0 +1,54 @@
package audit
import (
"context"
"time"
"tm/pkg/logger"
)
const ActionPasswordReset = "password.reset"
// TargetType values for audit entries.
const (
TargetTypeAdmin = "admin"
TargetTypeCustomer = "customer"
)
// Entry represents a security-relevant audit event.
type Entry struct {
ActorID string
TargetType string
TargetID string
Action string
At int64
}
// Logger records audit events.
type Logger interface {
Log(ctx context.Context, entry Entry)
}
type auditLogger struct {
logger logger.Logger
}
// NewLogger creates an audit logger backed by structured application logs.
func NewLogger(log logger.Logger) Logger {
return &auditLogger{logger: log}
}
func (a *auditLogger) Log(ctx context.Context, entry Entry) {
if entry.At == 0 {
entry.At = time.Now().Unix()
}
a.logger.Info("audit event", map[string]interface{}{
"audit": true,
"actor_id": entry.ActorID,
"target_type": entry.TargetType,
"target_id": entry.TargetID,
"action": entry.Action,
"at": entry.At,
})
}
+77
View File
@@ -9,12 +9,14 @@ import (
"encoding/hex" "encoding/hex"
"encoding/pem" "encoding/pem"
"fmt" "fmt"
"strconv"
"time" "time"
"tm/pkg/logger" "tm/pkg/logger"
"tm/pkg/redis" "tm/pkg/redis"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
redisv9 "github.com/redis/go-redis/v9"
) )
// TokenType represents the type of token // TokenType represents the type of token
@@ -84,6 +86,9 @@ type AuthorizationService interface {
// GetBlacklistStats returns statistics about the blacklist // GetBlacklistStats returns statistics about the blacklist
GetBlacklistStats(ctx context.Context) (map[string]interface{}, error) GetBlacklistStats(ctx context.Context) (map[string]interface{}, error)
// InvalidateUserSessions marks all tokens issued before now as invalid for the user.
InvalidateUserSessions(userID string) error
} }
// AuthorizationConfig holds the configuration for the authorization service // AuthorizationConfig holds the configuration for the authorization service
@@ -308,6 +313,26 @@ func (s *Service) ValidateToken(tokenString string) (*TokenValidationResult, err
}, nil }, nil
} }
if invalidated, err := s.isSessionInvalidated(claims.UserID, claims.IssuedAt); err != nil {
s.logger.Error("Failed to check session invalidation", map[string]interface{}{
"user_id": claims.UserID,
"error": err.Error(),
})
return &TokenValidationResult{
Valid: false,
Claims: nil,
Error: "session validation unavailable",
Expired: false,
}, nil
} else if invalidated {
return &TokenValidationResult{
Valid: false,
Claims: nil,
Error: "session invalidated",
Expired: false,
}, nil
}
return &TokenValidationResult{ return &TokenValidationResult{
Valid: true, Valid: true,
Claims: claims, Claims: claims,
@@ -408,6 +433,58 @@ func (s *Service) RefreshAccessToken(refreshToken string) (string, error) {
return newAccessToken, nil return newAccessToken, nil
} }
func sessionInvalidationKey(userID string) string {
return "sessions:invalidated_at:" + userID
}
// InvalidateUserSessions marks tokens issued before now as invalid for the given user.
func (s *Service) InvalidateUserSessions(userID string) error {
if s.redis == nil {
s.logger.Warn("Redis client not available, session invalidation skipped", map[string]interface{}{
"user_id": userID,
})
return nil
}
now := time.Now().Unix()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ttl := s.config.RefreshTokenExpiry + 5*time.Minute
if err := s.redis.Set(ctx, sessionInvalidationKey(userID), strconv.FormatInt(now, 10), ttl); err != nil {
return fmt.Errorf("failed to invalidate user sessions: %w", err)
}
s.logger.Info("User sessions invalidated", map[string]interface{}{
"user_id": userID,
})
return nil
}
func (s *Service) isSessionInvalidated(userID string, issuedAt *jwt.NumericDate) (bool, error) {
if s.redis == nil || issuedAt == nil {
return false, nil
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
value, err := s.redis.Get(ctx, sessionInvalidationKey(userID))
if err != nil {
if err == redisv9.Nil {
return false, nil
}
return false, err
}
invalidatedAt, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return false, err
}
return issuedAt.Time.Unix() < invalidatedAt, nil
}
// generateTokenHash generates a SHA-256 hash of the token for storage // generateTokenHash generates a SHA-256 hash of the token for storage
func (s *Service) generateTokenHash(tokenString string) string { func (s *Service) generateTokenHash(tokenString string) string {
hash := sha256.Sum256([]byte(tokenString)) hash := sha256.Sum256([]byte(tokenString))
+5
View File
@@ -25,6 +25,11 @@ func (m *MockRedisClient) Get(ctx context.Context, key string) (string, error) {
return args.String(0), args.Error(1) return args.String(0), args.Error(1)
} }
func (m *MockRedisClient) IncrWithExpire(ctx context.Context, key string, expiration time.Duration) (int64, error) {
args := m.Called(ctx, key, expiration)
return args.Get(0).(int64), args.Error(1)
}
func (m *MockRedisClient) Del(ctx context.Context, keys ...string) error { func (m *MockRedisClient) Del(ctx context.Context, keys ...string) error {
args := m.Called(ctx, keys) args := m.Called(ctx, keys)
return args.Error(0) return args.Error(0)
+114
View File
@@ -0,0 +1,114 @@
package password
import (
"crypto/rand"
"errors"
"math/big"
)
const defaultLength = 16
// charset excludes visually ambiguous characters: 0/O, 1/l/I
const (
upperChars = "ABCDEFGHJKLMNPQRSTUVWXYZ"
lowerChars = "abcdefghjkmnpqrstuvwxyz"
digitChars = "23456789"
specialChars = "!@#$%^&*-_=+"
allChars = upperChars + lowerChars + digitChars + specialChars
)
// GenerateSecure returns a cryptographically random password that satisfies
// uppercase, lowercase, digit, and special character requirements.
func GenerateSecure() (string, error) {
return GenerateSecureWithLength(defaultLength)
}
// GenerateSecureWithLength generates a password of at least 12 characters.
func GenerateSecureWithLength(length int) (string, error) {
if length < 12 {
length = 12
}
password := make([]byte, length)
requiredSets := []string{upperChars, lowerChars, digitChars, specialChars}
for i, charset := range requiredSets {
ch, err := randomChar(charset)
if err != nil {
return "", err
}
password[i] = ch
}
for i := len(requiredSets); i < length; i++ {
ch, err := randomChar(allChars)
if err != nil {
return "", err
}
password[i] = ch
}
if err := shuffle(password); err != nil {
return "", err
}
return string(password), nil
}
func randomChar(charset string) (byte, error) {
n := big.NewInt(int64(len(charset)))
idx, err := rand.Int(rand.Reader, n)
if err != nil {
return 0, err
}
return charset[idx.Int64()], nil
}
func shuffle(password []byte) error {
for i := len(password) - 1; i > 0; i-- {
jBig, err := rand.Int(rand.Reader, big.NewInt(int64(i+1)))
if err != nil {
return err
}
j := int(jBig.Int64())
password[i], password[j] = password[j], password[i]
}
return nil
}
// ValidatePolicy checks that a password meets admin-creation policy.
func ValidatePolicy(password string) error {
if len(password) < 8 {
return errors.New("password too short")
}
hasUpper, hasLower, hasDigit, hasSpecial := false, false, false, false
for _, char := range password {
switch {
case char >= 'A' && char <= 'Z':
hasUpper = true
case char >= 'a' && char <= 'z':
hasLower = true
case char >= '0' && char <= '9':
hasDigit = true
default:
if isSpecialRune(char) {
hasSpecial = true
}
}
}
if !hasUpper || !hasLower || !hasDigit || !hasSpecial {
return errors.New("password does not meet policy")
}
return nil
}
func isSpecialRune(char rune) bool {
for _, c := range specialChars {
if char == c {
return true
}
}
return false
}
+22
View File
@@ -0,0 +1,22 @@
package ratelimit
import (
"context"
"time"
"tm/pkg/redis"
)
// Allow returns whether the request is within limit for the given key.
func Allow(ctx context.Context, client redis.Client, key string, limit int64, window time.Duration) (bool, error) {
if client == nil {
return true, nil
}
count, err := client.IncrWithExpire(ctx, key, window)
if err != nil {
return false, err
}
return count <= limit, nil
}
+23
View File
@@ -23,6 +23,7 @@ type Config struct {
type Client interface { type Client interface {
Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error
Get(ctx context.Context, key string) (string, error) Get(ctx context.Context, key string) (string, error)
IncrWithExpire(ctx context.Context, key string, expiration time.Duration) (int64, error)
Del(ctx context.Context, keys ...string) error Del(ctx context.Context, keys ...string) error
Exists(ctx context.Context, keys ...string) (int64, error) Exists(ctx context.Context, keys ...string) (int64, error)
Close() error Close() error
@@ -70,6 +71,28 @@ func NewClient(config *Config, logger logger.Logger) (Client, error) {
return &client{rdb: rdb}, nil return &client{rdb: rdb}, nil
} }
const incrWithExpireScript = `
local current = redis.call("INCR", KEYS[1])
if current == 1 then
redis.call("EXPIRE", KEYS[1], ARGV[1])
else
local ttl = redis.call("TTL", KEYS[1])
if ttl == -1 then
redis.call("EXPIRE", KEYS[1], ARGV[1])
end
end
return current
`
// IncrWithExpire atomically increments a counter and ensures the key has a TTL.
func (c *client) IncrWithExpire(ctx context.Context, key string, expiration time.Duration) (int64, error) {
seconds := int64(expiration.Seconds())
if seconds < 1 {
seconds = 1
}
return c.rdb.Eval(ctx, incrWithExpireScript, []string{key}, seconds).Int64()
}
// Set sets a key-value pair with expiration // Set sets a key-value pair with expiration
func (c *client) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error { func (c *client) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error {
return c.rdb.Set(ctx, key, value, expiration).Err() return c.rdb.Set(ctx, key, value, expiration).Err()
+12
View File
@@ -143,6 +143,18 @@ func Conflict(c echo.Context, message string) error {
}) })
} }
// TooManyRequests returns a 429 Too Many Requests response
func TooManyRequests(c echo.Context, message string) error {
return c.JSON(http.StatusTooManyRequests, APIResponse{
Success: false,
Message: "Too Many Requests",
Error: &APIError{
Code: "TOO_MANY_REQUESTS",
Message: message,
},
})
}
// ValidationError returns a 422 Unprocessable Entity response // ValidationError returns a 422 Unprocessable Entity response
func ValidationError(c echo.Context, message, details string) error { func ValidationError(c echo.Context, message, details string) error {
return c.JSON(http.StatusUnprocessableEntity, APIResponse{ return c.JSON(http.StatusUnprocessableEntity, APIResponse{