package authorization import ( "context" "crypto/rand" "crypto/rsa" "crypto/sha256" "crypto/x509" "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 type TokenType string const ( AccessToken TokenType = "access" RefreshToken TokenType = "refresh" ) // TokenClaims represents the JWT claims structure type TokenClaims struct { UserID string `json:"user_id"` Email string `json:"email"` Role string `json:"role"` CompanyID string `json:"company_id,omitempty"` TokenType TokenType `json:"token_type"` jwt.RegisteredClaims } // TokenPair represents a pair of access and refresh tokens type TokenPair struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` ExpiresIn int64 `json:"expires_in"` } // TokenValidationResult represents the result of token validation type TokenValidationResult struct { Valid bool Claims *TokenClaims Error string Expired bool } // AuthorizationService defines the interface for authorization operations type AuthorizationService interface { // GenerateTokenPair generates both access and refresh tokens GenerateTokenPair(userID, email, role, companyID string) (*TokenPair, error) // GenerateAccessToken generates only an access token GenerateAccessToken(userID, email, role, companyID string) (string, error) // GenerateRefreshToken generates only a refresh token GenerateRefreshToken(userID, email, role, companyID string) (string, error) // ValidateToken validates a token and returns the claims ValidateToken(tokenString string) (*TokenValidationResult, error) // ValidateAccessToken validates specifically an access token ValidateAccessToken(tokenString string) (*TokenValidationResult, error) // ValidateRefreshToken validates specifically a refresh token ValidateRefreshToken(tokenString string) (*TokenValidationResult, error) // RefreshAccessToken generates a new access token using a valid refresh token RefreshAccessToken(refreshToken string) (string, error) // InvalidateToken adds a token to the blacklist (if using Redis) InvalidateToken(tokenString string) error // IsTokenBlacklisted checks if a token is blacklisted IsTokenBlacklisted(tokenString string) (bool, error) // ClearExpiredTokens removes expired tokens from the blacklist ClearExpiredTokens() error // 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 type AuthorizationConfig struct { AccessTokenSecret string `json:"access_token_secret"` RefreshTokenSecret string `json:"refresh_token_secret"` AccessTokenExpiry time.Duration `json:"access_token_expiry"` RefreshTokenExpiry time.Duration `json:"refresh_token_expiry"` Issuer string `json:"issuer"` Audience string `json:"audience"` } // DefaultConfig returns a default configuration func DefaultConfig() *AuthorizationConfig { return &AuthorizationConfig{ AccessTokenSecret: "your-access-token-secret-key-here", RefreshTokenSecret: "your-refresh-token-secret-key-here", AccessTokenExpiry: 15 * time.Minute, // 15 minutes RefreshTokenExpiry: 7 * 24 * time.Hour, // 7 days Issuer: "tender-management", Audience: "tender-management-api", } } // Service implements the AuthorizationService interface type Service struct { config *AuthorizationConfig logger logger.Logger redis redis.Client } // NewAuthorizationService creates a new authorization service func NewAuthorizationService(config *AuthorizationConfig, logger logger.Logger, redis redis.Client) AuthorizationService { if config == nil { config = DefaultConfig() } return &Service{ config: config, logger: logger, redis: redis, } } // GenerateTokenPair generates both access and refresh tokens func (s *Service) GenerateTokenPair(userID, email, role, companyID string) (*TokenPair, error) { s.logger.Info("Generating token pair", map[string]interface{}{ "user_id": userID, "email": email, "role": role, }) accessToken, err := s.GenerateAccessToken(userID, email, role, companyID) if err != nil { s.logger.Error("Failed to generate access token", map[string]interface{}{ "user_id": userID, "error": err.Error(), }) return nil, fmt.Errorf("failed to generate access token: %w", err) } refreshToken, err := s.GenerateRefreshToken(userID, email, role, companyID) if err != nil { s.logger.Error("Failed to generate refresh token", map[string]interface{}{ "user_id": userID, "error": err.Error(), }) return nil, fmt.Errorf("failed to generate refresh token: %w", err) } tokenPair := &TokenPair{ AccessToken: accessToken, RefreshToken: refreshToken, ExpiresIn: int64(s.config.AccessTokenExpiry.Seconds()), } s.logger.Info("Token pair generated successfully", map[string]interface{}{ "user_id": userID, "expires_in": tokenPair.ExpiresIn, }) return tokenPair, nil } // GenerateAccessToken generates only an access token func (s *Service) GenerateAccessToken(userID, email, role, companyID string) (string, error) { now := time.Now() expiresAt := now.Add(s.config.AccessTokenExpiry) claims := &TokenClaims{ UserID: userID, Email: email, Role: role, CompanyID: companyID, TokenType: AccessToken, RegisteredClaims: jwt.RegisteredClaims{ Issuer: s.config.Issuer, Subject: userID, Audience: []string{s.config.Audience}, ExpiresAt: jwt.NewNumericDate(expiresAt), NotBefore: jwt.NewNumericDate(now), IssuedAt: jwt.NewNumericDate(now), }, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) tokenString, err := token.SignedString([]byte(s.config.AccessTokenSecret)) if err != nil { return "", fmt.Errorf("failed to sign access token: %w", err) } return tokenString, nil } // GenerateRefreshToken generates only a refresh token func (s *Service) GenerateRefreshToken(userID, email, role, companyID string) (string, error) { now := time.Now() expiresAt := now.Add(s.config.RefreshTokenExpiry) claims := &TokenClaims{ UserID: userID, Email: email, Role: role, CompanyID: companyID, TokenType: RefreshToken, RegisteredClaims: jwt.RegisteredClaims{ Issuer: s.config.Issuer, Subject: userID, Audience: []string{s.config.Audience}, ExpiresAt: jwt.NewNumericDate(expiresAt), NotBefore: jwt.NewNumericDate(now), IssuedAt: jwt.NewNumericDate(now), }, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) tokenString, err := token.SignedString([]byte(s.config.RefreshTokenSecret)) if err != nil { return "", fmt.Errorf("failed to sign refresh token: %w", err) } return tokenString, nil } // ValidateToken validates a token and returns the claims func (s *Service) ValidateToken(tokenString string) (*TokenValidationResult, error) { // First check if token is blacklisted isBlacklisted, err := s.IsTokenBlacklisted(tokenString) if err != nil { s.logger.Error("Failed to check token blacklist", map[string]interface{}{ "error": err.Error(), }) // Continue with validation even if blacklist check fails } if isBlacklisted { return &TokenValidationResult{ Valid: false, Claims: nil, Error: "token is blacklisted", Expired: false, }, nil } // Parse the token token, err := jwt.ParseWithClaims(tokenString, &TokenClaims{}, func(token *jwt.Token) (interface{}, error) { // Validate the signing method if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } // Determine which secret to use based on token type claims, ok := token.Claims.(*TokenClaims) if !ok { return nil, fmt.Errorf("invalid token claims") } switch claims.TokenType { case AccessToken: return []byte(s.config.AccessTokenSecret), nil case RefreshToken: return []byte(s.config.RefreshTokenSecret), nil default: return nil, fmt.Errorf("invalid token type") } }) if err != nil { if jwt.ErrTokenExpired.Error() == err.Error() { return &TokenValidationResult{ Valid: false, Claims: nil, Error: "token expired", Expired: true, }, nil } return &TokenValidationResult{ Valid: false, Claims: nil, Error: fmt.Sprintf("token validation failed: %s", err.Error()), Expired: false, }, nil } if !token.Valid { return &TokenValidationResult{ Valid: false, Claims: nil, Error: "invalid token", Expired: false, }, nil } claims, ok := token.Claims.(*TokenClaims) if !ok { return &TokenValidationResult{ Valid: false, Claims: nil, Error: "invalid token claims", Expired: false, }, 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{ Valid: true, Claims: claims, Error: "", Expired: false, }, nil } // ValidateAccessToken validates specifically an access token func (s *Service) ValidateAccessToken(tokenString string) (*TokenValidationResult, error) { result, err := s.ValidateToken(tokenString) if err != nil { return nil, err } if !result.Valid { return result, nil } // Check if it's actually an access token if result.Claims.TokenType != AccessToken { return &TokenValidationResult{ Valid: false, Claims: nil, Error: "token is not an access token", Expired: false, }, nil } return result, nil } // ValidateRefreshToken validates specifically a refresh token func (s *Service) ValidateRefreshToken(tokenString string) (*TokenValidationResult, error) { result, err := s.ValidateToken(tokenString) if err != nil { return nil, err } if !result.Valid { return result, nil } // Check if it's actually a refresh token if result.Claims.TokenType != RefreshToken { return &TokenValidationResult{ Valid: false, Claims: nil, Error: "token is not a refresh token", Expired: false, }, nil } return result, nil } // RefreshAccessToken generates a new access token using a valid refresh token func (s *Service) RefreshAccessToken(refreshToken string) (string, error) { s.logger.Info("Refreshing access token", map[string]interface{}{ "refresh_token_length": len(refreshToken), }) // Validate the refresh token result, err := s.ValidateRefreshToken(refreshToken) if err != nil { s.logger.Error("Failed to validate refresh token", map[string]interface{}{ "error": err.Error(), }) return "", fmt.Errorf("failed to validate refresh token: %w", err) } if !result.Valid { s.logger.Warn("Invalid refresh token provided", map[string]interface{}{ "error": result.Error, }) return "", fmt.Errorf("invalid refresh token: %s", result.Error) } // Generate new access token newAccessToken, err := s.GenerateAccessToken( result.Claims.UserID, result.Claims.Email, result.Claims.Role, result.Claims.CompanyID, ) if err != nil { s.logger.Error("Failed to generate new access token", map[string]interface{}{ "user_id": result.Claims.UserID, "error": err.Error(), }) return "", fmt.Errorf("failed to generate new access token: %w", err) } s.logger.Info("Access token refreshed successfully", map[string]interface{}{ "user_id": result.Claims.UserID, }) 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)) return hex.EncodeToString(hash[:]) } // InvalidateToken adds a token to the blacklist func (s *Service) InvalidateToken(tokenString string) error { if s.redis == nil { s.logger.Warn("Redis client not available, token blacklisting disabled", map[string]interface{}{ "token_length": len(tokenString), }) return nil } // Generate hash of the token for storage tokenHash := s.generateTokenHash(tokenString) // Parse token to get expiration time for blacklist TTL claims := &TokenClaims{} _, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) { // Determine which secret to use based on token type claims, ok := token.Claims.(*TokenClaims) if !ok { return nil, fmt.Errorf("invalid token claims") } switch claims.TokenType { case AccessToken: return []byte(s.config.AccessTokenSecret), nil case RefreshToken: return []byte(s.config.RefreshTokenSecret), nil default: return nil, fmt.Errorf("invalid token type") } }) if err != nil { s.logger.Error("Failed to parse token for blacklisting", map[string]interface{}{ "error": err.Error(), }) // Still add to blacklist with default TTL even if parsing fails ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() // Use default TTL (24 hours) if we can't determine actual expiration defaultTTL := 24 * time.Hour err = s.redis.Set(ctx, "blacklist:"+tokenHash, "1", defaultTTL) if err != nil { s.logger.Error("Failed to add token to blacklist", map[string]interface{}{ "error": err.Error(), }) return fmt.Errorf("failed to add token to blacklist: %w", err) } s.logger.Info("Token added to blacklist with default TTL", map[string]interface{}{ "token_hash": tokenHash, "ttl": defaultTTL.String(), }) return nil } // Calculate TTL based on token expiration var ttl time.Duration if claims.ExpiresAt != nil { ttl = time.Until(claims.ExpiresAt.Time) // Add some buffer time to ensure token is blacklisted until it would have expired ttl += 5 * time.Minute } else { // Fallback to default TTL if no expiration ttl = 24 * time.Hour } // Add token to blacklist with TTL ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() err = s.redis.Set(ctx, "blacklist:"+tokenHash, "1", ttl) if err != nil { s.logger.Error("Failed to add token to blacklist", map[string]interface{}{ "token_hash": tokenHash, "error": err.Error(), }) return fmt.Errorf("failed to add token to blacklist: %w", err) } s.logger.Info("Token successfully added to blacklist", map[string]interface{}{ "token_hash": tokenHash, "ttl": ttl.String(), }) return nil } // IsTokenBlacklisted checks if a token is blacklisted func (s *Service) IsTokenBlacklisted(tokenString string) (bool, error) { if s.redis == nil { // If Redis is not available, assume token is not blacklisted // This allows the system to continue functioning without Redis return false, nil } // Generate hash of the token for lookup tokenHash := s.generateTokenHash(tokenString) // Check if token exists in blacklist ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() exists, err := s.redis.Exists(ctx, "blacklist:"+tokenHash) if err != nil { s.logger.Error("Failed to check token blacklist", map[string]interface{}{ "token_hash": tokenHash, "error": err.Error(), }) // If we can't check the blacklist, assume token is not blacklisted // This prevents blocking legitimate requests due to Redis issues return false, nil } isBlacklisted := exists > 0 if isBlacklisted { s.logger.Debug("Token found in blacklist", map[string]interface{}{ "token_hash": tokenHash, }) } else { s.logger.Debug("Token not found in blacklist", map[string]interface{}{ "token_hash": tokenHash, }) } return isBlacklisted, nil } // ClearExpiredTokens removes expired tokens from the blacklist // This is useful for cleanup operations func (s *Service) ClearExpiredTokens() error { if s.redis == nil { return nil } s.logger.Info("Clearing expired tokens from blacklist", map[string]interface{}{}) // Note: Redis automatically expires keys based on TTL // This method is provided for manual cleanup if needed // In most cases, Redis TTL handling is sufficient return nil } // GetBlacklistStats returns statistics about the blacklist func (s *Service) GetBlacklistStats(ctx context.Context) (map[string]interface{}, error) { if s.redis == nil { return map[string]interface{}{ "redis_available": false, "message": "Redis client not available", }, nil } // This is a placeholder for future implementation // Could return metrics like total blacklisted tokens, memory usage, etc. return map[string]interface{}{ "redis_available": true, "message": "Blacklist statistics not yet implemented", }, nil } // GenerateRandomSecret generates a random secret key for JWT signing func GenerateRandomSecret(length int) (string, error) { if length < 32 { length = 32 // Minimum recommended length } bytes := make([]byte, length) _, err := rand.Read(bytes) if err != nil { return "", fmt.Errorf("failed to generate random bytes: %w", err) } return fmt.Sprintf("%x", bytes), nil } // GenerateRSAKeyPair generates an RSA key pair for JWT signing func GenerateRSAKeyPair(bits int) (privateKeyPEM, publicKeyPEM string, err error) { if bits < 2048 { bits = 2048 // Minimum recommended bits } privateKey, err := rsa.GenerateKey(rand.Reader, bits) if err != nil { return "", "", fmt.Errorf("failed to generate RSA key: %w", err) } // Encode private key privateKeyBytes := x509.MarshalPKCS1PrivateKey(privateKey) privateKeyPEM = string(pem.EncodeToMemory(&pem.Block{ Type: "RSA PRIVATE KEY", Bytes: privateKeyBytes, })) // Encode public key publicKeyBytes := x509.MarshalPKCS1PublicKey(&privateKey.PublicKey) publicKeyPEM = string(pem.EncodeToMemory(&pem.Block{ Type: "RSA PUBLIC KEY", Bytes: publicKeyBytes, })) return privateKeyPEM, publicKeyPEM, nil }