953 lines
36 KiB
Go
953 lines
36 KiB
Go
package handlers
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
|
|
"github.com/creativenoz/aurganize-v62/backend/internal/config"
|
|
"github.com/creativenoz/aurganize-v62/backend/internal/services"
|
|
"github.com/labstack/echo/v4"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
// AuthHandler handles all authentication-related HTTP requests.
|
|
// This handler is responsible for:
|
|
// 1. User login (validating credentials, generating tokens)
|
|
// 2. Token refresh (obtaining new access tokens using refresh tokens)
|
|
// 3. User logout (revoking refresh tokens, clearing cookies)
|
|
//
|
|
// Architecture pattern used: Handler -> Service -> Repository
|
|
// - Handler: Handles HTTP concerns (request parsing, response formatting, cookies)
|
|
// - Service: Implements business logic (token generation, validation)
|
|
// - Repository: Handles data persistence (database operations)
|
|
//
|
|
// This separation ensures:
|
|
// - Clean code organization
|
|
// - Testability (can mock dependencies)
|
|
// - Reusability (services can be used by other handlers)
|
|
type AuthHandler struct {
|
|
config *config.Config // Application configuration (JWT settings, cookie config, etc.)
|
|
authService *services.AuthService // Service for token generation and validation logic
|
|
userService *services.UserService // Service for user-related operations (authentication, fetching user data)
|
|
}
|
|
|
|
// NewAuthHandler creates a new instance of AuthHandler with injected dependencies.
|
|
// This constructor follows the dependency injection pattern:
|
|
// - Dependencies are passed in rather than created internally
|
|
// - Makes testing easier (can pass mock implementations)
|
|
// - Makes dependencies explicit and visible
|
|
// - Follows SOLID principles (Dependency Inversion)
|
|
//
|
|
// Parameters:
|
|
// - cfg: Configuration containing JWT secrets, cookie settings, etc.
|
|
// - authServ: Service for handling authentication logic
|
|
// - userServ: Service for handling user operations
|
|
//
|
|
// Returns:
|
|
// - Fully initialized AuthHandler ready to handle requests
|
|
func NewAuthHandler(
|
|
cfg *config.Config,
|
|
authServ *services.AuthService,
|
|
userServ *services.UserService,
|
|
) *AuthHandler {
|
|
log.Info().
|
|
Str("handler", "auth").
|
|
Str("cookie_domain", cfg.Cookie.CookieDomain).
|
|
Bool("cookie_secure", cfg.Cookie.CookieSecure).
|
|
Dur("access_expiry", cfg.JWT.AccessExpiry).
|
|
Dur("refresh_expiry", cfg.JWT.RefreshExpiry).
|
|
Msg("auth handler initialized")
|
|
return &AuthHandler{
|
|
config: cfg,
|
|
authService: authServ,
|
|
userService: userServ,
|
|
}
|
|
}
|
|
|
|
// LoginRequest represents the expected JSON structure for login requests.
|
|
// JSON tags specify how struct fields map to JSON keys.
|
|
// Validate tags specify validation rules applied by Echo's validator.
|
|
//
|
|
// Why use struct tags?
|
|
// - json: Controls JSON serialization/deserialization
|
|
// - validate: Enables automatic validation (email format, required fields, etc.)
|
|
//
|
|
// This approach provides:
|
|
// - Type safety (compile-time checking)
|
|
// - Automatic validation (don't need manual validation code)
|
|
// - Clear API contract (documents expected request format)
|
|
type LoginRequest struct {
|
|
Email string `json:"email" validate:"required,email"` // Email must be present and valid format
|
|
Password string `json:"password" validate:"required"` // Password must be present (no format validation for flexibility)
|
|
}
|
|
|
|
// LoginResponse represents the JSON structure returned after successful login.
|
|
// Contains everything the client needs to maintain an authenticated session:
|
|
// - User data for display/personalization
|
|
// - Access token for API requests
|
|
// - Refresh token for obtaining new access tokens
|
|
// - Expiration time for token lifetime management
|
|
//
|
|
// Why include both tokens in response AND cookies?
|
|
// - Cookies: Used for browser-based requests (more secure with HttpOnly flag)
|
|
// - JSON body: Used by mobile apps or clients that prefer token management in localStorage
|
|
// - This dual approach supports multiple client types
|
|
type LoginResponse struct {
|
|
User interface{} `json:"user"` // User object (actual type depends on sanitization)
|
|
AccessToken string `json:"access_token"` // JWT access token for API authentication
|
|
RefreshToken string `json:"refresh_token"` // JWT refresh token for obtaining new access tokens
|
|
ExpiresIn int `json:"expires_in"` // Access token lifetime in seconds
|
|
}
|
|
|
|
// TokenRefreshRequest represents the request body for token refresh with rotation.
|
|
// This struct is used when the client provides the refresh token in the request body
|
|
// instead of (or in addition to) cookies.
|
|
//
|
|
// Use cases:
|
|
// - Mobile apps that manage tokens in secure storage
|
|
// - SPAs that prefer localStorage/sessionStorage over cookies
|
|
// - Cross-origin scenarios where cookies may not work
|
|
// - Testing and development
|
|
//
|
|
// The refresh token can come from either:
|
|
// 1. Request body (this struct) - for programmatic clients
|
|
// 2. HTTP-only cookie - for browser clients
|
|
// The handler will check both sources
|
|
type TokenRefreshRequest struct {
|
|
RefreshToken string `json:"refresh_token" validate:"required"` // JWT refresh token to rotate
|
|
}
|
|
|
|
// TokenRefreshResponse represents the response after successful token refresh with rotation.
|
|
// Contains both new access and refresh tokens, requiring client to update stored tokens.
|
|
//
|
|
// Why both tokens are returned:
|
|
// - AccessToken: New short-lived token for immediate API use
|
|
// - RefreshToken: New refresh token (old one is now invalid)
|
|
// - ExpiresIn: Tells client when to request next refresh
|
|
//
|
|
// IMPORTANT: Client MUST store the new refresh token, as the old one is invalidated.
|
|
// Attempting to reuse the old refresh token will fail and may trigger security alerts.
|
|
type TokenRefreshResponse struct {
|
|
AccessToken string `json:"access_token"` // New JWT access token for API authentication
|
|
RefreshToken string `json:"refresh_token"` // New JWT refresh token (MUST replace old one)
|
|
ExpiresIn int `json:"expires_in"` // Access token lifetime in seconds
|
|
}
|
|
|
|
// Login handles user authentication and token generation.
|
|
// This endpoint processes login requests through several steps:
|
|
//
|
|
// Flow:
|
|
// 1. Parse and validate request body (email, password)
|
|
// 2. Authenticate user credentials against database
|
|
// 3. Check if user account is active
|
|
// 4. Generate access token (short-lived, for API requests)
|
|
// 5. Generate refresh token (long-lived, for obtaining new access tokens)
|
|
// 6. Store tokens in HTTP-only cookies (XSS protection)
|
|
// 7. Update user's last login timestamp and IP
|
|
// 8. Return user data and tokens in response
|
|
//
|
|
// Security measures:
|
|
// - Passwords never returned (sanitized in response)
|
|
// - Generic error messages (prevents email enumeration)
|
|
// - HttpOnly cookies (prevents JavaScript access)
|
|
// - Account status check (prevents access to deactivated accounts)
|
|
// - IP and user agent tracking (audit trail, session management)
|
|
//
|
|
// Error handling:
|
|
// - 400: Invalid request format or validation errors
|
|
// - 401: Invalid credentials (wrong email or password)
|
|
// - 403: Account exists but not active (suspended, pending verification, etc.)
|
|
// - 500: Server errors (token generation failure, database errors)
|
|
func (h *AuthHandler) Login(c echo.Context) error {
|
|
log.Info().
|
|
Str("handler", "auth").
|
|
Str("action", "login_attempt").
|
|
Str("ip", c.RealIP()).
|
|
Str("user_agent", c.Request().UserAgent()).
|
|
Msg("login attempt started")
|
|
|
|
// Step 1: Parse request body into LoginRequest struct
|
|
var req LoginRequest
|
|
|
|
// Bind() extracts JSON from request body and populates the struct
|
|
// It handles:
|
|
// - JSON parsing
|
|
// - Type conversion
|
|
// - Field mapping based on json tags
|
|
if err := c.Bind(&req); err != nil {
|
|
log.Warn().
|
|
Str("handler", "auth").
|
|
Str("action", "login_bind_failed").
|
|
Str("ip", c.RealIP()).
|
|
Err(err).
|
|
Msg("failed to bind login request")
|
|
// Return 400 Bad Request if JSON is malformed or doesn't match struct
|
|
return echo.NewHTTPError(http.StatusBadRequest, "invalid request")
|
|
}
|
|
|
|
// Step 2: Validate request using struct validation tags
|
|
// Echo's validator checks:
|
|
// - required: Email and password must be present
|
|
// - email: Email must be valid format (contains @, proper structure)
|
|
if err := c.Validate(&req); err != nil {
|
|
log.Warn().
|
|
Str("handler", "auth").
|
|
Str("action", "login_validation_failed").
|
|
Str("email", req.Email).
|
|
Str("validation_error", err.Error()).
|
|
Str("ip", c.RealIP()).
|
|
Msg("login request validation failed")
|
|
// Return 400 Bad Request with specific validation error
|
|
// err.Error() contains details like "Email is required" or "Email is invalid"
|
|
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
|
}
|
|
|
|
// Get request context for passing to service layer
|
|
// Context allows:
|
|
// - Request cancellation propagation
|
|
// - Timeout enforcement
|
|
// - Value passing (trace IDs, user info, etc.)
|
|
ctx := c.Request().Context()
|
|
log.Info().
|
|
Str("handler", "auth").
|
|
Str("action", "authenticate_attempt").
|
|
Str("email", req.Email).
|
|
Str("ip", c.RealIP()).
|
|
Msg("attempting to authenticate user")
|
|
|
|
// Step 3: Authenticate user by email and password
|
|
// This calls the user service which:
|
|
// 1. Looks up user by email
|
|
// 2. Verifies password using bcrypt
|
|
// 3. Returns user object if valid
|
|
user, err := h.userService.AuthenticateUserByEmail(ctx, req.Email, req.Password)
|
|
if err != nil {
|
|
log.Warn().
|
|
Str("handler", "auth").
|
|
Str("action", "authentication_failed").
|
|
Str("email", req.Email).
|
|
Str("ip", c.RealIP()).
|
|
Err(err).
|
|
Msg("user authentication failed")
|
|
// Return 401 Unauthorized with generic message
|
|
// We use a generic message to prevent email enumeration attacks
|
|
// (attacker can't tell if email exists but password is wrong)
|
|
return echo.NewHTTPError(http.StatusUnauthorized, "invalid credentials")
|
|
}
|
|
|
|
// Step 4: Check if user account is active
|
|
// Status could be: "active", "suspended", "pending_verification", "deleted", etc.
|
|
// Only "active" users can log in
|
|
if user.Status != "active" {
|
|
log.Warn().
|
|
Str("handler", "auth").
|
|
Str("action", "inactive_account_login").
|
|
Str("user_id", user.ID.String()).
|
|
Str("email", user.Email).
|
|
Str("status", user.Status).
|
|
Str("ip", c.RealIP()).
|
|
Msg("login attempt on inactive account")
|
|
// Return 403 Forbidden (authenticated but not authorized)
|
|
// Different from 401 because we know who they are, but they can't access
|
|
return echo.NewHTTPError(http.StatusForbidden, "account is not active")
|
|
}
|
|
log.Info().
|
|
Str("handler", "auth").
|
|
Str("action", "authentication_success").
|
|
Str("user_id", user.ID.String()).
|
|
Str("email", user.Email).
|
|
Str("tenant_id", user.TenantID.String()).
|
|
Str("role", user.Role).
|
|
Msg("user authenticated successfully, generating tokens")
|
|
|
|
// Step 5: Generate access token
|
|
// Access token is short-lived (typically 15 minutes) and contains:
|
|
// - User ID, tenant ID, email, role
|
|
// - Expiration time
|
|
// - Issuer information
|
|
// Used for authenticating API requests
|
|
accessToken, err := h.authService.GenerateAccessToken(user)
|
|
if err != nil {
|
|
log.Error().
|
|
Str("handler", "auth").
|
|
Str("action", "access_token_generation_failed").
|
|
Str("user_id", user.ID.String()).
|
|
Str("email", user.Email).
|
|
Err(err).
|
|
Msg("failed to generate access token")
|
|
// Return 500 Internal Server Error
|
|
// Token generation should rarely fail unless there's a configuration issue
|
|
return echo.NewHTTPError(http.StatusInternalServerError, "failed to generate access token")
|
|
}
|
|
|
|
// Extract client information for session tracking
|
|
// User agent identifies the client (browser type, OS, etc.)
|
|
userAgent := c.Request().UserAgent()
|
|
// Real IP handles proxies and load balancers to get actual client IP
|
|
ipAddress := c.RealIP()
|
|
log.Info().
|
|
Str("handler", "auth").
|
|
Str("action", "generating_refresh_token").
|
|
Str("user_id", user.ID.String()).
|
|
Str("ip", ipAddress).
|
|
Str("user_agent", userAgent).
|
|
Msg("generating refresh token and creating session")
|
|
|
|
// Step 6: Generate refresh token
|
|
// Refresh token is long-lived (typically 7 days) and:
|
|
// - Creates a session record in database
|
|
// - Stores hashed token for validation
|
|
// - Tracks device information (user agent, IP)
|
|
// - Enables session management (list active sessions, revoke specific sessions)
|
|
refreshToken, _, err := h.authService.GenerateRefreshToken(ctx, user, &userAgent, &ipAddress)
|
|
if err != nil {
|
|
log.Error().
|
|
Str("handler", "auth").
|
|
Str("action", "refresh_token_generation_failed").
|
|
Str("user_id", user.ID.String()).
|
|
Str("email", user.Email).
|
|
Err(err).
|
|
Msg("failed to generate refresh token")
|
|
|
|
// Return 500 Internal Server Error
|
|
// This could fail due to database issues
|
|
return echo.NewHTTPError(http.StatusInternalServerError, "failed to generate refresh token")
|
|
}
|
|
|
|
// Step 7: Set tokens as HTTP-only cookies
|
|
// This provides security benefits:
|
|
// - HttpOnly flag prevents JavaScript access (XSS protection)
|
|
// - Secure flag (in production) ensures HTTPS-only transmission
|
|
// - SameSite flag provides CSRF protection
|
|
// Cookies are automatically sent with requests, no client-side token management needed
|
|
h.setAccessTokenCookie(c, accessToken)
|
|
h.setRefreshTokenCookie(c, refreshToken)
|
|
log.Debug().
|
|
Str("handler", "auth").
|
|
Str("action", "updating_last_login").
|
|
Str("user_id", user.ID.String()).
|
|
Str("ip", ipAddress).
|
|
Msg("updating user last login timestamp")
|
|
// Step 8: Update user's last login information
|
|
// Track when and from where user logged in for:
|
|
// - Security audit trail
|
|
// - User awareness (show "last login" in UI)
|
|
// - Suspicious activity detection
|
|
// We ignore errors here (don't fail login if this update fails)
|
|
if err = h.userService.UpdateLastLogin(ctx, user.ID, &ipAddress); err != nil {
|
|
log.Warn().
|
|
Str("handler", "auth").
|
|
Str("action", "last_login_update_failed").
|
|
Str("user_id", user.ID.String()).
|
|
Err(err).
|
|
Msg("failed to update last login (non-critical)")
|
|
}
|
|
log.Info().
|
|
Str("handler", "auth").
|
|
Str("action", "login_success").
|
|
Str("user_id", user.ID.String()).
|
|
Str("email", user.Email).
|
|
Str("tenant_id", user.TenantID.String()).
|
|
Str("role", user.Role).
|
|
Str("ip", ipAddress).
|
|
Str("user_agent", userAgent).
|
|
Msg("user logged in successfully")
|
|
|
|
// Step 9: Return successful response with user data and tokens
|
|
// Response includes:
|
|
// - Sanitized user object (passwords removed)
|
|
// - Both tokens (for non-cookie clients like mobile apps)
|
|
// - Token expiration time
|
|
return c.JSON(http.StatusOK, LoginResponse{
|
|
User: h.sanitizeUser(user),
|
|
AccessToken: accessToken,
|
|
RefreshToken: refreshToken,
|
|
ExpiresIn: int(h.config.JWT.AccessExpiry.Seconds()), // Convert duration to seconds
|
|
})
|
|
}
|
|
|
|
// Refresh handles access token renewal using a refresh token.
|
|
// This endpoint allows clients to obtain a new access token without re-entering credentials.
|
|
//
|
|
// Why separate access and refresh tokens?
|
|
// - Security: Access tokens are short-lived (15 min) limiting exposure if stolen
|
|
// - UX: Refresh tokens are long-lived (7 days) so users don't constantly re-login
|
|
// - Control: Can revoke refresh tokens (logout all devices) without affecting active requests
|
|
//
|
|
// Flow:
|
|
// 1. Extract refresh token from HTTP-only cookie
|
|
// 2. Validate refresh token (signature, expiration, revocation status)
|
|
// 3. Fetch user from database (ensure user still exists and is active)
|
|
// 4. Generate new access token
|
|
// 5. Set new access token cookie
|
|
// 6. Return new access token in response
|
|
//
|
|
// Security measures:
|
|
// - Refresh token stored in database (can be revoked)
|
|
// - Validates token hasn't been revoked
|
|
// - Checks token expiration
|
|
// - Updates session last-used timestamp
|
|
//
|
|
// Error handling:
|
|
// - 401: Missing refresh token, invalid token, expired token, user not found
|
|
// - 500: Token generation failure
|
|
func (h *AuthHandler) Refresh(c echo.Context) error {
|
|
log.Info().
|
|
Str("handler", "auth").
|
|
Str("action", "refresh_attempt").
|
|
Str("ip", c.RealIP()).
|
|
Msg("token refresh attempt started")
|
|
// Step 1: Extract refresh token from cookie
|
|
// Cookie name must match what was set during login ("refresh_token")
|
|
// Cookies are automatically parsed by Echo from Cookie header
|
|
cookie, err := c.Cookie("refresh_token")
|
|
if err != nil {
|
|
log.Warn().
|
|
Str("handler", "auth").
|
|
Str("action", "refresh_missing_token").
|
|
Str("ip", c.RealIP()).
|
|
Msg("refresh token cookie not found")
|
|
// Return 401 if cookie is missing
|
|
// This means user is not authenticated or cookie expired/was deleted
|
|
return echo.NewHTTPError(http.StatusUnauthorized, "missing refresh token")
|
|
}
|
|
|
|
// Get request context
|
|
ctx := c.Request().Context()
|
|
|
|
log.Debug().
|
|
Str("handler", "auth").
|
|
Str("action", "validating_refresh_token").
|
|
Str("ip", c.RealIP()).
|
|
Msg("validating refresh token")
|
|
// Step 2: Validate the refresh token
|
|
// This process:
|
|
// 1. Verifies JWT signature using refresh secret
|
|
// 2. Checks token expiration time
|
|
// 3. Looks up session in database using SessionID from claims
|
|
// 4. Verifies session hasn't been revoked
|
|
// 5. Updates session's last_used_at timestamp
|
|
// Returns claims (user ID, session ID, etc.) and session object
|
|
claims, _, err := h.authService.ValidateRefreshToken(ctx, cookie.Value)
|
|
if err != nil {
|
|
log.Warn().
|
|
Str("handler", "auth").
|
|
Str("action", "refresh_validation_failed").
|
|
Str("ip", c.RealIP()).
|
|
Err(err).
|
|
Msg("refresh token validation failed")
|
|
// Return 401 with error details
|
|
// Could be: "invalid token", "token expired", "token revoked"
|
|
return echo.NewHTTPError(http.StatusUnauthorized, err.Error())
|
|
}
|
|
log.Debug().
|
|
Str("handler", "auth").
|
|
Str("action", "refresh_fetching_user").
|
|
Str("user_id", claims.UserID.String()).
|
|
Str("session_id", claims.SessionID.String()).
|
|
Msg("refresh token validated, fetching user data")
|
|
// Step 3: Fetch current user from database
|
|
// We re-fetch the user to ensure:
|
|
// - User still exists (not deleted)
|
|
// - User data is current (role might have changed)
|
|
// - User is still active (account not suspended)
|
|
user, err := h.userService.GetByID(ctx, claims.UserID)
|
|
if err != nil {
|
|
log.Warn().
|
|
Str("handler", "auth").
|
|
Str("action", "refresh_user_not_found").
|
|
Str("user_id", claims.UserID.String()).
|
|
Str("session_id", claims.SessionID.String()).
|
|
Err(err).
|
|
Msg("user not found during token refresh")
|
|
// Return 401 if user not found
|
|
// This could mean user was deleted since token was issued
|
|
return echo.NewHTTPError(http.StatusUnauthorized, "user not found")
|
|
}
|
|
|
|
// Step 4: Generate new access token
|
|
// New token contains current user data (including any role changes)
|
|
accessToken, err := h.authService.GenerateAccessToken(user) // Note: typo "Tokken"
|
|
if err != nil {
|
|
log.Error().
|
|
Str("handler", "auth").
|
|
Str("action", "refresh_token_generation_failed").
|
|
Str("user_id", user.ID.String()).
|
|
Err(err).
|
|
Msg("failed to generate new access token during refresh")
|
|
// Return 500 if token generation fails
|
|
return echo.NewHTTPError(http.StatusInternalServerError, "failed to generate access token")
|
|
}
|
|
|
|
// Step 5: Set new access token cookie
|
|
// Only the access token is refreshed, refresh token remains the same
|
|
// This is more secure - refresh token changes only on login/explicit refresh
|
|
h.setAccessTokenCookie(c, accessToken)
|
|
log.Info().
|
|
Str("handler", "auth").
|
|
Str("action", "refresh_success").
|
|
Str("user_id", user.ID.String()).
|
|
Str("session_id", claims.SessionID.String()).
|
|
Str("ip", c.RealIP()).
|
|
Msg("access token refreshed successfully")
|
|
// Step 6: Return new access token
|
|
// Response contains:
|
|
// - New access token (for non-cookie clients)
|
|
// - Expiration time (so client knows when to refresh again)
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"access_token": accessToken,
|
|
"expires_in": int(h.config.JWT.AccessExpiry.Seconds()),
|
|
})
|
|
}
|
|
|
|
// RefreshTokenWithRotation handles the token refresh endpoint with rotation enabled.
|
|
// This endpoint implements refresh token rotation for enhanced security:
|
|
//
|
|
// What is token rotation?
|
|
// - Every time a refresh token is used, a NEW refresh token is issued
|
|
// - The old refresh token is immediately invalidated (revoked in database)
|
|
// - Client receives both new access token AND new refresh token
|
|
// - Client must store the new refresh token for next refresh
|
|
//
|
|
// Security benefits over non-rotating tokens:
|
|
// 1. Limited exposure window: Stolen tokens become useless after legitimate user refreshes
|
|
// 2. Theft detection: Reusing old tokens after rotation indicates potential compromise
|
|
// 3. Reduced attack surface: Each token is single-use after rotation
|
|
// 4. Fresh cryptographic material: New random token generated each time
|
|
//
|
|
// Token sources (checked in order):
|
|
// 1. Request body (req.RefreshToken) - for mobile/SPA clients
|
|
// 2. HTTP-only cookie - for browser-based clients
|
|
// This dual approach supports multiple client types
|
|
//
|
|
// Response (Error - 401 Unauthorized):
|
|
// - Missing refresh token (not in body or cookie)
|
|
// - Invalid token signature
|
|
// - Expired refresh token
|
|
// - Revoked session (token already used after rotation)
|
|
// - User not found or account inactive
|
|
//
|
|
// Response (Error - 500 Internal Server Error):
|
|
// - Token generation failure
|
|
// - Database errors
|
|
//
|
|
// Security considerations:
|
|
// - Old refresh token is immediately invalidated after successful rotation
|
|
// - Attempting to reuse old token may trigger security alerts (theft detection)
|
|
// - Both tokens should be transmitted over HTTPS only in production
|
|
// - Cookies use HttpOnly flag to prevent JavaScript access (XSS protection)
|
|
// - Session tracks device/IP for security monitoring
|
|
//
|
|
// Client implementation requirements:
|
|
// - MUST store the new refresh token from response
|
|
// - MUST discard the old refresh token immediately
|
|
// - MUST NOT retry with old token if refresh fails
|
|
// - SHOULD implement secure token storage (keychain, secure storage, etc.)
|
|
func (h *AuthHandler) RefreshTokenWithRotation(c echo.Context) error {
|
|
log.Info().
|
|
Str("handler", "auth").
|
|
Str("action", "token_rotation_attempt").
|
|
Str("ip", c.RealIP()).
|
|
Msg("token rotation with refresh attempt started")
|
|
// Step 1: Parse request body (optional - might use cookie instead)
|
|
var req TokenRefreshRequest
|
|
// Attempt to bind JSON from request body
|
|
// This is optional - we'll also check cookies
|
|
// Bind error is not fatal here
|
|
if err := c.Bind(&req); err != nil {
|
|
// Binding failed - no body or malformed JSON
|
|
// Not an error yet, we'll check cookie next
|
|
req.RefreshToken = "" // Ensure empty if bind failed
|
|
}
|
|
|
|
// Step 2: Determine refresh token source
|
|
// Check request body first, then fall back to cookie
|
|
// This supports both browser and non-browser clients
|
|
var refreshToken string
|
|
|
|
var tokenSource string
|
|
|
|
if req.RefreshToken != "" {
|
|
// Token provided in request body (mobile/SPA clients)
|
|
refreshToken = req.RefreshToken
|
|
tokenSource = "request_body"
|
|
} else {
|
|
// Try to get token from HTTP-only cookie (browser clients)
|
|
cookie, err := c.Cookie("refresh_token")
|
|
if err != nil {
|
|
log.Warn().
|
|
Str("handler", "auth").
|
|
Str("action", "rotation_missing_token").
|
|
Str("ip", c.RealIP()).
|
|
Msg("refresh token not found in body or cookie")
|
|
// No token in body or cookie
|
|
return echo.NewHTTPError(http.StatusUnauthorized, "missing refresh token")
|
|
}
|
|
refreshToken = cookie.Value
|
|
tokenSource = "cookie"
|
|
}
|
|
|
|
log.Debug().
|
|
Str("handler", "auth").
|
|
Str("action", "rotation_token_source").
|
|
Str("token_source", tokenSource).
|
|
Str("ip", c.RealIP()).
|
|
Msg("refresh token source identified")
|
|
|
|
// Step 3: Validate that we have a token
|
|
if refreshToken == "" {
|
|
return echo.NewHTTPError(http.StatusUnauthorized, "missing refresh token")
|
|
}
|
|
// Get request context for cancellation and timeout propagation
|
|
ctx := c.Request().Context()
|
|
|
|
// Step 4: Extract client metadata for new session
|
|
// This information is stored with the new session for:
|
|
// - Security monitoring (detect unusual locations/devices)
|
|
// - Session display (show user their active sessions)
|
|
// - Audit trail (track when/where tokens were used)
|
|
userAgent := c.Request().UserAgent()
|
|
ipAddress := c.RealIP()
|
|
|
|
log.Info().
|
|
Str("handler", "auth").
|
|
Str("action", "rotating_refresh_token").
|
|
Str("ip", ipAddress).
|
|
Str("user_agent", userAgent).
|
|
Str("token_source", tokenSource).
|
|
Msg("rotating refresh token and creating new session")
|
|
// Step 5: Rotate the refresh token
|
|
// This process:
|
|
// 1. Validates old token (JWT + session check)
|
|
// 2. Generates new access token
|
|
// 3. Generates new refresh token (creates new session)
|
|
// 4. Revokes old session (invalidates old token)
|
|
newAccessToken, newRefreshToken, _, err := h.authService.RotateRefreshToken(
|
|
ctx,
|
|
refreshToken,
|
|
&userAgent,
|
|
&ipAddress,
|
|
)
|
|
|
|
if err != nil {
|
|
// Handle specific error types with appropriate HTTP status and messages
|
|
// Using errors.Is() for proper error comparison
|
|
if errors.Is(err, services.ErrExpiredToken) {
|
|
log.Warn().
|
|
Str("handler", "auth").
|
|
Str("action", "rotation_expired_token").
|
|
Str("ip", ipAddress).
|
|
Str("token_source", tokenSource).
|
|
Msg("refresh token expired during rotation")
|
|
|
|
// Refresh token has expired (needs re-login)
|
|
return echo.NewHTTPError(http.StatusUnauthorized, "refresh token expired")
|
|
}
|
|
if errors.Is(err, services.ErrRevokedToken) {
|
|
log.Warn().
|
|
Str("handler", "auth").
|
|
Str("action", "rotation_revoked_token").
|
|
Str("ip", ipAddress).
|
|
Str("token_source", tokenSource).
|
|
Msg("revoked refresh token used in rotation attempt")
|
|
// Session was revoked (logout, password change, etc.)
|
|
return echo.NewHTTPError(http.StatusUnauthorized, "refresh token revoked")
|
|
}
|
|
if errors.Is(err, services.ErrInvalidToken) {
|
|
log.Warn().
|
|
Str("handler", "auth").
|
|
Str("action", "rotation_invalid_token").
|
|
Str("ip", ipAddress).
|
|
Str("token_source", tokenSource).
|
|
Msg("invalid refresh token in rotation attempt")
|
|
// Token signature invalid or malformed
|
|
return echo.NewHTTPError(http.StatusUnauthorized, "invalid refresh token")
|
|
}
|
|
log.Error().
|
|
Str("handler", "auth").
|
|
Str("action", "rotation_failed").
|
|
Str("ip", ipAddress).
|
|
Str("token_source", tokenSource).
|
|
Err(err).
|
|
Msg("token rotation failed with unexpected error")
|
|
// Generic error for unexpected cases
|
|
return echo.NewHTTPError(http.StatusUnauthorized, "invalid refresh token")
|
|
}
|
|
|
|
// Step 6: Set new tokens in HTTP-only cookies
|
|
// This benefits browser clients:
|
|
// - Cookies automatically sent with requests
|
|
// - HttpOnly prevents JavaScript access (XSS protection)
|
|
// - Browser handles storage securely
|
|
// Non-browser clients will use tokens from response body
|
|
h.setAccessTokenCookie(c, newAccessToken)
|
|
h.setRefreshTokenCookie(c, newRefreshToken)
|
|
|
|
log.Info().
|
|
Str("handler", "auth").
|
|
Str("action", "rotation_success").
|
|
Str("ip", ipAddress).
|
|
Str("user_agent", userAgent).
|
|
Str("token_source", tokenSource).
|
|
Msg("refresh token rotated successfully, new tokens issued")
|
|
|
|
// Step 7: Return new tokens in response body
|
|
// Both browser and non-browser clients receive tokens
|
|
// Non-browser clients MUST store the new refresh token
|
|
// Browser clients benefit from having tokens available in JavaScript if needed
|
|
return c.JSON(http.StatusOK, TokenRefreshResponse{
|
|
AccessToken: newAccessToken,
|
|
RefreshToken: newRefreshToken,
|
|
ExpiresIn: int(h.config.JWT.AccessExpiry.Seconds()),
|
|
})
|
|
|
|
}
|
|
|
|
// Logout handles user logout by revoking the refresh token and clearing cookies.
|
|
// This endpoint invalidates the current session and removes authentication cookies.
|
|
//
|
|
// Why logout is important:
|
|
// - Security: Revokes refresh token so it can't be used to get new access tokens
|
|
// - Privacy: Removes tokens from browser
|
|
// - Session management: Marks session as ended in database
|
|
//
|
|
// Flow:
|
|
// 1. Extract refresh token from cookie
|
|
// 2. Revoke the refresh token in database
|
|
// 3. Clear both access and refresh token cookies
|
|
// 4. Return success (204 No Content)
|
|
//
|
|
// Graceful handling:
|
|
// - If no refresh token cookie: Still clears cookies and returns success
|
|
// - If token revocation fails: Still clears cookies (client-side cleanup)
|
|
// - Always returns success to avoid information leakage
|
|
//
|
|
// Error handling:
|
|
// - No errors returned to client (always succeeds)
|
|
// - Errors are silently handled to prevent information disclosure
|
|
func (h *AuthHandler) Logout(c echo.Context) error {
|
|
log.Info().
|
|
Str("handler", "auth").
|
|
Str("action", "logout_attempt").
|
|
Str("ip", c.RealIP()).
|
|
Msg("user logout attempt")
|
|
// Step 1: Attempt to get refresh token from cookie
|
|
cookie, err := c.Cookie("refresh_token")
|
|
if err != nil {
|
|
log.Debug().
|
|
Str("handler", "auth").
|
|
Str("action", "logout_no_token").
|
|
Str("ip", c.RealIP()).
|
|
Msg("logout attempt without refresh token cookie")
|
|
// No cookie found - user might have already logged out or session expired
|
|
// Still clear cookies (might be stale access token) and return success
|
|
h.clearAuthCookies(c)
|
|
return c.NoContent(http.StatusOK)
|
|
}
|
|
|
|
// Get request context
|
|
ctx := c.Request().Context()
|
|
log.Info().
|
|
Str("handler", "auth").
|
|
Str("action", "revoking_refresh_token").
|
|
Str("ip", c.RealIP()).
|
|
Msg("revoking refresh token for logout")
|
|
|
|
// Step 2: Revoke the refresh token in database
|
|
// This marks the session as revoked with reason "user_logout"
|
|
// Updates: is_revoked=true, revoked_at=NOW(), revoked_reason='user_logout'
|
|
// We ignore errors here - even if revocation fails, we clear client cookies
|
|
if err = h.authService.RevokeRefreshToken(ctx, cookie.Value); err != nil {
|
|
log.Warn().
|
|
Str("handler", "auth").
|
|
Str("action", "revocation_failed").
|
|
Str("ip", c.RealIP()).
|
|
Err(err).
|
|
Msg("failed to revoke refresh token during logout (continuing anyway)")
|
|
}
|
|
|
|
// Step 3: Clear authentication cookies from browser
|
|
// Sets MaxAge=-1 which tells browser to immediately delete cookies
|
|
// Clears both access_token and refresh_token cookies
|
|
h.clearAuthCookies(c)
|
|
log.Info().
|
|
Str("handler", "auth").
|
|
Str("action", "logout_success").
|
|
Str("ip", c.RealIP()).
|
|
Msg("user logged out successfully")
|
|
// Step 4: Return success with no content
|
|
// 204 No Content is appropriate for successful logout
|
|
// No response body needed
|
|
return c.NoContent(http.StatusOK)
|
|
}
|
|
|
|
// setAccessTokenCookie creates and sets the access token cookie with security flags.
|
|
// This cookie stores the JWT access token for subsequent API requests.
|
|
//
|
|
// Cookie configuration explained:
|
|
// - Name: "access_token" - identifies this cookie
|
|
// - Value: The JWT token string
|
|
// - Path: "/" - cookie sent for all paths on domain
|
|
// - Domain: From config (e.g., "localhost", ".example.com")
|
|
// - MaxAge: Token lifetime in seconds (how long browser keeps cookie)
|
|
// - Secure: Only sent over HTTPS (true in production)
|
|
// - HttpOnly: Cannot be accessed by JavaScript (XSS protection)
|
|
// - SameSite: CSRF protection (controls when cookie is sent)
|
|
//
|
|
// Why these settings?
|
|
// - HttpOnly: Prevents XSS attacks from stealing tokens
|
|
// - Secure: Prevents tokens from being sent over unencrypted connections
|
|
// - SameSite: Prevents CSRF attacks by controlling cross-site cookie sending
|
|
// - Path=/: Makes cookie available to all API endpoints
|
|
func (h *AuthHandler) setAccessTokenCookie(c echo.Context, token string) {
|
|
log.Debug().
|
|
Str("handler", "auth").
|
|
Str("action", "set_access_cookie").
|
|
Str("domain", h.config.Cookie.CookieDomain).
|
|
Bool("secure", h.config.Cookie.CookieSecure).
|
|
Int("max_age", int(h.config.JWT.AccessExpiry.Seconds())).
|
|
Msg("setting access token cookie")
|
|
|
|
cookie := &http.Cookie{
|
|
Name: "access_token",
|
|
Value: token,
|
|
Path: "/", // Available to all paths
|
|
Domain: h.config.Cookie.CookieDomain,
|
|
MaxAge: int(h.config.JWT.AccessExpiry.Seconds()), // Browser deletes after this time
|
|
Secure: h.config.Cookie.CookieSecure, // HTTPS only in production
|
|
HttpOnly: true, // JavaScript cannot access (XSS protection)
|
|
SameSite: h.parseSameSite(h.config.Cookie.CookieSameSite), // CSRF protection
|
|
}
|
|
|
|
c.SetCookie(cookie)
|
|
}
|
|
|
|
// setRefreshTokenCookie creates and sets the refresh token cookie with security flags.
|
|
// This cookie stores the JWT refresh token for obtaining new access tokens.
|
|
//
|
|
// Similar to access token cookie but with longer lifetime (7 days vs 15 minutes).
|
|
// Uses same security flags (HttpOnly, Secure, SameSite) for protection.
|
|
//
|
|
// Why separate cookies?
|
|
// - Different lifetimes (access=short, refresh=long)
|
|
// - Different purposes (access=API requests, refresh=token renewal)
|
|
// - Can revoke one without affecting the other
|
|
// - Follows OAuth 2.0 best practices
|
|
func (h *AuthHandler) setRefreshTokenCookie(c echo.Context, token string) {
|
|
log.Debug().
|
|
Str("handler", "auth").
|
|
Str("action", "set_refresh_cookie").
|
|
Str("domain", h.config.Cookie.CookieDomain).
|
|
Bool("secure", h.config.Cookie.CookieSecure).
|
|
Int("max_age", int(h.config.JWT.RefreshExpiry.Seconds())).
|
|
Msg("setting refresh token cookie")
|
|
|
|
cookie := &http.Cookie{
|
|
Name: "refresh_token",
|
|
Value: token,
|
|
Path: "/", // Available to all paths
|
|
Domain: h.config.Cookie.CookieDomain,
|
|
MaxAge: int(h.config.JWT.RefreshExpiry.Seconds()), // Much longer than access token
|
|
Secure: h.config.Cookie.CookieSecure, // HTTPS only in production
|
|
HttpOnly: true, // JavaScript cannot access (XSS protection)
|
|
SameSite: h.parseSameSite(h.config.Cookie.CookieSameSite), // CSRF protection
|
|
}
|
|
|
|
c.SetCookie(cookie)
|
|
}
|
|
|
|
// clearAuthCookies removes both access and refresh token cookies from the browser.
|
|
// This is called during logout to clean up authentication state.
|
|
//
|
|
// How cookie deletion works:
|
|
// - Set MaxAge=-1 which tells browser to immediately delete the cookie
|
|
// - Set empty Value to clear any existing value
|
|
// - Keep same Name, Path, and Domain so browser knows which cookie to delete
|
|
//
|
|
// Why we still set Secure and HttpOnly:
|
|
// - Browser needs these to match original cookie attributes for deletion
|
|
// - Ensures cookie is properly identified and removed
|
|
func (h *AuthHandler) clearAuthCookies(c echo.Context) {
|
|
log.Debug().
|
|
Str("handler", "auth").
|
|
Str("action", "clear_auth_cookies").
|
|
Msg("clearing access and refresh token cookies")
|
|
|
|
// Create cookie with MaxAge=-1 to delete access token
|
|
accessCookie := &http.Cookie{
|
|
Name: "access_token",
|
|
Value: "", // Empty value
|
|
Path: "/",
|
|
Domain: h.config.Cookie.CookieDomain,
|
|
MaxAge: -1, // Negative MaxAge means delete immediately
|
|
Secure: h.config.Cookie.CookieSecure,
|
|
HttpOnly: true,
|
|
}
|
|
|
|
// Create cookie with MaxAge=-1 to delete refresh token
|
|
refreshCookie := &http.Cookie{
|
|
Name: "refresh_token",
|
|
Value: "", // Empty value
|
|
Path: "/",
|
|
Domain: h.config.Cookie.CookieDomain,
|
|
MaxAge: -1, // Negative MaxAge means delete immediately
|
|
Secure: h.config.Cookie.CookieSecure,
|
|
HttpOnly: true,
|
|
}
|
|
|
|
// Set both cookies (browser will delete them)
|
|
c.SetCookie(accessCookie)
|
|
c.SetCookie(refreshCookie)
|
|
}
|
|
|
|
// parseSameSite converts a string SameSite policy to http.SameSite type.
|
|
// SameSite is a cookie attribute that controls when cookies are sent in cross-site requests.
|
|
//
|
|
// Values explained:
|
|
// - "strict": Cookie never sent in cross-site requests (most secure, may break some flows)
|
|
// Example: User clicks link from email to your site - no cookie sent
|
|
// - "lax": Cookie sent on top-level navigation (GET) but not on embedded requests (balanced)
|
|
// Example: User clicks link - cookie sent; Embedded image - cookie not sent
|
|
// - "none": Cookie always sent (requires Secure=true, needed for some third-party integrations)
|
|
// Example: Your API called from different domain - cookie sent
|
|
// - default: Browser decides (usually similar to "lax")
|
|
//
|
|
// Why this matters for security:
|
|
// - Prevents CSRF attacks by limiting when cookies are sent
|
|
// - "lax" is recommended for most authentication cookies (good security + usability)
|
|
// - "strict" can break legitimate flows (like OAuth redirects)
|
|
// - "none" should only be used when necessary (requires HTTPS)
|
|
func (h *AuthHandler) parseSameSite(s string) http.SameSite {
|
|
switch s {
|
|
case "strict":
|
|
return http.SameSiteStrictMode // Never send cookie cross-site
|
|
case "lax":
|
|
return http.SameSiteLaxMode // Send on top-level navigation only
|
|
case "none":
|
|
return http.SameSiteNoneMode // Always send (requires Secure=true)
|
|
default:
|
|
return http.SameSiteDefaultMode // Let browser decide
|
|
}
|
|
}
|
|
|
|
// sanitizeUser removes sensitive information from user object before sending to client.
|
|
// Currently just returns the user as-is, but should remove:
|
|
// - password_hash: Never send password hashes to client
|
|
// - internal IDs: Remove any internal tracking IDs
|
|
// - audit fields: Consider removing internal timestamps
|
|
//
|
|
// TODO: Implement actual sanitization:
|
|
// - Remove PasswordHash field
|
|
// - Consider using a separate UserResponse struct
|
|
// - Transform to DTO (Data Transfer Object) pattern
|
|
//
|
|
// Why sanitization is critical:
|
|
// - Security: Prevents exposing sensitive data
|
|
// - Privacy: User data should be minimal
|
|
// - API contract: Clearly defines what clients receive
|
|
func (h *AuthHandler) sanitizeUser(user interface{}) interface{} {
|
|
// TODO: Actually sanitize the user object
|
|
// Current implementation just passes through - should remove sensitive fields
|
|
return user
|
|
}
|