aurganize-backend/backend/internal/handlers/auth_handler.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
}