900 lines
32 KiB
Go
900 lines
32 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"errors"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/creativenoz/aurganize-v62/backend/internal/config"
|
|
"github.com/creativenoz/aurganize-v62/backend/internal/models"
|
|
"github.com/creativenoz/aurganize-v62/backend/internal/repositories"
|
|
"github.com/creativenoz/aurganize-v62/backend/pkg/auth"
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// Predefined errors for authentication operations.
|
|
// These are defined as package-level variables so they can be:
|
|
// 1. Compared using errors.Is() for error handling
|
|
// 2. Wrapped with additional context using errors.Wrap()
|
|
// 3. Tested reliably (same error instance)
|
|
// 4. Documented centrally
|
|
//
|
|
// Why use errors.New() vs custom error types?
|
|
// - Simple errors don't need additional data
|
|
// - errors.Is() works for comparison
|
|
// - Can still wrap with context: fmt.Errorf("context: %w", ErrInvalidToken)
|
|
// - Good balance between simplicity and functionality
|
|
var (
|
|
// ErrInvalidToken indicates the provided token is malformed or has invalid signature
|
|
// Common causes:
|
|
// - Token tampered with
|
|
// - Wrong signing secret used
|
|
// - Token format doesn't match JWT standard
|
|
// - Token claims are malformed
|
|
ErrInvalidToken = errors.New("invalid token")
|
|
|
|
// ErrExpiredToken indicates the token's expiration time has passed
|
|
// This is normal - tokens should expire for security
|
|
// Client should use refresh token to get new access token
|
|
ErrExpiredToken = errors.New("token has been expired")
|
|
|
|
// ErrRevokedToken indicates the token has been explicitly invalidated
|
|
// Happens when:
|
|
// - User logs out
|
|
// - Admin revokes session
|
|
// - Password changed (all sessions revoked)
|
|
// - Security breach detected
|
|
ErrRevokedToken = errors.New("token has been revoked")
|
|
|
|
// ErrInvalidTokenType indicates token type doesn't match expected
|
|
// We have two token types: "access" and "refresh"
|
|
// This prevents using a refresh token as an access token (security issue)
|
|
ErrInvalidTokenType = errors.New("invalid token type")
|
|
)
|
|
|
|
// AuthService handles all authentication and authorization logic.
|
|
// This service is the business logic layer that sits between handlers and repositories.
|
|
//
|
|
// Responsibilities:
|
|
// 1. Token generation (access and refresh tokens)
|
|
// 2. Token validation (signature, expiration, revocation)
|
|
// 3. Session management (create, validate, revoke)
|
|
// 4. Token revocation (logout, logout all devices)
|
|
//
|
|
// Architecture: Service Layer Pattern
|
|
// - Handlers call services (not repositories directly)
|
|
// - Services contain business logic
|
|
// - Services call repositories for data access
|
|
// - Services can call multiple repositories (transaction coordination)
|
|
//
|
|
// Why separate service from handler?
|
|
// - Reusability: Multiple handlers can use same service
|
|
// - Testability: Can test business logic without HTTP
|
|
// - Separation of concerns: HTTP logic vs business logic
|
|
// - Transaction management: Service coordinates multiple repo calls
|
|
type AuthService struct {
|
|
config *config.Config // JWT secrets, expiration times, issuer info
|
|
sessionRepo *repositories.SessionRepository // Database operations for sessions
|
|
userRepo *repositories.UserRepository // Database operations for users (not used much here)
|
|
}
|
|
|
|
// NewAuthService creates a new AuthService with injected dependencies.
|
|
// This constructor follows dependency injection pattern for:
|
|
// - Testability (can inject mocks)
|
|
// - Flexibility (can change implementations)
|
|
// - Clear dependencies (explicit in signature)
|
|
//
|
|
// Parameters:
|
|
// - config: Application configuration (JWT settings)
|
|
// - sessionRepo: For session database operations
|
|
// - userRepo: For user database operations
|
|
//
|
|
// Returns:
|
|
// - Fully initialized AuthService
|
|
func NewAuthService(config *config.Config, sessionRepo *repositories.SessionRepository, userRepo *repositories.UserRepository) *AuthService {
|
|
return &AuthService{
|
|
config: config,
|
|
sessionRepo: sessionRepo,
|
|
userRepo: userRepo,
|
|
}
|
|
}
|
|
|
|
// GenerateAccessToken creates a new JWT access token for a user.
|
|
// Access tokens are short-lived (typically 15 minutes) and used for API authentication.
|
|
//
|
|
// What's an access token?
|
|
// - Short-lived JWT (15 minutes typical)
|
|
// - Contains user identity and permissions
|
|
// - Sent with every API request
|
|
// - Stateless (doesn't require database lookup)
|
|
// - Used for request authentication/authorization
|
|
//
|
|
// Token structure:
|
|
// - Header: Algorithm (HS256), type (JWT)
|
|
// - Payload: Claims (user info, expiration, issuer)
|
|
// - Signature: HMAC SHA-256 of header+payload with secret
|
|
//
|
|
// Claims included:
|
|
// - UserID: Identifies the user
|
|
// - TenantID: For multi-tenancy (which organization)
|
|
// - Email: User's email address
|
|
// - Role: User's role (for authorization checks)
|
|
// - TokenType: "access" (prevents token confusion)
|
|
// - Standard claims: exp, iat, nbf, iss, sub
|
|
//
|
|
// Standard JWT claims explained:
|
|
// - exp (expires at): When token becomes invalid
|
|
// - iat (issued at): When token was created
|
|
// - nbf (not before): Token not valid before this time (usually same as iat)
|
|
// - iss (issuer): Who issued the token (our application)
|
|
// - sub (subject): User ID (standard way to identify token subject)
|
|
//
|
|
// Why short-lived?
|
|
// - If token is stolen, it's only valid for 15 minutes
|
|
// - Limits damage from token theft
|
|
// - Forces regular token refresh (can check if user still has access)
|
|
// - Balance between security and UX
|
|
//
|
|
// Security considerations:
|
|
// - Signed with secret key (only server can create valid tokens)
|
|
// - Include token type to prevent refresh token being used as access token
|
|
// - Don't include sensitive data (token visible in requests)
|
|
// - Set reasonable expiration (not too long)
|
|
//
|
|
// Return values:
|
|
// - (string, nil): Successfully generated token
|
|
// - ("", error): Token generation failed (configuration error)
|
|
func (a *AuthService) GenerateAccessToken(user *models.User) (string, error) {
|
|
// Get current time for timestamps
|
|
now := time.Now()
|
|
// Calculate expiration time (now + configured expiry duration)
|
|
expiresAt := now.Add(a.config.JWT.AccessExpiry)
|
|
|
|
// Create claims structure with user information
|
|
claims := auth.AccessTokenClaims{
|
|
// Custom claims (our application-specific data)
|
|
UserID: user.ID, // User's unique identifier
|
|
TenantID: user.TenantID, // Organization/tenant identifier (multi-tenancy)
|
|
Email: user.Email, // User's email address
|
|
Role: user.Role, // User's role (admin, user, etc.)
|
|
TokenType: "access", // Identifies this as an access token
|
|
|
|
// Standard JWT registered claims
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
ExpiresAt: jwt.NewNumericDate(expiresAt), // When token expires
|
|
IssuedAt: jwt.NewNumericDate(now), // When token was created
|
|
NotBefore: jwt.NewNumericDate(now), // Token valid from this time
|
|
Issuer: "aurganize-v62-api", // Who issued this token
|
|
Subject: user.ID.String(), // Subject of token (user ID)
|
|
},
|
|
}
|
|
|
|
// Create JWT token with claims
|
|
// NewWithClaims:
|
|
// - First param: Signing method (HS256 = HMAC SHA-256)
|
|
// - Second param: Claims to include in token
|
|
// Returns unsigned token object
|
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
|
|
// Sign token with secret key to create final JWT string
|
|
// SignedString:
|
|
// - Takes secret key as []byte
|
|
// - Creates signature using HMAC SHA-256
|
|
// - Returns complete JWT string: "header.payload.signature"
|
|
// - Only holder of secret can create valid signatures
|
|
return token.SignedString([]byte(a.config.JWT.AccessSecret))
|
|
}
|
|
|
|
// GenerateRefreshToken creates a new refresh token and session record.
|
|
// Refresh tokens are long-lived (typically 7 days) and used to obtain new access tokens.
|
|
//
|
|
// What's a refresh token?
|
|
// - Long-lived (7 days typical)
|
|
// - Used only to get new access tokens
|
|
// - Stored in database (can be revoked)
|
|
// - Contains session ID for tracking
|
|
// - More secure than never-expiring access tokens
|
|
//
|
|
// Refresh token vs Access token:
|
|
// - Access: Short-lived (15min), stateless, for API requests
|
|
// - Refresh: Long-lived (7 days), stateful (in database), for token renewal only
|
|
//
|
|
// Two-part token system:
|
|
// 1. Random token ID (stored in database)
|
|
// 2. JWT containing user ID, session ID, and token ID
|
|
//
|
|
// Why this two-part system?
|
|
// - JWT alone: Can't revoke (stateless)
|
|
// - Database alone: Requires lookup on every request (slow)
|
|
// - Hybrid: JWT for claims, database for revocation checking
|
|
//
|
|
// Token generation process:
|
|
// 1. Generate random 32-byte token ID (cryptographically secure)
|
|
// 2. Create session record in database with hashed token ID
|
|
// 3. Create JWT containing user ID, session ID, and token ID
|
|
// 4. Sign JWT with refresh secret
|
|
//
|
|
// Session tracking:
|
|
// - Records device information (user agent, IP, device type)
|
|
// - Allows "view active sessions" feature
|
|
// - Enables "logout from device X" functionality
|
|
// - Audit trail for security
|
|
//
|
|
// Security features:
|
|
// - Random token ID (not predictable)
|
|
// - Stored in database (can revoke)
|
|
// - Hashed in database (can't use even if database breached)
|
|
// - Device tracking (detect unusual activity)
|
|
// - Expiration date (eventually expires)
|
|
//
|
|
// Parameters:
|
|
// - ctx: Context for database operations
|
|
// - user: User to create token for
|
|
// - userAgent: Browser/app information (optional)
|
|
// - ipAddress: IP address of request (optional)
|
|
//
|
|
// Returns:
|
|
// - (signedToken, session, nil): Success
|
|
// - ("", nil, error): Failed to generate random token
|
|
// - ("", nil, error): Failed to create session in database
|
|
// - ("", nil, error): Failed to sign JWT
|
|
func (a *AuthService) GenerateRefreshToken(ctx context.Context, user *models.User, userAgent *string, ipAddress *string) (string, *models.Session, error) {
|
|
// Step 1: Generate cryptographically secure random token ID
|
|
// Create 32-byte buffer for random data
|
|
tokenBytes := make([]byte, 32)
|
|
|
|
// Fill buffer with cryptographically secure random bytes
|
|
// crypto/rand.Read uses OS-provided randomness (very secure)
|
|
// This is NOT like math/rand (which is predictable)
|
|
if _, err := rand.Read(tokenBytes); err != nil {
|
|
return "", nil, err // Failed to generate random data (very rare)
|
|
}
|
|
|
|
// Encode random bytes to base64 string
|
|
// Base64 makes binary data safe for text storage
|
|
// URLEncoding variant avoids special characters (+, /, =)
|
|
// Result: ~44-character string
|
|
refreshToken := base64.URLEncoding.EncodeToString(tokenBytes)
|
|
|
|
// Step 2: Calculate token expiration time
|
|
now := time.Now()
|
|
expiresAt := now.Add(a.config.JWT.RefreshExpiry) // Usually 7 days
|
|
|
|
// Step 3: Create session record in database
|
|
// This stores:
|
|
// - Hashed token (not plaintext for security)
|
|
// - User ID (who owns this session)
|
|
// - Device information (user agent, IP, device type)
|
|
// - Expiration time
|
|
session, err := a.sessionRepo.Create(ctx, &models.CreateSessionInput{
|
|
UserID: user.ID,
|
|
RefreshToken: refreshToken, // Will be hashed by repository
|
|
UserAgent: userAgent, // Browser/app info
|
|
IPAddress: ipAddress, // Where login came from
|
|
DeviceType: detectDeviceType(userAgent), // mobile, desktop, or web
|
|
ExpiresAt: expiresAt,
|
|
})
|
|
|
|
if err != nil {
|
|
return "", nil, err // Database error
|
|
}
|
|
|
|
// Step 4: Create JWT claims with session information
|
|
claims := auth.RefreshTokenClaims{
|
|
// Custom claims
|
|
UserID: user.ID, // Which user owns this token
|
|
SessionID: session.ID, // Which session this token belongs to
|
|
TokenID: refreshToken, // The random token ID (for database lookup)
|
|
TokenType: "refresh", // Identifies this as refresh token
|
|
|
|
// Standard JWT claims
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
ExpiresAt: jwt.NewNumericDate(expiresAt), // When token expires
|
|
IssuedAt: jwt.NewNumericDate(now), // When created
|
|
NotBefore: jwt.NewNumericDate(now), // Valid from now
|
|
Issuer: "aurganize-v62-api", // Who issued it
|
|
Subject: user.ID.String(), // Subject (user ID)
|
|
},
|
|
}
|
|
|
|
// Step 5: Create and sign JWT
|
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
signedToken, err := token.SignedString([]byte(a.config.JWT.RefreshSecret))
|
|
if err != nil {
|
|
return "", nil, err // Failed to sign token
|
|
}
|
|
|
|
// Return signed JWT and session object
|
|
return signedToken, session, err
|
|
}
|
|
|
|
// ValidateAccessToken verifies an access token's signature and claims.
|
|
// This is called on every authenticated API request.
|
|
//
|
|
// What gets validated:
|
|
// 1. JWT signature (proves token wasn't tampered with)
|
|
// 2. Token not expired (exp claim)
|
|
// 3. Token valid now (nbf claim)
|
|
// 4. Token is "access" type (not refresh)
|
|
// 5. Token issued by us (iss claim)
|
|
//
|
|
// Validation process:
|
|
// 1. Parse JWT structure (header.payload.signature)
|
|
// 2. Verify signature using access secret
|
|
// 3. Check signing algorithm is HMAC (not "none" or RSA)
|
|
// 4. Validate expiration time
|
|
// 5. Validate issued-at time
|
|
// 6. Validate token type
|
|
//
|
|
// Why validate on every request?
|
|
// - Stateless authentication (no session lookups)
|
|
// - Fast (just cryptographic validation)
|
|
// - Secure (can't forge without secret)
|
|
// - Scalable (no database query needed)
|
|
//
|
|
// Security checks:
|
|
// - Algorithm verification (prevents "none" algorithm attack)
|
|
// - Signature verification (prevents tampering)
|
|
// - Expiration check (prevents replay of old tokens)
|
|
// - Token type check (prevents using refresh as access)
|
|
//
|
|
// Why NOT check database?
|
|
// - Would be slow (database query on every request)
|
|
// - Would not scale well
|
|
// - Access tokens are short-lived anyway (15 min)
|
|
// - Revocation handled at refresh token level
|
|
//
|
|
// When validation fails:
|
|
// - 401 Unauthorized response to client
|
|
// - Client should try refresh token
|
|
// - If refresh fails, redirect to login
|
|
//
|
|
// Parameters:
|
|
// - tokenString: JWT string from Authorization header or cookie
|
|
//
|
|
// Return values:
|
|
// - (*claims, nil): Token valid, returns claims for authorization
|
|
// - (nil, ErrExpiredToken): Token expired (client should refresh)
|
|
// - (nil, ErrInvalidToken): Token invalid (malformed, wrong signature, wrong type)
|
|
func (a *AuthService) ValidateAccessToken(tokenString string) (*auth.AccessTokenClaims, error) {
|
|
// Parse and validate JWT token
|
|
// ParseWithClaims:
|
|
// - Parses JWT string
|
|
// - Validates signature using provided key function
|
|
// - Checks expiration and issued-at times
|
|
// - Populates claims struct
|
|
token, err := jwt.ParseWithClaims(
|
|
tokenString, // JWT string to parse
|
|
&auth.AccessTokenClaims{}, // Struct to populate with claims
|
|
// Key function: Called to get signing key for validation
|
|
func(token *jwt.Token) (interface{}, error) {
|
|
// Security check: Verify algorithm is HMAC
|
|
// Prevents "none" algorithm attack where attacker removes signature
|
|
// Prevents algorithm confusion attacks (using public key as symmetric key)
|
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
|
return nil, ErrInvalidToken
|
|
}
|
|
// Return secret key for signature verification
|
|
return []byte(a.config.JWT.AccessSecret), nil
|
|
},
|
|
// Parser options for additional validation
|
|
jwt.WithExpirationRequired(), // Ensure exp claim is present and valid
|
|
jwt.WithIssuedAt(), // Validate iat claim
|
|
jwt.WithTimeFunc(time.Now), // Use current time for validation
|
|
)
|
|
|
|
if err != nil {
|
|
// Check if error is specifically about expiration
|
|
if errors.Is(err, jwt.ErrTokenExpired) {
|
|
return nil, ErrExpiredToken // Return specific expiration error
|
|
}
|
|
// Other errors: invalid signature, malformed JWT, etc.
|
|
return nil, ErrInvalidToken
|
|
}
|
|
|
|
// Extract and validate claims
|
|
// Type assertion: Convert interface{} to *AccessTokenClaims
|
|
claims, ok := token.Claims.(*auth.AccessTokenClaims)
|
|
if !ok || !token.Valid {
|
|
// Claims wrong type or token invalid
|
|
return nil, ErrInvalidToken
|
|
}
|
|
|
|
// Verify token type (prevent refresh token being used as access token)
|
|
if claims.TokenType != "access" {
|
|
return nil, ErrInvalidTokenType
|
|
}
|
|
|
|
// Token is valid, return claims for use in authorization
|
|
return claims, nil
|
|
}
|
|
|
|
// ValidateRefreshToken verifies a refresh token and returns associated session.
|
|
// This is called when client wants to get a new access token.
|
|
//
|
|
// Unlike access tokens, refresh tokens are validated against database:
|
|
// 1. Verify JWT signature
|
|
// 2. Check expiration
|
|
// 3. Look up session in database
|
|
// 4. Verify session not revoked
|
|
// 5. Verify session not expired
|
|
// 6. Update session last-used timestamp
|
|
//
|
|
// Why check database for refresh tokens?
|
|
// - Enables revocation (logout, password change)
|
|
// - Tracks device/location information
|
|
// - Allows "logout all devices" functionality
|
|
// - More secure than purely stateless
|
|
// - Acceptable performance (refresh happens every 15 min, not every request)
|
|
//
|
|
// Validation flow:
|
|
// 1. Parse JWT and verify signature
|
|
// 2. Extract session ID and token ID from claims
|
|
// 3. Query database for matching session
|
|
// 4. Verify session exists and is valid
|
|
// 5. Update last-used timestamp
|
|
// 6. Return claims and session
|
|
//
|
|
// Security checks performed:
|
|
// 1. JWT signature verification
|
|
// 2. Token expiration check
|
|
// 3. Token type verification ("refresh")
|
|
// 4. Session existence check
|
|
// 5. Session revocation check
|
|
// 6. Session expiration check
|
|
//
|
|
// Why so many checks?
|
|
// - Defense in depth (multiple security layers)
|
|
// - Catches different types of attacks
|
|
// - Provides clear error messages
|
|
// - Enables fine-grained control
|
|
//
|
|
// Parameters:
|
|
// - ctx: Context for database operations
|
|
// - tokenString: JWT string from cookie
|
|
//
|
|
// Return values:
|
|
// - (claims, session, nil): Valid token, returns data for use
|
|
// - (nil, nil, ErrExpiredToken): Token expired
|
|
// - (nil, nil, ErrInvalidToken): Token invalid or session not found
|
|
// - (nil, nil, ErrRevokedToken): Session has been revoked
|
|
func (a *AuthService) ValidateRefreshToken(ctx context.Context, tokenString string) (*auth.RefreshTokenClaims, *models.Session, error) {
|
|
// Step 1: Parse and validate JWT
|
|
token, err := jwt.ParseWithClaims(
|
|
tokenString,
|
|
&auth.RefreshTokenClaims{}, // Refresh token claims struct
|
|
func(token *jwt.Token) (interface{}, error) {
|
|
// Verify algorithm is HMAC (security check)
|
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
|
return nil, ErrInvalidToken
|
|
}
|
|
// Return REFRESH secret (different from access secret!)
|
|
return []byte(a.config.JWT.RefreshSecret), nil
|
|
},
|
|
jwt.WithExpirationRequired(), // Check expiration
|
|
jwt.WithIssuedAt(), // Check issued-at
|
|
jwt.WithTimeFunc(time.Now), // Use current time
|
|
)
|
|
|
|
if err != nil {
|
|
if errors.Is(err, jwt.ErrTokenExpired) {
|
|
return nil, nil, ErrExpiredToken
|
|
}
|
|
return nil, nil, ErrInvalidToken
|
|
}
|
|
|
|
// Step 2: Extract and validate claims
|
|
claims, ok := token.Claims.(*auth.RefreshTokenClaims)
|
|
if !ok || !token.Valid {
|
|
return nil, nil, ErrInvalidToken
|
|
}
|
|
|
|
// Step 3: Verify token type
|
|
if claims.TokenType != "refresh" {
|
|
return nil, nil, ErrInvalidToken
|
|
}
|
|
|
|
// Step 4: Look up session in database
|
|
// This checks:
|
|
// - Session exists
|
|
// - Token hash matches
|
|
// - Session not revoked
|
|
// - Session not expired
|
|
session, err := a.sessionRepo.FindBySessionIDAndToken(ctx, claims.SessionID, claims.TokenID)
|
|
if err != nil {
|
|
return nil, nil, err // Database error
|
|
}
|
|
|
|
// Step 5: Verify session was found
|
|
if session == nil {
|
|
// Session doesn't exist or is invalid
|
|
// Could mean: wrong token, session revoked, session expired
|
|
return nil, nil, ErrInvalidToken
|
|
}
|
|
|
|
// Step 6: Verify session not revoked (redundant but explicit)
|
|
if session.IsRevoked {
|
|
// Session was explicitly revoked (logout, password change, etc.)
|
|
return nil, nil, ErrRevokedToken
|
|
}
|
|
|
|
// Step 7: Verify session not expired (redundant but explicit)
|
|
if session.ExpiresAt.Before(time.Now()) {
|
|
// Session expired (different from token expiration)
|
|
return nil, nil, ErrRevokedToken
|
|
}
|
|
|
|
// Step 8: Update session last-used timestamp
|
|
// Tracks when session was last active
|
|
// Useful for security monitoring and cleanup
|
|
// We ignore error (not critical for validation)
|
|
_ = a.sessionRepo.UpdateLastUsed(ctx, session.ID)
|
|
|
|
// All checks passed, return claims and session
|
|
return claims, session, nil
|
|
}
|
|
|
|
// RotateRefreshToken validates an old refresh token and issues a new one.
|
|
// This implements refresh token rotation for enhanced security:
|
|
// 1. Validates the old refresh token (JWT + session)
|
|
// 2. Generates a new random token and creates new session
|
|
// 3. Revokes the old session
|
|
// 4. Returns new access token + new refresh token
|
|
//
|
|
// Security benefits:
|
|
// - Limits window of exposure if token is stolen
|
|
// - Enables detection of token theft (if old token is used after rotation)
|
|
// - Reduces attack surface by regularly cycling credentials
|
|
//
|
|
// Token theft detection:
|
|
// If an attacker uses a stolen old token after the legitimate user has already
|
|
// rotated it, the system can detect this suspicious activity and revoke all
|
|
// sessions for that user as a security precaution.
|
|
func (a *AuthService) RotateRefreshToken(ctx context.Context, oldTokenString string, userAgent *string, ipAddress *string) (string, string, *models.Session, error) {
|
|
// Step 1: Validate the old refresh token
|
|
claims, _, err := a.ValidateRefreshToken(ctx, oldTokenString)
|
|
if err != nil {
|
|
return "", "", nil, err
|
|
}
|
|
|
|
// Step 2: Get user details
|
|
user, err := a.userRepo.FindByID(ctx, claims.UserID)
|
|
if err != nil {
|
|
return "", "", nil, err
|
|
}
|
|
|
|
if user == nil {
|
|
return "", "", nil, ErrInvalidToken
|
|
}
|
|
|
|
// Step 3: Generate new access token
|
|
newAccessToken, err := a.GenerateAccessToken(user)
|
|
if err != nil {
|
|
return "", "", nil, err
|
|
}
|
|
|
|
// Step 4: Generate new refresh token (creates new session)
|
|
newRefreshToken, newSession, err := a.GenerateRefreshToken(ctx, user, userAgent, ipAddress)
|
|
if err != nil {
|
|
return "", "", nil, err
|
|
}
|
|
|
|
// Step 5: Revoke the old session (invalidates old refresh token)
|
|
// Use background context to ensure revocation completes even if request is cancelled
|
|
go func() {
|
|
_ = a.sessionRepo.Revoke(context.Background(), claims.TokenID, "token_rotation")
|
|
}()
|
|
|
|
return newAccessToken, newRefreshToken, newSession, nil
|
|
|
|
}
|
|
|
|
// ValidateRefreshTokenWithRotationCheck validates a refresh token and detects potential theft.
|
|
// If a revoked token is used (possible replay attack after rotation), it revokes all user sessions.
|
|
//
|
|
// Attack scenario:
|
|
// 1. Legitimate user rotates token (old token revoked, new token issued)
|
|
// 2. Attacker tries to use the old stolen token
|
|
// 3. System detects revoked token usage → revokes ALL user sessions
|
|
// 4. Both attacker and legitimate user must re-authenticate
|
|
// 5. User is alerted to suspicious activity
|
|
//
|
|
// This is an optional enhancement for high-security requirements.
|
|
func (a *AuthService) ValidateRefreshTokenWithRotationCheck(ctx context.Context, tokenString string) (*auth.RefreshTokenClaims, *models.Session, error) {
|
|
// Parse JWT to get claims
|
|
token, err := jwt.ParseWithClaims(
|
|
tokenString,
|
|
&auth.RefreshTokenClaims{},
|
|
func(token *jwt.Token) (interface{}, error) {
|
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
|
return nil, ErrInvalidToken
|
|
}
|
|
return []byte(a.config.JWT.RefreshSecret), nil
|
|
},
|
|
jwt.WithExpirationRequired(),
|
|
jwt.WithIssuedAt(),
|
|
jwt.WithTimeFunc(time.Now),
|
|
)
|
|
if err != nil {
|
|
if errors.Is(err, jwt.ErrTokenExpired) {
|
|
return nil, nil, ErrExpiredToken
|
|
}
|
|
return nil, nil, ErrInvalidToken
|
|
}
|
|
|
|
claims, ok := token.Claims.(*auth.RefreshTokenClaims)
|
|
if !ok {
|
|
return nil, nil, ErrInvalidToken
|
|
}
|
|
if claims.TokenType != "refresh" {
|
|
return nil, nil, ErrInvalidToken
|
|
}
|
|
|
|
// Look up session
|
|
session, err := a.sessionRepo.FindBySessionIDAndToken(ctx, claims.SessionID, claims.TokenID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
if session == nil {
|
|
return nil, nil, ErrInvalidToken
|
|
}
|
|
|
|
// THEFT DETECTION: If session is revoked but token is still valid (not expired),
|
|
// this indicates someone is trying to reuse a rotated token.
|
|
// This could be a legitimate user with an old token, or an attacker with a stolen token.
|
|
if session.IsRevoked {
|
|
// Check if token was revoked due to rotation
|
|
if session.RevokedReason != nil && *session.RevokedReason == "token_rotation" {
|
|
// SECURITY EVENT: Possible token theft detected
|
|
// Revoke ALL sessions for this user as a precaution
|
|
go func() {
|
|
_ = a.sessionRepo.RevokeByUserId(context.Background(), session.UserID, "potential_token_theft")
|
|
// TODO: Send security alert email/notification to user
|
|
// TODO: Log security event for monitoring
|
|
}()
|
|
}
|
|
return nil, nil, ErrExpiredToken
|
|
}
|
|
|
|
if session.ExpiresAt.Before(time.Now()) {
|
|
return nil, nil, ErrExpiredToken
|
|
}
|
|
_ = a.sessionRepo.UpdateLastUsed(ctx, session.ID)
|
|
return claims, session, nil
|
|
}
|
|
|
|
// RevokeRefreshToken marks a refresh token as revoked (logout).
|
|
// This is called when user logs out from current device.
|
|
//
|
|
// What happens:
|
|
// 1. Parse JWT to extract token ID
|
|
// 2. Find session by token hash
|
|
// 3. Mark session as revoked in database
|
|
// 4. Record revocation reason and timestamp
|
|
//
|
|
// Why parse JWT if we're revoking?
|
|
// - Need to extract token ID from claims
|
|
// - Token might be expired (that's okay for revocation)
|
|
// - We still verify signature (ensure it's our token)
|
|
//
|
|
// After revocation:
|
|
// - Token can't be used to get new access tokens
|
|
// - Current access tokens still work (until they expire in ~15 min)
|
|
// - User effectively logged out from this device
|
|
//
|
|
// Why current access tokens still work:
|
|
// - Access tokens are stateless (not checked against database)
|
|
// - They expire quickly anyway (15 minutes)
|
|
// - Checking database on every request would be too slow
|
|
// - This is an acceptable security tradeoff
|
|
//
|
|
// Revocation reason:
|
|
// - "user_logout": User clicked logout button
|
|
// - Stored for audit trail
|
|
// - Can be used for analytics
|
|
// - Helps in security investigations
|
|
//
|
|
// Error handling:
|
|
// - Returns error if parsing fails
|
|
// - Returns error if database update fails
|
|
// - Idempotent: OK to revoke already-revoked token
|
|
//
|
|
// Parameters:
|
|
// - ctx: Context for database operations
|
|
// - tokenJWT: JWT string to revoke
|
|
//
|
|
// Return values:
|
|
// - nil: Successfully revoked (or already revoked)
|
|
// - error: Failed to parse token or update database
|
|
func (a *AuthService) RevokeRefreshToken(ctx context.Context, tokenJWT string) error {
|
|
// Step 1: Parse JWT to extract claims
|
|
// We need the token ID to find the session
|
|
// We still use ParseWithClaims even though we're revoking
|
|
// because we need to verify it's actually our token
|
|
token, err := jwt.ParseWithClaims(
|
|
tokenJWT,
|
|
&auth.RefreshTokenClaims{},
|
|
func(token *jwt.Token) (interface{}, error) {
|
|
// Verify algorithm
|
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
|
return nil, ErrInvalidToken
|
|
}
|
|
return []byte(a.config.JWT.RefreshSecret), nil
|
|
},
|
|
// Note: We still require expiration and issued-at validation
|
|
// even for revocation, to ensure token structure is valid
|
|
jwt.WithExpirationRequired(),
|
|
jwt.WithIssuedAt(),
|
|
jwt.WithTimeFunc(time.Now),
|
|
)
|
|
if err != nil {
|
|
// Could be expired token (that's okay for revocation)
|
|
// But we still return error to indicate parsing failure
|
|
return err
|
|
}
|
|
|
|
// Step 2: Extract claims
|
|
claims, ok := token.Claims.(*auth.RefreshTokenClaims)
|
|
if !ok {
|
|
return ErrInvalidToken
|
|
}
|
|
|
|
// Step 3: Revoke the session in database
|
|
// Uses token ID to find session
|
|
// Marks as revoked with reason "user_logout"
|
|
// Operation is idempotent (safe to call multiple times)
|
|
return a.sessionRepo.Revoke(ctx, claims.TokenID, "user_logout")
|
|
}
|
|
|
|
// RevokeAllUserToken revokes all refresh tokens for a user (logout all devices).
|
|
// This is a security feature for:
|
|
// - Password change (force re-login everywhere)
|
|
// - Account compromise (revoke all access)
|
|
// - User request (logout from all devices)
|
|
// - Administrative action (force logout)
|
|
//
|
|
// What it does:
|
|
// - Finds all non-revoked sessions for user
|
|
// - Marks them all as revoked
|
|
// - Records revocation reason "revoke_all"
|
|
// - Updates revocation timestamp
|
|
//
|
|
// After calling this:
|
|
// - All refresh tokens for user become invalid
|
|
// - User must log in again on all devices
|
|
// - Current access tokens still work (until they expire in ~15 min)
|
|
//
|
|
// Use cases:
|
|
// 1. Password change: User changes password, log out all devices
|
|
// 2. Security breach: User reports compromise, revoke all access
|
|
// 3. Lost device: User lost phone, remotely log out all
|
|
// 4. Suspicious activity: Admin detects breach, force logout
|
|
// 5. Account termination: Ensure all access revoked
|
|
//
|
|
// Why this is important:
|
|
// - User control: Can remotely log out stolen device
|
|
// - Security: Limits damage from compromise
|
|
// - Password change: Ensures old sessions can't continue
|
|
// - Compliance: May be required for certain operations
|
|
//
|
|
// What happens to user:
|
|
// - All devices logged out
|
|
// - Must log in again with new credentials
|
|
// - Sees all sessions revoked in session list
|
|
// - Receives email notification (recommended)
|
|
//
|
|
// Parameters:
|
|
// - ctx: Context for database operations
|
|
// - userId: User whose tokens to revoke
|
|
//
|
|
// Return values:
|
|
// - nil: All tokens successfully revoked
|
|
// - error: Database error occurred
|
|
func (a *AuthService) RevokeAllUserToken(ctx context.Context, userId uuid.UUID) error {
|
|
// Revoke all sessions for user
|
|
// Reason "revoke_all" indicates this was bulk revocation
|
|
// Repository handles finding and updating all sessions
|
|
return a.sessionRepo.RevokeByUserId(ctx, userId, "revoke_all")
|
|
}
|
|
|
|
// detectDeviceType attempts to determine device type from user agent string.
|
|
// This is used for:
|
|
// - Session display (show user what device they're logged in on)
|
|
// - Security monitoring (detect unusual devices)
|
|
// - Analytics (understand user devices)
|
|
// - Targeted features (mobile vs desktop experience)
|
|
//
|
|
// Detection logic:
|
|
// 1. Check for mobile indicators: "Mobile", "Android", "iPhone"
|
|
// 2. Check for desktop app indicator: "Electron"
|
|
// 3. Default to "web" for browsers
|
|
// 4. Return "unknown" if no user agent
|
|
//
|
|
// Device types:
|
|
// - "mobile": Smartphones and tablets (Android, iOS)
|
|
// - "desktop": Desktop applications (Electron apps)
|
|
// - "web": Web browsers on desktop/laptop
|
|
// - "unknown": No user agent or unrecognized
|
|
//
|
|
// Limitations:
|
|
// - User agent can be spoofed (not 100% reliable)
|
|
// - Simple detection (not comprehensive device detection)
|
|
// - Can't distinguish tablet from phone
|
|
// - Can't detect specific browser or OS version
|
|
//
|
|
// For better detection, consider:
|
|
// - User agent parsing library (more comprehensive)
|
|
// - Client-side detection (more accurate)
|
|
// - Device fingerprinting (more reliable but privacy concerns)
|
|
//
|
|
// Why this is good enough:
|
|
// - Just for display/convenience
|
|
// - Not used for security decisions
|
|
// - Simple and fast
|
|
// - No external dependencies
|
|
//
|
|
// Parameters:
|
|
// - userAgent: User-Agent header from HTTP request
|
|
//
|
|
// Returns:
|
|
// - "mobile": Mobile device detected
|
|
// - "desktop": Desktop application detected
|
|
// - "web": Web browser (default for desktop browsers)
|
|
// - "unknown": No user agent or unrecognized
|
|
func detectDeviceType(userAgent *string) string {
|
|
// Handle nil user agent (no header provided)
|
|
if userAgent == nil {
|
|
return "unknown"
|
|
}
|
|
|
|
// Get user agent string
|
|
ua := *userAgent
|
|
|
|
// Check for mobile indicators
|
|
// Contains checks for substring (case-insensitive via contains helper)
|
|
// Mobile keywords: "Mobile", "Android", "Iphone" (covers iOS and Android)
|
|
if contains(ua, "Mobile") || contains(ua, "Android") || contains(ua, "Iphone") {
|
|
return "mobile"
|
|
}
|
|
|
|
// Check for desktop application
|
|
// "Electron" indicates Electron-based desktop app
|
|
if contains(ua, "Electron") {
|
|
return "desktop"
|
|
}
|
|
|
|
// Default to web browser
|
|
// Catches Chrome, Firefox, Safari, Edge, etc. on desktop
|
|
return "web"
|
|
}
|
|
|
|
// contains checks if a string contains a substring (case-insensitive).
|
|
// This is a helper function for user agent parsing.
|
|
//
|
|
// Why case-insensitive?
|
|
// - User agents can vary in casing
|
|
// - "iPhone" vs "iphone" vs "IPHONE"
|
|
// - "Android" vs "android"
|
|
// - More robust matching
|
|
//
|
|
// Implementation:
|
|
// - Convert both strings to lowercase
|
|
// - Use strings.Contains for substring check
|
|
//
|
|
// Parameters:
|
|
// - s: String to search in
|
|
// - substring: Substring to search for
|
|
//
|
|
// Returns:
|
|
// - true: Substring found (case-insensitive)
|
|
// - false: Substring not found
|
|
func contains(s string, substring string) bool {
|
|
// Convert both to lowercase and check if substring exists
|
|
return strings.Contains(strings.ToLower(s), strings.ToLower(substring))
|
|
}
|