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
+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/pem"
"fmt"
"strconv"
"time"
"tm/pkg/logger"
"tm/pkg/redis"
"github.com/golang-jwt/jwt/v5"
redisv9 "github.com/redis/go-redis/v9"
)
// TokenType represents the type of token
@@ -84,6 +86,9 @@ type AuthorizationService interface {
// GetBlacklistStats returns statistics about the blacklist
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
@@ -308,6 +313,20 @@ func (s *Service) ValidateToken(tokenString string) (*TokenValidationResult, err
}, 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{
Valid: true,
Claims: claims,
@@ -408,6 +427,58 @@ func (s *Service) RefreshAccessToken(refreshToken string) (string, error) {
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
func (s *Service) generateTokenHash(tokenString string) string {
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)
}
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 {
args := m.Called(ctx, keys)
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 {
Set(ctx context.Context, key string, value interface{}, expiration time.Duration) 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
Exists(ctx context.Context, keys ...string) (int64, 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()
}
// 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
func (c *client) Del(ctx context.Context, keys ...string) error {
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
func ValidationError(c echo.Context, message, details string) error {
return c.JSON(http.StatusUnprocessableEntity, APIResponse{