Enhance cursor rules and add user management functionality

- Updated cursor rules to emphasize the importance of dependency injection and logging practices.
- Introduced a new user management domain, including entities, services, handlers, and validation.
- Implemented user authentication features such as login, registration, and role-based access control.
- Added Redis integration for token management and caching.
- Enhanced API documentation with Swagger for user-related endpoints.
- Updated configuration to support new JWT settings and Redis connection parameters.
This commit is contained in:
n.nakhostin
2025-08-10 14:09:17 +03:30
parent 9119e2383e
commit 98f581ec97
28 changed files with 7782 additions and 131 deletions
+353
View File
@@ -0,0 +1,353 @@
# Authorization Package
This package provides JWT-based authorization services for the Tender Management system, following Clean Architecture principles and best practices.
## Features
- **JWT Token Management**: Generate and validate access and refresh tokens
- **Middleware Support**: Ready-to-use Echo middleware for authentication and authorization
- **Role-Based Access Control**: Built-in role validation middleware
- **Company Scoping**: Company-level access control middleware
- **Token Blacklisting**: Support for token invalidation (Redis-ready)
- **Injectable Configuration**: Dependency injection for flexible configuration management
- **Configuration Providers**: Multiple ways to create configurations (environment, custom, hybrid)
- **Security Best Practices**: Secure token generation and validation
## Quick Start
### 1. Basic Usage
```go
package main
import (
"tm/pkg/authorization"
"tm/pkg/logger"
)
func main() {
// Create logger
logger := logger.NewLogger()
// Create configuration provider (environment-based)
configProvider := authorization.NewEnvConfigProvider()
config := configProvider.CreateConfig()
// Create authorization service
authService := authorization.NewAuthorizationService(config, logger)
// Generate tokens
tokenPair, err := authService.GenerateTokenPair(
"user123",
"user@example.com",
"admin",
"company456",
)
if err != nil {
log.Fatal(err)
}
// Validate tokens
result, err := authService.ValidateAccessToken(tokenPair.AccessToken)
if err != nil {
log.Fatal(err)
}
if result.Valid {
fmt.Printf("User ID: %s, Role: %s\n", result.Claims.UserID, result.Claims.Role)
}
}
```
### 2. Using with Echo Framework
```go
package main
import (
"github.com/labstack/echo/v4"
"tm/pkg/authorization"
"tm/pkg/logger"
)
func main() {
e := echo.New()
// Setup authorization
logger := logger.NewLogger()
configProvider := authorization.NewEnvConfigProvider()
config := configProvider.CreateConfig()
authService := authorization.NewAuthorizationService(config, logger)
// Public routes
e.POST("/login", loginHandler)
// Protected routes
protected := e.Group("/api")
protected.Use(authorization.AuthMiddleware(authService))
{
protected.GET("/profile", getProfileHandler)
protected.PUT("/profile", updateProfileHandler)
}
// Admin routes
admin := protected.Group("/admin")
admin.Use(authorization.AdminMiddleware(authService))
{
admin.POST("/users", createUserHandler)
admin.GET("/users", listUsersHandler)
}
// Company-scoped routes
company := protected.Group("/company/:company_id")
company.Use(authorization.CompanyMiddleware(authService))
{
company.GET("/tenders", getCompanyTendersHandler)
}
e.Start(":8080")
}
```
### 3. Configuration Management
The authorization package uses dependency injection for configuration. You can create configurations using different providers:
#### Configuration Providers
The package provides several configuration providers that implement the `ConfigProvider` interface:
```go
type ConfigProvider interface {
CreateConfig() *AuthorizationConfig
}
```
#### Environment-based Configuration
```go
// Create configuration from environment variables
configProvider := authorization.NewEnvConfigProvider()
config := configProvider.CreateConfig()
```
#### Custom Configuration
```go
// Create custom configuration
customConfig := &authorization.AuthorizationConfig{
AccessTokenSecret: "my-secret-key",
RefreshTokenSecret: "my-refresh-secret",
AccessTokenExpiry: 30 * time.Minute,
RefreshTokenExpiry: 14 * 24 * time.Hour,
Issuer: "my-app",
Audience: "my-api",
}
configProvider := authorization.NewCustomConfigProvider(customConfig)
config := configProvider.CreateConfig()
```
#### Hybrid Configuration
```go
// Combine multiple configuration sources with priority
envProvider := authorization.NewEnvConfigProvider()
customProvider := authorization.NewCustomConfigProvider(customConfig)
hybridProvider := authorization.NewHybridConfigProvider(envProvider, customProvider)
config := hybridProvider.CreateConfig()
// The hybrid provider applies configurations in order:
// 1. Environment variables (base)
// 2. Custom overrides (priority)
```
#### File-based Configuration (Future)
```go
// File-based configuration (placeholder for future implementation)
fileProvider := authorization.NewFileConfigProvider("config.yaml")
config := fileProvider.CreateConfig()
```
#### Environment Variables
```bash
# Required
export TM_ACCESS_TOKEN_SECRET="your-super-secret-access-key-here"
export TM_REFRESH_TOKEN_SECRET="your-super-secret-refresh-key-here"
# Optional (with defaults)
export TM_ACCESS_TOKEN_EXPIRY="15m"
export TM_REFRESH_TOKEN_EXPIRY="168h" # 7 days
export TM_JWT_ISSUER="tender-management"
export TM_JWT_AUDIENCE="tender-management-api"
```
## API Reference
### AuthorizationService Interface
```go
type AuthorizationService interface {
// Generate tokens
GenerateTokenPair(userID, email, role, companyID string) (*TokenPair, error)
GenerateAccessToken(userID, email, role, companyID string) (string, error)
GenerateRefreshToken(userID, email, role, companyID string) (string, error)
// Validate tokens
ValidateToken(tokenString string) (*TokenValidationResult, error)
ValidateAccessToken(tokenString string) (*TokenValidationResult, error)
ValidateRefreshToken(tokenString string) (*TokenValidationResult, error)
// Token management
RefreshAccessToken(refreshToken string) (string, error)
InvalidateToken(tokenString string) error
IsTokenBlacklisted(tokenString string) (bool, error)
}
```
### Middleware Functions
```go
// Authentication middleware
AuthMiddleware(authService AuthorizationService) echo.MiddlewareFunc
// Role-based access control
RoleMiddleware(authService AuthorizationService, requiredRoles ...string) echo.MiddlewareFunc
// Admin-only access
AdminMiddleware(authService AuthorizationService) echo.MiddlewareFunc
// Company-scoped access
CompanyMiddleware(authService AuthorizationService) echo.MiddlewareFunc
// Optional authentication
OptionalAuthMiddleware(authService AuthorizationService) echo.MiddlewareFunc
```
### Context Helpers
```go
// Extract user information from context
userID, email, role, companyID, authenticated := GetUserFromContext(c)
// Extract token claims
claims := GetTokenClaimsFromContext(c)
// Check authentication status
if IsAuthenticated(c) {
// User is authenticated
}
```
## Token Structure
### Access Token Claims
```json
{
"user_id": "uuid-string",
"email": "user@example.com",
"role": "admin",
"company_id": "company-uuid",
"token_type": "access",
"iss": "tender-management",
"sub": "uuid-string",
"aud": ["tender-management-api"],
"exp": 1640995200,
"nbf": 1640994300,
"iat": 1640994300
}
```
### Refresh Token Claims
```json
{
"user_id": "uuid-string",
"email": "user@example.com",
"role": "admin",
"company_id": "company-uuid",
"token_type": "refresh",
"iss": "tender-management",
"sub": "uuid-string",
"aud": ["tender-management-api"],
"exp": 1641600000,
"nbf": 1640994300,
"iat": 1640994300
}
```
## Security Features
### Token Security
- **Separate Secrets**: Access and refresh tokens use different signing secrets
- **Short Expiry**: Access tokens expire quickly (default: 15 minutes)
- **Long Expiry**: Refresh tokens last longer (default: 7 days)
- **Type Validation**: Tokens are validated against their intended type
- **Blacklist Support**: Tokens can be invalidated and blacklisted
### Best Practices
- **Environment Variables**: Never hardcode secrets in code
- **Strong Secrets**: Use cryptographically strong random secrets
- **HTTPS Only**: Always use HTTPS in production
- **Token Rotation**: Implement refresh token rotation for security
- **Audit Logging**: Log all authentication and authorization events
## Configuration Validation
The package automatically validates configuration on startup:
```go
config := authorization.LoadConfigFromEnv()
if err := config.ValidateConfig(); err != nil {
log.Fatal("Invalid authorization configuration:", err)
}
```
Validation checks:
- Non-empty secrets
- Valid expiry durations
- Access token expiry < refresh token expiry
- Non-empty issuer and audience
## Error Handling
All functions return descriptive errors:
```go
result, err := authService.ValidateAccessToken(token)
if err != nil {
// Handle validation error
return err
}
if !result.Valid {
if result.Expired {
return response.Unauthorized(c, "Token has expired")
}
return response.Unauthorized(c, result.Error)
}
```
## Future Enhancements
- **Redis Integration**: Full token blacklist implementation
- **Rate Limiting**: Token generation rate limiting
- **Audit Trail**: Comprehensive authentication audit logging
- **Multi-Tenant**: Enhanced company-level security
- **OAuth2 Support**: Integration with external OAuth providers
## Dependencies
- `github.com/golang-jwt/jwt/v5` - JWT implementation
- `github.com/labstack/echo/v4` - HTTP framework
- `tm/pkg/logger` - Internal logging package
- `tm/pkg/response` - Internal response package
## License
This package is part of the Tender Management system and follows the same license terms.
+620
View File
@@ -0,0 +1,620 @@
package authorization
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/hex"
"encoding/pem"
"fmt"
"time"
"tm/pkg/logger"
"tm/pkg/redis"
"github.com/golang-jwt/jwt/v5"
)
// 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)
}
// 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
}
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
}
// 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
}
+268
View File
@@ -0,0 +1,268 @@
package authorization
import (
"context"
"testing"
"time"
"tm/pkg/logger"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
// MockRedisClient is a mock implementation of the Redis client interface
type MockRedisClient struct {
mock.Mock
}
func (m *MockRedisClient) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error {
args := m.Called(ctx, key, value, expiration)
return args.Error(0)
}
func (m *MockRedisClient) Get(ctx context.Context, key string) (string, error) {
args := m.Called(ctx, key)
return args.String(0), args.Error(1)
}
func (m *MockRedisClient) Del(ctx context.Context, keys ...string) error {
args := m.Called(ctx, keys)
return args.Error(0)
}
func (m *MockRedisClient) Exists(ctx context.Context, keys ...string) (int64, error) {
args := m.Called(ctx, keys)
return args.Get(0).(int64), args.Error(1)
}
func (m *MockRedisClient) Close() error {
args := m.Called()
return args.Error(0)
}
// MockLogger is a mock implementation of the logger interface
type MockLogger struct {
mock.Mock
}
func (m *MockLogger) Debug(msg string, fields map[string]interface{}) {
m.Called(msg, fields)
}
func (m *MockLogger) Info(msg string, fields map[string]interface{}) {
m.Called(msg, fields)
}
func (m *MockLogger) Warn(msg string, fields map[string]interface{}) {
m.Called(msg, fields)
}
func (m *MockLogger) Error(msg string, fields map[string]interface{}) {
m.Called(msg, fields)
}
func (m *MockLogger) Fatal(msg string, fields map[string]interface{}) {
m.Called(msg, fields)
}
func (m *MockLogger) Sync() error {
args := m.Called()
return args.Error(0)
}
func (m *MockLogger) WithFields(fields map[string]interface{}) logger.Logger {
args := m.Called(fields)
return args.Get(0).(logger.Logger)
}
func TestGenerateTokenHash(t *testing.T) {
service := &Service{}
token1 := "test-token-1"
token2 := "test-token-2"
hash1 := service.generateTokenHash(token1)
hash2 := service.generateTokenHash(token2)
// Hash should be consistent for the same input
assert.Equal(t, service.generateTokenHash(token1), hash1)
// Different tokens should have different hashes
assert.NotEqual(t, hash1, hash2)
// Hash should be a valid hex string
assert.Len(t, hash1, 64) // SHA-256 produces 32 bytes = 64 hex chars
}
func TestInvalidateToken_WithRedis(t *testing.T) {
mockRedis := new(MockRedisClient)
mockLogger := new(MockLogger)
config := &AuthorizationConfig{
AccessTokenSecret: "test-secret",
RefreshTokenSecret: "test-refresh-secret",
AccessTokenExpiry: 15 * time.Minute,
RefreshTokenExpiry: 7 * 24 * time.Hour,
Issuer: "test",
Audience: "test-api",
}
service := &Service{
config: config,
logger: mockLogger,
redis: mockRedis,
}
// Generate a valid token first
token, err := service.GenerateAccessToken("user123", "test@example.com", "user", "company123")
assert.NoError(t, err)
// Mock Redis Set call
mockRedis.On("Set", mock.Anything, mock.Anything, "1", mock.Anything).Return(nil)
// Mock logger calls
mockLogger.On("Info", mock.Anything, mock.Anything).Return()
// Test invalidation
err = service.InvalidateToken(token)
assert.NoError(t, err)
// Verify Redis was called
mockRedis.AssertExpectations(t)
}
func TestInvalidateToken_WithoutRedis(t *testing.T) {
mockLogger := new(MockLogger)
config := &AuthorizationConfig{
AccessTokenSecret: "test-secret",
RefreshTokenSecret: "test-refresh-secret",
AccessTokenExpiry: 15 * time.Minute,
RefreshTokenExpiry: 7 * 24 * time.Hour,
Issuer: "test",
Audience: "test-api",
}
service := &Service{
config: config,
logger: mockLogger,
redis: nil, // No Redis client
}
// Mock logger calls
mockLogger.On("Warn", mock.Anything, mock.Anything).Return()
// Test invalidation without Redis
err := service.InvalidateToken("test-token")
assert.NoError(t, err)
// Verify warning was logged
mockLogger.AssertExpectations(t)
}
func TestIsTokenBlacklisted_WithRedis(t *testing.T) {
mockRedis := new(MockRedisClient)
mockLogger := new(MockLogger)
config := &AuthorizationConfig{
AccessTokenSecret: "test-secret",
RefreshTokenSecret: "test-refresh-secret",
AccessTokenExpiry: 15 * time.Minute,
RefreshTokenExpiry: 7 * 24 * time.Hour,
Issuer: "test",
Audience: "test-api",
}
service := &Service{
config: config,
logger: mockLogger,
redis: mockRedis,
}
token := "test-token"
tokenHash := service.generateTokenHash(token)
// Test case 1: Token is blacklisted
mockRedis.On("Exists", mock.Anything, []string{"blacklist:" + tokenHash}).Return(int64(1), nil).Once()
mockLogger.On("Debug", mock.Anything, mock.Anything).Return()
isBlacklisted, err := service.IsTokenBlacklisted(token)
assert.NoError(t, err)
assert.True(t, isBlacklisted)
// Test case 2: Token is not blacklisted
mockRedis.On("Exists", mock.Anything, []string{"blacklist:" + tokenHash}).Return(int64(0), nil).Once()
mockLogger.On("Debug", mock.Anything, mock.Anything).Return()
isBlacklisted, err = service.IsTokenBlacklisted(token)
assert.NoError(t, err)
assert.False(t, isBlacklisted)
mockRedis.AssertExpectations(t)
}
func TestIsTokenBlacklisted_WithoutRedis(t *testing.T) {
mockLogger := new(MockLogger)
config := &AuthorizationConfig{
AccessTokenSecret: "test-secret",
RefreshTokenSecret: "test-refresh-secret",
AccessTokenExpiry: 15 * time.Minute,
RefreshTokenExpiry: 7 * 24 * time.Hour,
Issuer: "test",
Audience: "test-api",
}
service := &Service{
config: config,
logger: mockLogger,
redis: nil, // No Redis client
}
// Test blacklist check without Redis
isBlacklisted, err := service.IsTokenBlacklisted("test-token")
assert.NoError(t, err)
assert.False(t, isBlacklisted)
}
func TestClearExpiredTokens(t *testing.T) {
mockRedis := new(MockRedisClient)
mockLogger := new(MockLogger)
service := &Service{
logger: mockLogger,
redis: mockRedis,
}
// Mock logger call
mockLogger.On("Info", mock.Anything, mock.Anything).Return()
// Test clearing expired tokens
err := service.ClearExpiredTokens()
assert.NoError(t, err)
mockLogger.AssertExpectations(t)
}
func TestGetBlacklistStats(t *testing.T) {
mockRedis := new(MockRedisClient)
mockLogger := new(MockLogger)
service := &Service{
logger: mockLogger,
redis: mockRedis,
}
ctx := context.Background()
// Test with Redis available
stats, err := service.GetBlacklistStats(ctx)
assert.NoError(t, err)
assert.Equal(t, true, stats["redis_available"])
// Test without Redis
service.redis = nil
stats, err = service.GetBlacklistStats(ctx)
assert.NoError(t, err)
assert.Equal(t, false, stats["redis_available"])
}
+23
View File
@@ -0,0 +1,23 @@
package authorization
import "errors"
// Authorization errors
var (
ErrEmptyAccessTokenSecret = errors.New("access token secret cannot be empty")
ErrEmptyRefreshTokenSecret = errors.New("refresh token secret cannot be empty")
ErrInvalidAccessTokenExpiry = errors.New("access token expiry must be greater than 0")
ErrInvalidRefreshTokenExpiry = errors.New("refresh token expiry must be greater than 0")
ErrAccessTokenExpiryTooLong = errors.New("access token expiry cannot be longer than refresh token expiry")
ErrEmptyIssuer = errors.New("JWT issuer cannot be empty")
ErrEmptyAudience = errors.New("JWT audience cannot be empty")
ErrInvalidToken = errors.New("invalid token")
ErrTokenExpired = errors.New("token has expired")
ErrTokenBlacklisted = errors.New("token is blacklisted")
ErrInvalidTokenType = errors.New("invalid token type")
ErrInvalidSigningMethod = errors.New("invalid signing method")
ErrInvalidClaims = errors.New("invalid token claims")
ErrTokenValidationFailed = errors.New("token validation failed")
ErrInsufficientPermissions = errors.New("insufficient permissions")
ErrCompanyAccessDenied = errors.New("access denied to this company")
)