aurganize-backend/backend/internal/middleware/auth.go

455 lines
16 KiB
Go

package middleware
import (
"net/http"
"strings"
"github.com/creativenoz/aurganize-v62/backend/internal/services"
"github.com/labstack/echo/v4"
"github.com/rs/zerolog/log"
)
// AuthMiddleware provides authentication middleware for protecting routes.
// This middleware intercepts HTTP requests to verify user authentication before
// allowing access to protected resources.
//
// What is middleware?
// Middleware is code that runs between receiving a request and executing the handler.
// It's like a security checkpoint that requests must pass through.
//
// Request flow with middleware:
// Client Request → CORS → AuthMiddleware → Route Handler → Response
//
// This middleware provides two authentication modes:
// 1. Authenticate: REQUIRED authentication (blocks unauthenticated requests)
// 2. OptionalAuth: OPTIONAL authentication (allows both authenticated and anonymous)
//
// Authentication sources (checked in order):
// 1. HTTP-only cookie (primary for browser clients)
// 2. Authorization header with Bearer token (for mobile/API clients)
//
// Why support both?
// - Cookies: Secure for browsers (HttpOnly prevents XSS)
// - Headers: Required for mobile apps and API clients
// - Flexibility: Supports multiple client types
//
// What gets validated:
// - JWT signature (ensures token wasn't tampered with)
// - Token expiration (ensures token is still valid)
// - Token type (ensures it's an access token, not refresh)
// - Token format (ensures proper JWT structure)
//
// After successful authentication:
// - User claims are stored in Echo context
// - Downstream handlers can access user info via c.Get("user_id"), etc.
// - No need to re-validate token in handlers
type AuthMiddleware struct {
authService *services.AuthService
}
// NewAuthMiddleware creates a new authentication middleware with injected dependencies.
// This constructor follows the dependency injection pattern for:
// - Testability: Can inject mock auth service for testing
// - Flexibility: Can swap implementations without changing middleware
// - Clear dependencies: Explicitly shows what middleware needs
//
// Parameters:
// - authService: Service that handles token validation
//
// Returns:
// - Fully initialized AuthMiddleware ready to protect routes
//
// Usage:
//
// authService := services.NewAuthService(...)
// authMiddleware := middleware.NewAuthMiddleware(authService)
// e.GET("/protected", handler, authMiddleware.Authenticate)
func NewAuthMiddleware(authService *services.AuthService) *AuthMiddleware {
log.Info().
Str("middleware", "auth").
Str("component", "middleware_init").
Bool("has_auth_service", authService != nil).
Msg("authentication middleware initialized")
return &AuthMiddleware{
authService: authService,
}
}
// Authenticate is a REQUIRED authentication middleware.
// Routes using this middleware will reject requests without valid authentication.
//
// When to use:
// - Protected endpoints that require authentication
// - User-specific operations (profile, settings, logout)
// - Resource access control (only authenticated users)
// - Any route that needs user identity
//
// Authentication flow:
// 1. Extract token from cookie OR Authorization header
// 2. Validate token (signature, expiration, type)
// 3. If valid: Store claims in context, continue to handler
// 4. If invalid: Return 401 Unauthorized, block request
//
// Token sources (priority order):
// 1. Cookie: "access_token" (for browser clients)
// 2. Header: "Authorization: Bearer <token>" (for mobile/API clients)
//
// Why check cookie first?
// - More secure for browsers (HttpOnly prevents XSS)
// - Automatically sent by browsers
// - Primary method for web applications
//
// Response codes:
// - 200: Token valid, request proceeds to handler
// - 401: Missing token, invalid token, or expired token
//
// What gets stored in context (accessible in handlers):
// - user_id: UUID of authenticated user
// - tenant_id: UUID of user's organization/tenant
// - email: User's email address
// - role: User's role (admin, user, etc.)
// - claims: Full claims object (all token data)
//
// Handler access example:
//
// userID := c.Get("user_id").(uuid.UUID)
// email := c.Get("email").(string)
// role := c.Get("role").(string)
//
// Error handling:
// - Missing token: "missing authentication token"
// - Invalid format: "invalid authorization header format"
// - Expired token: "token has expired" (client should refresh)
// - Invalid token: "invalid token" (signature/tampering)
//
// Parameters:
// - next: The next handler in the chain (the actual route handler)
//
// Returns:
// - HandlerFunc that wraps the next handler with authentication
//
// Usage:
//
// // Protect single route
// e.GET("/profile", profileHandler, authMiddleware.Authenticate)
//
// // Protect route group
// protected := e.Group("/api", authMiddleware.Authenticate)
// protected.GET("/users", listUsers)
// protected.POST("/posts", createPost)
func (m *AuthMiddleware) Authenticate(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
log.Debug().
Str("middleware", "auth").
Str("action", "authenticate_check_started").
Str("path", c.Request().URL.Path).
Str("method", c.Request().Method).
Str("ip", c.RealIP()).
Str("user_agent", c.Request().UserAgent()).
Msg("checking authentication for protected route")
// Step 1: Try to get token from cookie first (browser clients)
// This is the preferred method for web applications
token, err := c.Cookie("access_token")
var tokenString string
var tokenSource string
if err == nil {
tokenSource = "cookie"
// Cookie found - use its value
// This path is taken by browser-based clients
tokenString = token.Value
log.Debug().
Str("middleware", "auth").
Str("action", "token_found_in_cookie").
Str("path", c.Request().URL.Path).
Msg("access token found in cookie")
} else {
log.Debug().
Str("middleware", "auth").
Str("action", "no_cookie_checking_header").
Str("path", c.Request().URL.Path).
Msg("no cookie found, checking authorization header")
// Step 2: Cookie not found, try Authorization header (mobile/API clients)
// Expected format: "Authorization: Bearer <token>"
authHeader := c.Request().Header.Get("Authorization")
if authHeader == "" {
log.Warn().
Str("middleware", "auth").
Str("action", "missing_authentication").
Str("path", c.Request().URL.Path).
Str("method", c.Request().Method).
Str("ip", c.RealIP()).
Str("user_agent", c.Request().UserAgent()).
Msg("authentication required but no token provided")
// No cookie AND no header - user is not authenticated
return echo.NewHTTPError(http.StatusUnauthorized, "missing authentication token")
}
// Step 3: Parse Authorization header
// Expected format: "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
// Split into ["Bearer", "token_string"]
parts := strings.Split(authHeader, " ")
// Validate header format
if len(parts) != 2 || parts[0] != "Bearer" {
log.Warn().
Str("middleware", "auth").
Str("action", "invalid_auth_header_format").
Str("invalid_header", authHeader).
Int("header_parts_count", len(parts)).
Str("path", c.Request().URL.Path).
Str("ip", c.RealIP()).
Msg("authorization header present but format is invalid")
// Invalid format examples:
// - "Bearer" (no token)
// - "Bearer token extra" (too many parts)
// - "Basic base64string" (wrong auth type)
// - "token" (missing "Bearer" prefix)
return echo.NewHTTPError(http.StatusUnauthorized, "invalid authorization header format")
}
// Extract token (second part after "Bearer ")
tokenString = parts[1]
tokenSource = "header"
log.Debug().
Str("middleware", "auth").
Str("action", "token_found_in_header").
Str("path", c.Request().URL.Path).
Msg("access token found in authorization header")
}
log.Debug().
Str("middleware", "auth").
Str("action", "validating_access_token").
Str("token_source", tokenSource).
Str("path", c.Request().URL.Path).
Msg("validating access token")
// Step 4: Validate the access token
// This checks:
// - JWT signature (proves token wasn't tampered)
// - Token expiration (ensures not expired)
// - Token type (ensures it's "access" not "refresh")
// - Token structure (valid JWT format)
claims, err := m.authService.ValidateAccessToken(tokenString)
if err != nil {
// Handle specific error types
if err == services.ErrExpiredToken {
log.Warn().
Str("middleware", "auth").
Str("action", "expired_token").
Str("token_source", tokenSource).
Str("path", c.Request().URL.Path).
Str("ip", c.RealIP()).
Str("user_agent", c.Request().UserAgent()).
Msg("access token has expired")
// Token is valid but expired
// Client should use refresh token to get new access token
// Return specific message so client knows to refresh
return echo.NewHTTPError(http.StatusUnauthorized, "token has expired")
}
log.Warn().
Str("middleware", "auth").
Str("action", "invalid_token").
Err(err).
Str("token_source", tokenSource).
Str("path", c.Request().URL.Path).
Str("ip", c.RealIP()).
Str("user_agent", c.Request().UserAgent()).
Msg("token validation failed - invalid or tampered token")
// Other errors: invalid signature, wrong type, malformed, etc.
// Return generic error to avoid leaking information
return echo.NewHTTPError(http.StatusUnauthorized, "invalid token")
}
log.Info().
Str("middleware", "auth").
Str("action", "authentication_success").
Str("user_id", claims.UserID.String()).
Str("tenant_id", claims.TenantID.String()).
Str("email", claims.Email).
Str("role", claims.Role).
Str("token_source", tokenSource).
Str("path", c.Request().URL.Path).
Str("method", c.Request().Method).
Str("ip", c.RealIP()).
Msg("user authenticated successfully")
// Step 5: Token is valid - store claims in context
// Context values can be retrieved by downstream handlers
// This avoids re-validating token in every handler
// Store individual fields for easy access
c.Set("user_id", claims.UserID)
c.Set("tenant_id", claims.TenantID)
c.Set("email", claims.Email)
c.Set("role", claims.Role)
// Store full claims object for advanced use cases
c.Set("claims", claims)
// Step 6: Continue to next handler (the actual route handler)
// Request is now authenticated and handlers can access user info
return next(c)
}
}
// OptionalAuth is an OPTIONAL authentication middleware.
// Routes using this middleware will work for both authenticated and anonymous users.
//
// When to use:
// - Public endpoints that enhance experience for logged-in users
// - Content that shows differently based on auth status
// - APIs that return more data for authenticated users
// - Features with both public and private modes
//
// Examples:
// 1. Homepage: Shows personalized content if logged in, generic if not
// 2. Blog post: Shows "Edit" button if author is logged in
// 3. Search: Returns more results for authenticated users
// 4. Comments: Shows "Reply" option if logged in
//
// Behavior:
// - If valid token: Store claims in context, proceed (like Authenticate)
// - If no token: Proceed anyway without claims
// - If invalid token: Proceed anyway without claims (graceful degradation)
//
// Why not return error for invalid token?
// - Allows graceful degradation (partial functionality)
// - Doesn't block anonymous users
// - Expired tokens don't break the page
// - Better user experience
//
// How handlers detect authentication status:
//
// userID := c.Get("user_id")
// if userID != nil {
// // User is authenticated
// authenticatedUserID := userID.(uuid.UUID)
// // Show personalized content
// } else {
// // User is anonymous
// // Show generic content
// }
//
// Difference from Authenticate:
// - Authenticate: BLOCKS unauthenticated requests (401 error)
// - OptionalAuth: ALLOWS unauthenticated requests (no error)
//
// Token source:
// - Only checks cookie (not Authorization header)
// - Why? Browser-based clients naturally use cookies
// - Mobile/API clients should use specific endpoints
//
// What gets stored (if authenticated):
// - user_id: UUID of authenticated user
// - tenant_id: UUID of user's organization
// - email: User's email address
// - role: User's role
// - claims: Full claims object
//
// What gets stored (if not authenticated):
// - Nothing - context values will be nil
//
// Parameters:
// - next: The next handler in the chain (the actual route handler)
//
// Returns:
// - HandlerFunc that wraps the next handler with optional authentication
//
// Usage:
//
// // Single route with optional auth
// e.GET("/", homeHandler, authMiddleware.OptionalAuth)
//
// // Route group with optional auth
// public := e.Group("/public", authMiddleware.OptionalAuth)
// public.GET("/posts", listPosts) // Shows different content based on auth
// public.GET("/post/:id", viewPost) // Shows edit button if authenticated
//
// Handler example:
//
// func homeHandler(c echo.Context) error {
// userID := c.Get("user_id")
// if userID != nil {
// // Authenticated user
// return c.Render(200, "home-authenticated", data)
// }
// // Anonymous user
// return c.Render(200, "home-public", data)
// }
func (m *AuthMiddleware) OptionalAuth(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
log.Debug().
Str("middleware", "auth").
Str("action", "optional_auth_check_started").
Str("path", c.Request().URL.Path).
Str("method", c.Request().Method).
Str("ip", c.RealIP()).
Msg("checking optional authentication")
// Step 1: Try to get token from cookie
// We only check cookies for optional auth (not Authorization header)
// This is intentional - optional auth is primarily for browser clients
token, err := c.Cookie("access_token")
if err != nil {
log.Debug().
Str("middleware", "auth").
Str("action", "optional_auth_anonymous").
Str("path", c.Request().URL.Path).
Str("ip", c.RealIP()).
Str("user_agent", c.Request().UserAgent()).
Msg("no authentication cookie - proceeding as anonymous user")
// No cookie found - user is anonymous
// This is OKAY for optional auth
// Proceed to handler without setting context values
// Handler will see nil values and know user is not authenticated
return next(c)
}
log.Debug().
Str("middleware", "auth").
Str("action", "optional_auth_validating_token").
Str("path", c.Request().URL.Path).
Msg("cookie found in optional auth, validating token")
// Step 2: Cookie found - validate the token
// Even though auth is optional, we validate if token is present
// This ensures we don't use invalid/expired tokens
claims, err := m.authService.ValidateAccessToken(token.Value)
if err != nil {
log.Debug().
Str("middleware", "auth").
Str("action", "optional_auth_token_invalid").
Err(err).
Str("path", c.Request().URL.Path).
Str("ip", c.RealIP()).
Msg("token validation failed in optional auth - proceeding as anonymous")
// Token is invalid or expired
// For optional auth, we don't return error
// Just proceed without setting context values
// User will be treated as anonymous
// This provides graceful degradation
return next(c)
}
log.Info().
Str("middleware", "auth").
Str("action", "optional_auth_authenticated").
Str("user_id", claims.UserID.String()).
Str("tenant_id", claims.TenantID.String()).
Str("email", claims.Email).
Str("role", claims.Role).
Str("path", c.Request().URL.Path).
Str("ip", c.RealIP()).
Msg("authenticated user accessing optionally-protected route")
// Step 3: Token is valid - store claims in context
// Handler can now detect authenticated user via c.Get("user_id")
// Same values as Authenticate middleware
c.Set("user_id", claims.UserID)
c.Set("tenant_id", claims.TenantID)
c.Set("email", claims.Email)
c.Set("role", claims.Role)
c.Set("claims", claims)
// Step 4: Continue to handler
// Handler can check if user_id is nil to determine auth status
return next(c)
}
}