package httpapp import ( "context" "net/http" "strings" "gitea.avt.data-center.id/othman.suseno/atlas/internal/auth" "gitea.avt.data-center.id/othman.suseno/atlas/internal/models" ) const ( userCtxKey ctxKey = "user" roleCtxKey ctxKey = "role" ) // authMiddleware validates JWT tokens and extracts user info func (a *App) authMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Skip auth for public endpoints (includes web UI pages and read-only GET endpoints) if a.isPublicEndpoint(r.URL.Path, r.Method) { next.ServeHTTP(w, r) return } // Extract token from Authorization header authHeader := r.Header.Get("Authorization") if authHeader == "" { writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "missing authorization header"}) return } // Parse "Bearer " parts := strings.Split(authHeader, " ") if len(parts) != 2 || parts[0] != "Bearer" { writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid authorization header format"}) return } token := parts[1] claims, err := a.authService.ValidateToken(token) if err != nil { if err == auth.ErrExpiredToken { writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "token expired"}) } else { writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid token"}) } return } // Get user from store user, err := a.userStore.GetByID(claims.UserID) if err != nil { writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "user not found"}) return } if !user.Active { writeJSON(w, http.StatusForbidden, map[string]string{"error": "user account is disabled"}) return } // Add user info to context ctx := context.WithValue(r.Context(), userCtxKey, user) ctx = context.WithValue(ctx, roleCtxKey, user.Role) next.ServeHTTP(w, r.WithContext(ctx)) }) } // requireRole middleware checks if user has required role func (a *App) requireRole(allowedRoles ...models.Role) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { role, ok := r.Context().Value(roleCtxKey).(models.Role) if !ok { writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) return } // Check if user role is in allowed roles allowed := false for _, allowedRole := range allowedRoles { if role == allowedRole { allowed = true break } } if !allowed { writeJSON(w, http.StatusForbidden, map[string]string{"error": "insufficient permissions"}) return } next.ServeHTTP(w, r) }) } } // isPublicEndpoint checks if an endpoint is public (no auth required) // It validates both path and HTTP method to prevent unauthenticated mutations func (a *App) isPublicEndpoint(path, method string) bool { // Always public paths (any method) publicPaths := []string{ "/healthz", "/health", "/metrics", "/api/v1/auth/login", "/api/v1/auth/logout", "/", // Root - redirects to login "/login", // Login page "/dashboard", // Dashboard page (frontend will check token) "/storage", // Storage management page "/shares", // Shares page "/iscsi", // iSCSI page "/vtl", // VTL (Virtual Tape Library) page "/protection", // Data Protection page "/management", // System Management page "/api/docs", // API documentation "/api/openapi.yaml", // OpenAPI spec } for _, publicPath := range publicPaths { if path == publicPath { return true } // Also allow paths that start with public paths (for sub-pages) if strings.HasPrefix(path, publicPath+"/") { return true } } // Static files are public (any method) if strings.HasPrefix(path, "/static/") { return true } // Read-only GET endpoints are public for web UI (but require auth for mutations) // SECURITY: Only GET requests are allowed without authentication // POST, PUT, DELETE, PATCH require authentication publicReadOnlyPaths := []string{ "/api/v1/dashboard", // Dashboard data "/api/v1/disks", // List disks "/api/v1/pools", // List pools (GET only) "/api/v1/pools/available", // List available pools "/api/v1/datasets", // List datasets (GET only) "/api/v1/zvols", // List ZVOLs (GET only) "/api/v1/shares/smb", // List SMB shares (GET only) "/api/v1/exports/nfs", // List NFS exports (GET only) "/api/v1/iscsi/targets", // List iSCSI targets (GET only) "/api/v1/vtl/status", // VTL status (GET only) "/api/v1/vtl/drives", // List VTL drives (GET only) "/api/v1/vtl/tapes", // List VTL tapes (GET only) "/api/v1/vtl/changers", // List VTL media changers (GET only) "/api/v1/vtl/changer/status", // VTL media changer status (GET only) "/api/v1/snapshots", // List snapshots (GET only) "/api/v1/snapshot-policies", // List snapshot policies (GET only) } for _, publicPath := range publicReadOnlyPaths { if path == publicPath { // Only allow GET requests without authentication // All mutation methods (POST, PUT, DELETE, PATCH) require authentication return method == http.MethodGet } } return false } // getUserFromContext extracts user from request context func getUserFromContext(r *http.Request) (*models.User, bool) { user, ok := r.Context().Value(userCtxKey).(*models.User) return user, ok } // getRoleFromContext extracts role from request context func getRoleFromContext(r *http.Request) (models.Role, bool) { role, ok := r.Context().Value(roleCtxKey).(models.Role) return role, ok }