Add admin password reset functionality for users and customers
This commit is contained in:
@@ -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 err.Error() == "customer not found" {
|
||||
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
|
||||
}
|
||||
|
||||
customer.Password = string(hashedPassword)
|
||||
customer.UpdatedBy = &actorID
|
||||
if err := s.repository.Update(ctx, customer); err != nil {
|
||||
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,
|
||||
})
|
||||
return nil, errCustResetPasswordFailed
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
+13
-12
@@ -45,22 +45,23 @@ const (
|
||||
// Customer represents a customer in the tender management system
|
||||
// A customer can have an associated company for login purposes
|
||||
type Customer struct {
|
||||
mongo.Model `bson:",inline"`
|
||||
FullName *string `bson:"full_name"`
|
||||
Username string `bson:"username"`
|
||||
Email string `bson:"email"`
|
||||
Password string `bson:"password"`
|
||||
Status CustomerStatus `bson:"status"`
|
||||
Type CustomerType `bson:"type"`
|
||||
Role CustomerRole `bson:"role"`
|
||||
Phone *string `bson:"phone"`
|
||||
mongo.Model `bson:",inline"`
|
||||
FullName *string `bson:"full_name"`
|
||||
Username string `bson:"username"`
|
||||
Email string `bson:"email"`
|
||||
Password string `bson:"password"`
|
||||
Status CustomerStatus `bson:"status"`
|
||||
Type CustomerType `bson:"type"`
|
||||
Role CustomerRole `bson:"role"`
|
||||
Phone *string `bson:"phone"`
|
||||
Companies []string `bson:"companies"`
|
||||
Keywords []string `bson:"keywords,omitempty"`
|
||||
KeywordsStatus KeywordsStatus `bson:"keywords_status,omitempty"`
|
||||
DeviceToken []string `bson:"device_token"`
|
||||
LastLoginAt *int64 `bson:"last_login_at"`
|
||||
UpdatedAt int64 `bson:"updated_at"`
|
||||
CreatedAt int64 `bson:"created_at"`
|
||||
LastLoginAt *int64 `bson:"last_login_at"`
|
||||
UpdatedBy *string `bson:"updated_by,omitempty"`
|
||||
UpdatedAt int64 `bson:"updated_at"`
|
||||
CreatedAt int64 `bson:"created_at"`
|
||||
}
|
||||
|
||||
// SetID sets the customer ID (implements IDSetter interface)
|
||||
|
||||
@@ -37,6 +37,11 @@ type UpdateStatusForm struct {
|
||||
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
|
||||
type SearchCustomersForm struct {
|
||||
Search *string `query:"q" valid:"optional"`
|
||||
|
||||
@@ -240,6 +240,51 @@ func (h *Handler) UpdateStatus(c echo.Context) error {
|
||||
}, "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 err.Error() {
|
||||
case errInvalidCustomerID.Error():
|
||||
return response.BadRequest(c, "Invalid id", "")
|
||||
case errCustomerNotFound.Error():
|
||||
return response.NotFound(c, "Customer not found")
|
||||
case errCannotResetCustPassword.Error():
|
||||
return response.Conflict(c, "Cannot reset password for this user")
|
||||
case errCustResetPasswordRate.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")
|
||||
}
|
||||
|
||||
// AssignRole assigns a role to a customer (Web Panel)
|
||||
// @Summary Assign role to customer
|
||||
// @Description Assign a role (admin or analyst) to a customer
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"tm/internal/company"
|
||||
"tm/pkg/audit"
|
||||
"tm/pkg/logger"
|
||||
"tm/pkg/notification"
|
||||
"tm/pkg/redis"
|
||||
@@ -35,6 +36,7 @@ type Service interface {
|
||||
Update(ctx context.Context, id string, form *UpdateCustomerForm) (*CustomerResponse, error)
|
||||
Delete(ctx context.Context, id string) error
|
||||
UpdateStatus(ctx context.Context, id string, form *UpdateStatusForm) error
|
||||
AdminResetPassword(ctx context.Context, actorID, targetID string) (*AdminResetPasswordResponse, error)
|
||||
|
||||
// Authentication operations
|
||||
Login(ctx context.Context, form *LoginForm) (*AuthResponse, error)
|
||||
@@ -62,10 +64,11 @@ type customerService struct {
|
||||
redisClient redis.Client
|
||||
notification notification.SDK
|
||||
validator ValidationService
|
||||
auditLogger audit.Logger
|
||||
}
|
||||
|
||||
// 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{
|
||||
repository: repository,
|
||||
logger: logger,
|
||||
@@ -74,6 +77,7 @@ func NewService(repository Repository, logger logger.Logger, authService authori
|
||||
redisClient: redisClient,
|
||||
notification: notificationSDK,
|
||||
validator: validator,
|
||||
auditLogger: auditLogger,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user