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
+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)
} }
// 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 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
View File
@@ -45,22 +45,23 @@ const (
// Customer represents a customer in the tender management system // Customer represents a customer in the tender management system
// A customer can have an associated company for login purposes // A customer can have an associated company for login purposes
type Customer struct { type Customer struct {
mongo.Model `bson:",inline"` mongo.Model `bson:",inline"`
FullName *string `bson:"full_name"` FullName *string `bson:"full_name"`
Username string `bson:"username"` Username string `bson:"username"`
Email string `bson:"email"` Email string `bson:"email"`
Password string `bson:"password"` Password string `bson:"password"`
Status CustomerStatus `bson:"status"` Status CustomerStatus `bson:"status"`
Type CustomerType `bson:"type"` Type CustomerType `bson:"type"`
Role CustomerRole `bson:"role"` Role CustomerRole `bson:"role"`
Phone *string `bson:"phone"` Phone *string `bson:"phone"`
Companies []string `bson:"companies"` Companies []string `bson:"companies"`
Keywords []string `bson:"keywords,omitempty"` Keywords []string `bson:"keywords,omitempty"`
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"`
UpdatedAt int64 `bson:"updated_at"` UpdatedBy *string `bson:"updated_by,omitempty"`
CreatedAt int64 `bson:"created_at"` UpdatedAt int64 `bson:"updated_at"`
CreatedAt int64 `bson:"created_at"`
} }
// SetID sets the customer ID (implements IDSetter interface) // SetID sets the customer ID (implements IDSetter interface)
+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"`
+45
View File
@@ -240,6 +240,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 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) // 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
+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)
+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)"`
} }
+45
View File
@@ -458,3 +458,48 @@ 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")
}
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, "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)
+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" "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, 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,
})
}
+71
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,20 @@ 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(),
})
} 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 +427,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))
+10
View File
@@ -25,6 +25,16 @@ 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) Incr(ctx context.Context, key string) (int64, error) {
args := m.Called(ctx, key)
return args.Get(0).(int64), args.Error(1)
}
func (m *MockRedisClient) Expire(ctx context.Context, key string, expiration time.Duration) error {
args := m.Called(ctx, key, expiration)
return args.Error(0)
}
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
}
+35
View File
@@ -0,0 +1,35 @@
package ratelimit
import (
"context"
"time"
"tm/pkg/redis"
redisv9 "github.com/redis/go-redis/v9"
)
// 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.Incr(ctx, key)
if err != nil {
return false, err
}
if count == 1 {
if err := client.Expire(ctx, key, window); err != nil {
return false, err
}
}
return count <= limit, nil
}
// IsNotFound reports whether err indicates a missing Redis key.
func IsNotFound(err error) bool {
return err == redisv9.Nil
}
+12
View File
@@ -23,6 +23,8 @@ 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)
Incr(ctx context.Context, key string) (int64, error)
Expire(ctx context.Context, key string, expiration time.Duration) 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
@@ -80,6 +82,16 @@ func (c *client) Get(ctx context.Context, key string) (string, error) {
return c.rdb.Get(ctx, key).Result() return c.rdb.Get(ctx, key).Result()
} }
// Incr increments a key's integer value.
func (c *client) Incr(ctx context.Context, key string) (int64, error) {
return c.rdb.Incr(ctx, key).Result()
}
// Expire sets a timeout on a key.
func (c *client) Expire(ctx context.Context, key string, expiration time.Duration) error {
return c.rdb.Expire(ctx, key, expiration).Err()
}
// Del deletes one or more keys // Del deletes one or more keys
func (c *client) Del(ctx context.Context, keys ...string) error { func (c *client) Del(ctx context.Context, keys ...string) error {
return c.rdb.Del(ctx, keys...).Err() return c.rdb.Del(ctx, keys...).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{