From 61dae6c605bfc29a2a79bb2c5d485041118a3b49 Mon Sep 17 00:00:00 2001 From: Noste <83548733+Noooste@users.noreply.github.com> Date: Sun, 21 Dec 2025 23:42:02 +0100 Subject: [PATCH] feat: implement admin and OIDC authentication methods; add login and user info endpoints Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com> --- README.md | 87 +++++++--- backend/internal/auth/auth.go | 61 ++++--- backend/internal/config/config.go | 44 ++--- backend/internal/handlers/auth.go | 152 +++++++++++++++++ backend/internal/middleware/auth.go | 111 +++++-------- backend/internal/routes/routes.go | 48 +++--- backend/internal/services/garage_admin.go | 34 ++-- backend/internal/services/s3.go | 88 ++++++++-- backend/main.go | 18 +- backend/pkg/utils/retry.go | 104 ++++++++++++ config.yaml.example | 18 +- frontend/src/App.tsx | 26 ++- .../src/components/auth/BasicLoginForm.tsx | 118 ++++++++++++++ .../src/components/auth/LoadingSpinner.tsx | 12 ++ .../src/components/auth/OIDCLoginView.tsx | 41 +++++ .../src/components/auth/ProtectedRoute.tsx | 29 ++++ frontend/src/components/layout/sidebar.tsx | 44 +++-- frontend/src/lib/api.ts | 68 ++++++++ frontend/src/pages/Login.tsx | 80 +++++++++ frontend/src/store/auth-store.ts | 154 ++++++++++++++++++ frontend/src/types/auth.ts | 22 +++ frontend/vite.config.ts | 4 + helm/garage-ui/README.md | 61 +++++-- 23 files changed, 1187 insertions(+), 237 deletions(-) create mode 100644 backend/internal/handlers/auth.go create mode 100644 backend/pkg/utils/retry.go create mode 100644 frontend/src/components/auth/BasicLoginForm.tsx create mode 100644 frontend/src/components/auth/LoadingSpinner.tsx create mode 100644 frontend/src/components/auth/OIDCLoginView.tsx create mode 100644 frontend/src/components/auth/ProtectedRoute.tsx create mode 100644 frontend/src/pages/Login.tsx create mode 100644 frontend/src/store/auth-store.ts create mode 100644 frontend/src/types/auth.ts diff --git a/README.md b/README.md index 54e85d3..32c1005 100644 --- a/README.md +++ b/README.md @@ -156,18 +156,18 @@ garage: admin_endpoint: "http://garage:3903" admin_token: "your-admin-token-here" -# Authentication (none, basic, oidc) +# Authentication Configuration +# You can enable one or both authentication methods auth: - mode: "none" - - # Basic auth example - basic: + # Admin authentication (username/password) + admin: + enabled: false # Set to true to enable admin login username: "admin" password: "secure-password" - # OIDC example (Keycloak, Auth0, etc.) + # OIDC authentication (Keycloak, Auth0, etc.) oidc: - enabled: true + enabled: false # Set to true to enable OIDC login provider_name: "Keycloak" client_id: "garage-ui" client_secret: "your-client-secret" @@ -187,9 +187,10 @@ GARAGE_UI_SERVER_PORT=8080 GARAGE_UI_GARAGE_ENDPOINT=http://garage:3900 GARAGE_UI_GARAGE_ADMIN_ENDPOINT=http://garage:3903 GARAGE_UI_GARAGE_ADMIN_TOKEN=your-token -GARAGE_UI_AUTH_MODE=basic -GARAGE_UI_AUTH_BASIC_USERNAME=admin -GARAGE_UI_AUTH_BASIC_PASSWORD=password +GARAGE_UI_AUTH_ADMIN_ENABLED=true +GARAGE_UI_AUTH_ADMIN_USERNAME=admin +GARAGE_UI_AUTH_ADMIN_PASSWORD=password +GARAGE_UI_AUTH_OIDC_ENABLED=false GARAGE_UI_LOGGING_LEVEL=info ``` @@ -356,38 +357,44 @@ Once the backend is running, access the Swagger UI documentation at: --- -## Authentication Modes +## Authentication -Garage UI supports three authentication modes: +Garage UI supports flexible authentication with the ability to enable one or both methods simultaneously: -### 1. None (Default) +### No Authentication -No authentication required - suitable for private networks or development. +If neither admin nor OIDC authentication is enabled, the application runs without authentication - suitable for private networks or development. ```yaml auth: - mode: "none" + admin: + enabled: false + oidc: + enabled: false ``` -### 2. Basic Authentication +### Admin Authentication -Simple username/password authentication. +Simple username/password authentication using JWT tokens. ```yaml auth: - mode: "basic" - basic: + admin: + enabled: true username: "admin" password: "your-secure-password" + oidc: + enabled: false ``` -### 3. OIDC (OpenID Connect) +### OIDC (OpenID Connect) Authentication Enterprise-grade authentication with providers like Keycloak, Auth0, Okta, etc. ```yaml auth: - mode: "oidc" + admin: + enabled: false oidc: enabled: true provider_name: "Keycloak" @@ -401,6 +408,29 @@ auth: cookie_secure: true # Enable in production with HTTPS ``` +### Both Authentication Methods + +You can enable both admin and OIDC authentication simultaneously. Users will be presented with both options on the login page: + +```yaml +auth: + admin: + enabled: true + username: "admin" + password: "your-secure-password" + oidc: + enabled: true + provider_name: "Keycloak" + client_id: "garage-ui" + client_secret: "your-client-secret" + issuer_url: "https://auth.example.com/realms/master" + auth_url: "https://auth.example.com/realms/master/protocol/openid-connect/auth" + token_url: "https://auth.example.com/realms/master/protocol/openid-connect/token" + userinfo_url: "https://auth.example.com/realms/master/protocol/openid-connect/userinfo" + session_max_age: 86400 + cookie_secure: true +``` + **Role-Based Access (Optional):** ```yaml @@ -492,12 +522,15 @@ ingress: hosts: - garage-ui.example.com -auth: - mode: oidc - oidc: - clientId: garage-ui - clientSecret: secret - issuerUrl: https://auth.example.com/realms/master +config: + auth: + admin: + enabled: false + oidc: + enabled: true + client_id: garage-ui + client_secret: secret + issuer_url: https://auth.example.com/realms/master ``` Install with: diff --git a/backend/internal/auth/auth.go b/backend/internal/auth/auth.go index 01592e9..1a3fce6 100644 --- a/backend/internal/auth/auth.go +++ b/backend/internal/auth/auth.go @@ -15,7 +15,8 @@ import ( // AuthService handles authentication operations type AuthService struct { - config *config.AuthConfig + authConfig *config.AuthConfig + serverConfig *config.ServerConfig oidcProvider *oidc.Provider oidcVerifier *oidc.IDTokenVerifier oauth2Config *oauth2.Config @@ -31,19 +32,20 @@ type UserInfo struct { } // NewAuthService creates a new authentication service -func NewAuthService(cfg *config.AuthConfig) (*AuthService, error) { +func NewAuthService(authCfg *config.AuthConfig, serverCfg *config.ServerConfig) (*AuthService, error) { jwtService, err := NewJWTService() if err != nil { return nil, fmt.Errorf("failed to initialize JWT service: %w", err) } service := &AuthService{ - config: cfg, - jwtService: jwtService, + authConfig: authCfg, + serverConfig: serverCfg, + jwtService: jwtService, } // Initialize OIDC if enabled - if cfg.Mode == "oidc" && cfg.OIDC.Enabled { + if authCfg.OIDC.Enabled { if err := service.initOIDC(); err != nil { return nil, fmt.Errorf("failed to initialize OIDC: %w", err) } @@ -57,7 +59,7 @@ func (a *AuthService) initOIDC() error { ctx := context.Background() // Create OIDC provider - provider, err := oidc.NewProvider(ctx, a.config.OIDC.IssuerURL) + provider, err := oidc.NewProvider(ctx, a.authConfig.OIDC.IssuerURL) if err != nil { return fmt.Errorf("failed to create OIDC provider: %w", err) } @@ -66,18 +68,23 @@ func (a *AuthService) initOIDC() error { // Create ID token verifier verifierConfig := &oidc.Config{ - ClientID: a.config.OIDC.ClientID, - SkipIssuerCheck: a.config.OIDC.SkipIssuerCheck, - SkipExpiryCheck: a.config.OIDC.SkipExpiryCheck, + ClientID: a.authConfig.OIDC.ClientID, + SkipIssuerCheck: a.authConfig.OIDC.SkipIssuerCheck, + SkipExpiryCheck: a.authConfig.OIDC.SkipExpiryCheck, } a.oidcVerifier = provider.Verifier(verifierConfig) + // Construct redirect URL from server config + // Use root_url if set, otherwise construct from protocol/domain + redirectURL := a.serverConfig.RootURL + "/auth/oidc/callback" + // Create OAuth2 config a.oauth2Config = &oauth2.Config{ - ClientID: a.config.OIDC.ClientID, - ClientSecret: a.config.OIDC.ClientSecret, + ClientID: a.authConfig.OIDC.ClientID, + ClientSecret: a.authConfig.OIDC.ClientSecret, + RedirectURL: redirectURL, Endpoint: provider.Endpoint(), - Scopes: a.config.OIDC.Scopes, + Scopes: a.authConfig.OIDC.Scopes, } return nil @@ -88,12 +95,12 @@ func (a *AuthService) ValidateBasicAuth(username, password string) bool { // Use constant-time comparison to prevent timing attacks usernameMatch := subtle.ConstantTimeCompare( []byte(username), - []byte(a.config.Basic.Username), + []byte(a.authConfig.Admin.Username), ) == 1 passwordMatch := subtle.ConstantTimeCompare( []byte(password), - []byte(a.config.Basic.Password), + []byte(a.authConfig.Admin.Password), ) == 1 return usernameMatch && passwordMatch @@ -171,14 +178,14 @@ func (a *AuthService) VerifyIDToken(ctx context.Context, rawIDToken string) (*Us // Extract user information using configured attributes userInfo := &UserInfo{ - Username: extractClaim(claims, a.config.OIDC.UsernameAttribute), - Email: extractClaim(claims, a.config.OIDC.EmailAttribute), - Name: extractClaim(claims, a.config.OIDC.NameAttribute), + Username: extractClaim(claims, a.authConfig.OIDC.UsernameAttribute), + Email: extractClaim(claims, a.authConfig.OIDC.EmailAttribute), + Name: extractClaim(claims, a.authConfig.OIDC.NameAttribute), } // Extract roles if configured - if a.config.OIDC.RoleAttributePath != "" { - userInfo.Roles = extractRoles(claims, a.config.OIDC.RoleAttributePath) + if a.authConfig.OIDC.RoleAttributePath != "" { + userInfo.Roles = extractRoles(claims, a.authConfig.OIDC.RoleAttributePath) } return userInfo, nil @@ -207,14 +214,14 @@ func (a *AuthService) GetUserInfo(ctx context.Context, token *oauth2.Token) (*Us // Build user info userInfo := &UserInfo{ - Username: extractClaim(claims, a.config.OIDC.UsernameAttribute), - Email: extractClaim(claims, a.config.OIDC.EmailAttribute), - Name: extractClaim(claims, a.config.OIDC.NameAttribute), + Username: extractClaim(claims, a.authConfig.OIDC.UsernameAttribute), + Email: extractClaim(claims, a.authConfig.OIDC.EmailAttribute), + Name: extractClaim(claims, a.authConfig.OIDC.NameAttribute), } // Extract roles if configured - if a.config.OIDC.RoleAttributePath != "" { - userInfo.Roles = extractRoles(claims, a.config.OIDC.RoleAttributePath) + if a.authConfig.OIDC.RoleAttributePath != "" { + userInfo.Roles = extractRoles(claims, a.authConfig.OIDC.RoleAttributePath) } return userInfo, nil @@ -222,12 +229,12 @@ func (a *AuthService) GetUserInfo(ctx context.Context, token *oauth2.Token) (*Us // IsAdmin checks if the user has admin role func (a *AuthService) IsAdmin(userInfo *UserInfo) bool { - if a.config.OIDC.AdminRole == "" { + if a.authConfig.OIDC.AdminRole == "" { return false } for _, role := range userInfo.Roles { - if role == a.config.OIDC.AdminRole { + if role == a.authConfig.OIDC.AdminRole { return true } } @@ -320,7 +327,7 @@ func (a *AuthService) ValidateAndConsumeState(token string) bool { // GenerateSessionToken generates a JWT session token for the user func (a *AuthService) GenerateSessionToken(userInfo *UserInfo) (string, error) { - return a.jwtService.GenerateToken(userInfo, a.config.OIDC.SessionMaxAge) + return a.jwtService.GenerateToken(userInfo, a.authConfig.OIDC.SessionMaxAge) } // ValidateSessionToken validates a JWT session token and returns user info diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 8c6d397..ab7deaf 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -23,6 +23,9 @@ type ServerConfig struct { Port int `mapstructure:"port"` Environment string `mapstructure:"environment"` FrontendPath string `mapstructure:"frontend_path"` // Path to frontend dist directory + Domain string `mapstructure:"domain"` // Domain name (e.g., garage-ui.example.com) + Protocol string `mapstructure:"protocol"` // Protocol for internal communication (http/https) + RootURL string `mapstructure:"root_url"` // Full external URL for redirects (e.g., https://garage-ui.example.com) } // GarageConfig contains Garage S3 connection settings @@ -37,13 +40,13 @@ type GarageConfig struct { // AuthConfig contains authentication configuration type AuthConfig struct { - Mode string `mapstructure:"mode"` // "none", "basic", or "oidc" - Basic BasicAuthConfig `mapstructure:"basic"` + Admin AdminAuthConfig `mapstructure:"admin"` OIDC OIDCConfig `mapstructure:"oidc"` } -// BasicAuthConfig contains basic authentication settings -type BasicAuthConfig struct { +// AdminAuthConfig contains admin authentication settings +type AdminAuthConfig struct { + Enabled bool `mapstructure:"enabled"` Username string `mapstructure:"username"` Password string `mapstructure:"password"` } @@ -139,6 +142,9 @@ func bindEnvVars() { viper.BindEnv("server.port", "GARAGE_UI_SERVER_PORT") viper.BindEnv("server.environment", "GARAGE_UI_SERVER_ENVIRONMENT") viper.BindEnv("server.frontend_path", "GARAGE_UI_SERVER_FRONTEND_PATH") + viper.BindEnv("server.domain", "GARAGE_UI_SERVER_DOMAIN") + viper.BindEnv("server.protocol", "GARAGE_UI_SERVER_PROTOCOL") + viper.BindEnv("server.root_url", "GARAGE_UI_SERVER_ROOT_URL") // Garage config viper.BindEnv("garage.endpoint", "GARAGE_UI_GARAGE_ENDPOINT") @@ -149,9 +155,9 @@ func bindEnvVars() { viper.BindEnv("garage.admin_token", "GARAGE_UI_GARAGE_ADMIN_TOKEN") // Auth config - viper.BindEnv("auth.mode", "GARAGE_UI_AUTH_MODE") - viper.BindEnv("auth.basic.username", "GARAGE_UI_AUTH_BASIC_USERNAME") - viper.BindEnv("auth.basic.password", "GARAGE_UI_AUTH_BASIC_PASSWORD") + viper.BindEnv("auth.admin.enabled", "GARAGE_UI_AUTH_ADMIN_ENABLED") + viper.BindEnv("auth.admin.username", "GARAGE_UI_AUTH_ADMIN_USERNAME") + viper.BindEnv("auth.admin.password", "GARAGE_UI_AUTH_ADMIN_PASSWORD") // OIDC config viper.BindEnv("auth.oidc.enabled", "GARAGE_UI_AUTH_OIDC_ENABLED") @@ -208,28 +214,26 @@ func (c *Config) Validate() error { return fmt.Errorf("garage admin_token is required") } - // Validate auth mode - if c.Auth.Mode != "none" && c.Auth.Mode != "basic" && c.Auth.Mode != "oidc" { - return fmt.Errorf("auth mode must be 'none', 'basic', or 'oidc', got: %s", c.Auth.Mode) - } - - // Validate basic auth if enabled - if c.Auth.Mode == "basic" { - if c.Auth.Basic.Username == "" || c.Auth.Basic.Password == "" { - return fmt.Errorf("basic auth username and password are required when auth mode is 'basic'") + // Validate admin auth if enabled + if c.Auth.Admin.Enabled { + if c.Auth.Admin.Username == "" || c.Auth.Admin.Password == "" { + return fmt.Errorf("admin auth username and password are required when admin auth is enabled") } } // Validate OIDC config if enabled - if c.Auth.Mode == "oidc" { + if c.Auth.OIDC.Enabled { if c.Auth.OIDC.ClientID == "" { - return fmt.Errorf("oidc client_id is required when auth mode is 'oidc'") + return fmt.Errorf("oidc client_id is required when oidc is enabled") } if c.Auth.OIDC.IssuerURL == "" { - return fmt.Errorf("oidc issuer_url is required when auth mode is 'oidc'") + return fmt.Errorf("oidc issuer_url is required when oidc is enabled") + } + if c.Server.RootURL == "" { + return fmt.Errorf("server.root_url is required when oidc is enabled") } if len(c.Auth.OIDC.Scopes) == 0 { - return fmt.Errorf("oidc scopes are required when auth mode is 'oidc'") + return fmt.Errorf("oidc scopes are required when oidc is enabled") } } diff --git a/backend/internal/handlers/auth.go b/backend/internal/handlers/auth.go new file mode 100644 index 0000000..af744dc --- /dev/null +++ b/backend/internal/handlers/auth.go @@ -0,0 +1,152 @@ +package handlers + +import ( + "Noooste/garage-ui/internal/auth" + "Noooste/garage-ui/internal/config" + "Noooste/garage-ui/internal/models" + + "github.com/gofiber/fiber/v3" +) + +// AuthHandler handles authentication-related requests +type AuthHandler struct { + cfg *config.Config + authService *auth.AuthService +} + +// NewAuthHandler creates a new auth handler +func NewAuthHandler(cfg *config.Config, authService *auth.AuthService) *AuthHandler { + return &AuthHandler{ + cfg: cfg, + authService: authService, + } +} + +// GetAuthConfig returns the current authentication configuration +// @Summary Get authentication configuration +// @Description Returns the current auth configuration (admin and/or OIDC) +// @Tags auth +// @Produce json +// @Success 200 {object} object{admin=object,oidc=object} "Auth config" +// @Router /auth/config [get] +func (h *AuthHandler) GetAuthConfig(c fiber.Ctx) error { + response := fiber.Map{ + "admin": fiber.Map{ + "enabled": h.cfg.Auth.Admin.Enabled, + }, + "oidc": fiber.Map{ + "enabled": h.cfg.Auth.OIDC.Enabled, + }, + } + + // Add provider name if OIDC is enabled + if h.cfg.Auth.OIDC.Enabled { + provider := h.cfg.Auth.OIDC.ProviderName + if provider == "" { + provider = "OIDC Provider" + } + response["oidc"].(fiber.Map)["provider"] = provider + } + + return c.JSON(response) +} + +// LoginBasicRequest represents the basic auth login request +type LoginBasicRequest struct { + Username string `json:"username" validate:"required"` + Password string `json:"password" validate:"required"` +} + +// LoginAdmin handles admin authentication login +// @Summary Admin auth login +// @Description Authenticate with admin username and password, returns JWT token +// @Tags auth +// @Accept json +// @Produce json +// @Param credentials body LoginBasicRequest true "Login credentials" +// @Success 200 {object} object{success=bool,token=string,user=object} "Login successful" +// @Failure 400 {object} models.APIResponse "Invalid request" +// @Failure 401 {object} models.APIResponse "Invalid credentials" +// @Router /auth/login [post] +func (h *AuthHandler) LoginAdmin(c fiber.Ctx) error { + // Parse request body + var req LoginBasicRequest + if err := c.Bind().JSON(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON( + models.ErrorResponse(models.ErrCodeBadRequest, "Invalid request body"), + ) + } + + // Validate credentials against admin config + if req.Username != h.cfg.Auth.Admin.Username || req.Password != h.cfg.Auth.Admin.Password { + return c.Status(fiber.StatusUnauthorized).JSON( + models.ErrorResponse(models.ErrCodeUnauthorized, "Invalid credentials"), + ) + } + + // Create user info object + userInfo := &auth.UserInfo{ + Username: req.Username, + } + + // Generate JWT session token + sessionToken, err := h.authService.GenerateSessionToken(userInfo) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON( + models.ErrorResponse(models.ErrCodeInternalError, "Failed to create session"), + ) + } + + return c.JSON(fiber.Map{ + "success": true, + "token": sessionToken, + "user": fiber.Map{ + "username": userInfo.Username, + }, + }) +} + +// GetMe returns the current authenticated user's information +// @Summary Get current user +// @Description Returns information about the currently authenticated user +// @Tags auth +// @Produce json +// @Security ApiKeyAuth +// @Success 200 {object} object{success=bool,user=object} "User information" +// @Failure 401 {object} models.APIResponse "Not authenticated" +// @Router /auth/me [get] +func (h *AuthHandler) GetMe(c fiber.Ctx) error { + // Try to get user info from OIDC context + userInfoInterface := c.Locals("userInfo") + if userInfoInterface != nil { + userInfo, ok := userInfoInterface.(*auth.UserInfo) + if ok { + return c.JSON(fiber.Map{ + "success": true, + "user": fiber.Map{ + "username": userInfo.Username, + "email": userInfo.Email, + "name": userInfo.Name, + }, + }) + } + } + + // Try to get username from basic auth context + usernameInterface := c.Locals("username") + if usernameInterface != nil { + username, ok := usernameInterface.(string) + if ok { + return c.JSON(fiber.Map{ + "success": true, + "user": fiber.Map{ + "username": username, + }, + }) + } + } + + return c.Status(fiber.StatusUnauthorized).JSON( + models.ErrorResponse(models.ErrCodeUnauthorized, "Not authenticated"), + ) +} diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go index b30413d..cde9aec 100644 --- a/backend/internal/middleware/auth.go +++ b/backend/internal/middleware/auth.go @@ -9,94 +9,63 @@ import ( ) // AuthMiddleware returns a Fiber middleware for authentication -// It handles different auth modes: none, basic, and OIDC +// It handles multiple auth methods: admin and OIDC func AuthMiddleware(cfg *config.AuthConfig, authService *auth.AuthService) fiber.Handler { return func(c fiber.Ctx) error { - // If auth mode is "none", allow all requests - if cfg.Mode == "none" { + // If no auth is enabled, allow all requests + if !cfg.Admin.Enabled && !cfg.OIDC.Enabled { return c.Next() } - // Handle basic authentication - if cfg.Mode == "basic" { - return handleBasicAuth(c, authService) + // Get Authorization header + authHeader := c.Get("Authorization") + + // Try admin auth if enabled and header is present + if cfg.Admin.Enabled && authHeader != "" { + // Check if it's a Bearer token (JWT from admin login) + if len(authHeader) > 7 && authHeader[:7] == "Bearer " { + token := authHeader[7:] + + // Validate JWT session token + userInfo, err := authService.ValidateSessionToken(token) + if err == nil { + // Valid admin token + c.Locals("userInfo", userInfo) + c.Locals("username", userInfo.Username) + if userInfo.Email != "" { + c.Locals("email", userInfo.Email) + } + return c.Next() + } + } } - // Handle OIDC authentication - if cfg.Mode == "oidc" { - return handleOIDCAuth(c, authService, &cfg.OIDC) + // Try OIDC auth if enabled + if cfg.OIDC.Enabled { + sessionCookie := c.Cookies(cfg.OIDC.CookieName) + if sessionCookie != "" { + // Validate JWT session token from cookie + userInfo, err := authService.ValidateSessionToken(sessionCookie) + if err == nil { + // Valid OIDC token + c.Locals("userInfo", userInfo) + c.Locals("username", userInfo.Username) + c.Locals("email", userInfo.Email) + return c.Next() + } + } } - // Unknown auth mode - deny access - return c.Status(fiber.StatusUnauthorized).JSON( - models.ErrorResponse(models.ErrCodeUnauthorized, "Invalid authentication mode"), - ) - } -} - -// handleBasicAuth validates basic authentication credentials -func handleBasicAuth(c fiber.Ctx, authService *auth.AuthService) error { - // Get Authorization header - authHeader := c.Get("Authorization") - if authHeader == "" { - c.Set("WWW-Authenticate", `Basic realm="Restricted"`) - return c.Status(fiber.StatusUnauthorized).JSON( - models.ErrorResponse(models.ErrCodeUnauthorized, "Authorization header required"), - ) - } - - // Parse basic auth credentials - username, password, ok := auth.ParseBasicAuth(authHeader) - if !ok { - c.Set("WWW-Authenticate", `Basic realm="Restricted"`) - return c.Status(fiber.StatusUnauthorized).JSON( - models.ErrorResponse(models.ErrCodeUnauthorized, "Invalid Authorization header format"), - ) - } - - // Validate credentials - if !authService.ValidateBasicAuth(username, password) { - c.Set("WWW-Authenticate", `Basic realm="Restricted"`) - return c.Status(fiber.StatusUnauthorized).JSON( - models.ErrorResponse(models.ErrCodeUnauthorized, "Invalid credentials"), - ) - } - - // Store username in context for later use - c.Locals("username", username) - - return c.Next() -} - -// handleOIDCAuth validates OIDC session/token -func handleOIDCAuth(c fiber.Ctx, authService *auth.AuthService, oidcCfg *config.OIDCConfig) error { - // Get session cookie - sessionCookie := c.Cookies(oidcCfg.CookieName) - if sessionCookie == "" { + // No valid authentication found return c.Status(fiber.StatusUnauthorized).JSON( models.ErrorResponse(models.ErrCodeUnauthorized, "Authentication required"), ) } - - // Validate JWT session token - userInfo, err := authService.ValidateSessionToken(sessionCookie) - if err != nil { - return c.Status(fiber.StatusUnauthorized).JSON( - models.ErrorResponse(models.ErrCodeUnauthorized, "Invalid or expired session"), - ) - } - - // Store user info in context for handlers to use - c.Locals("userInfo", userInfo) - c.Locals("username", userInfo.Username) - c.Locals("email", userInfo.Email) - - return c.Next() } func RequireAuth(cfg *config.AuthConfig) fiber.Handler { return func(c fiber.Ctx) error { - if cfg.Mode == "none" { + if !cfg.Admin.Enabled && !cfg.OIDC.Enabled { return c.Status(fiber.StatusForbidden).JSON( models.ErrorResponse(models.ErrCodeForbidden, "Authentication is required but not configured"), ) diff --git a/backend/internal/routes/routes.go b/backend/internal/routes/routes.go index 6b295aa..22dbd59 100644 --- a/backend/internal/routes/routes.go +++ b/backend/internal/routes/routes.go @@ -39,6 +39,12 @@ func SetupRoutes( // Swagger documentation endpoint (no auth required) app.Get("/docs/*", swagger.HandlerDefault) + // Create auth handler + authHandler := handlers.NewAuthHandler(cfg, authService) + + // Auth configuration endpoint (always accessible, no auth required) + app.Get("/auth/config", authHandler.GetAuthConfig) + // API v1 group api := app.Group("/api/v1") @@ -97,12 +103,22 @@ func SetupRoutes( monitoring.Get("/dashboard", monitoringHandler.GetDashboardMetrics) // Get dashboard metrics } + // Admin auth login endpoint (only if admin is enabled) + if cfg.Auth.Admin.Enabled { + app.Post("/auth/login", authHandler.LoginAdmin) + } + + // Auth "me" endpoint (if any auth is enabled) + if cfg.Auth.Admin.Enabled || cfg.Auth.OIDC.Enabled { + app.Get("/auth/me", middleware.AuthMiddleware(&cfg.Auth, authService), authHandler.GetMe) + } + // OIDC authentication routes (only if OIDC is enabled) - if cfg.Auth.Mode == "oidc" && cfg.Auth.OIDC.Enabled { - authRoutes := app.Group("/auth") + if cfg.Auth.OIDC.Enabled { + oidcRoutes := app.Group("/auth/oidc") { // Login endpoint - redirects to OIDC provider - authRoutes.Get("/login", func(c fiber.Ctx) error { + oidcRoutes.Get("/login", func(c fiber.Ctx) error { state, err := authService.GenerateStateToken() if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ @@ -120,7 +136,7 @@ func SetupRoutes( }) // Callback endpoint - handles OIDC redirect after login - authRoutes.Get("/callback", func(c fiber.Ctx) error { + oidcRoutes.Get("/callback", func(c fiber.Ctx) error { // Get and validate state token state := c.Query("state") if !authService.ValidateAndConsumeState(state) { @@ -180,14 +196,12 @@ func SetupRoutes( SameSite: cfg.Auth.OIDC.CookieSameSite, }) - return c.JSON(fiber.Map{ - "success": true, - "user": userInfo, - }) + // Redirect to frontend with success indicator + return c.Redirect().To("/?login=success") }) // Logout endpoint - authRoutes.Post("/logout", func(c fiber.Ctx) error { + oidcRoutes.Post("/logout", func(c fiber.Ctx) error { // Clear session cookie c.Cookie(&fiber.Cookie{ Name: cfg.Auth.OIDC.CookieName, @@ -200,21 +214,6 @@ func SetupRoutes( "message": "Logged out successfully", }) }) - - // User info endpoint - authRoutes.Get("/me", middleware.AuthMiddleware(&cfg.Auth, authService), func(c fiber.Ctx) error { - userInfo := c.Locals("userInfo") - if userInfo == nil { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ - "error": "Not authenticated", - }) - } - - return c.JSON(fiber.Map{ - "success": true, - "user": userInfo, - }) - }) } } @@ -227,6 +226,7 @@ func SetupRoutes( path := c.Path() if strings.HasPrefix(path, "/api/") || + strings.HasPrefix(path, "/auth") || strings.HasPrefix(path, "/health") || strings.HasPrefix(path, "/docs") { fmt.Println("API or health check route, skipping SPA fallback:", path) diff --git a/backend/internal/services/garage_admin.go b/backend/internal/services/garage_admin.go index 9b7920e..6fda374 100644 --- a/backend/internal/services/garage_admin.go +++ b/backend/internal/services/garage_admin.go @@ -3,6 +3,7 @@ package services import ( "Noooste/garage-ui/internal/config" "Noooste/garage-ui/internal/models" + "Noooste/garage-ui/pkg/utils" "context" "encoding/json" "fmt" @@ -31,17 +32,30 @@ func NewGarageAdminService(cfg *config.GarageConfig) *GarageAdminService { } } -// doRequest performs an HTTP request to the Admin API +// doRequest performs an HTTP request to the Admin API with retry logic for connection refused errors func (s *GarageAdminService) doRequest(ctx context.Context, method, path string, body interface{}) (*azuretls.Response, error) { - return s.httpClient.Do(&azuretls.Request{ - Method: method, - Url: s.baseURL + path, - Body: body, - IgnoreBody: true, // decodeResponse will handle body reading - OrderedHeaders: azuretls.OrderedHeaders{ - {"Authorization", fmt.Sprintf("Bearer %s", s.token)}, - }, - }, ctx) + var resp *azuretls.Response + + retryConfig := utils.DefaultRetryConfig() + err := utils.RetryWithBackoff(ctx, retryConfig, func() error { + var reqErr error + resp, reqErr = s.httpClient.Do(&azuretls.Request{ + Method: method, + Url: s.baseURL + path, + Body: body, + IgnoreBody: true, // decodeResponse will handle body reading + OrderedHeaders: azuretls.OrderedHeaders{ + {"Authorization", fmt.Sprintf("Bearer %s", s.token)}, + }, + }, ctx) + return reqErr + }) + + if err != nil { + return nil, err + } + + return resp, nil } // decodeResponse decodes a JSON response into the target structure diff --git a/backend/internal/services/s3.go b/backend/internal/services/s3.go index ac14794..ced1ebc 100644 --- a/backend/internal/services/s3.go +++ b/backend/internal/services/s3.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "io" + "net/url" "strings" "time" @@ -122,8 +123,15 @@ func (s *S3Service) getMinioClient(ctx context.Context, bucketName string) (*min // ListBuckets retrieves all buckets from Garage func (s *S3Service) ListBuckets(ctx context.Context) (*models.BucketListResponse, error) { - // Call MinIO ListBuckets API - bucketInfos, err := s.client.ListBuckets(ctx) + var bucketInfos []minio.BucketInfo + + // Call MinIO ListBuckets API with retry logic + retryConfig := utils.DefaultRetryConfig() + err := utils.RetryWithBackoff(ctx, retryConfig, func() error { + var listErr error + bucketInfos, listErr = s.client.ListBuckets(ctx) + return listErr + }) if err != nil { return nil, fmt.Errorf("failed to list buckets: %w", err) } @@ -150,9 +158,12 @@ func (s *S3Service) CreateBucket(ctx context.Context, bucketName string) error { return fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err) } - // Call MinIO MakeBucket API - err = client.MakeBucket(ctx, bucketName, minio.MakeBucketOptions{ - Region: s.config.Region, + // Call MinIO MakeBucket API with retry logic + retryConfig := utils.DefaultRetryConfig() + err = utils.RetryWithBackoff(ctx, retryConfig, func() error { + return client.MakeBucket(ctx, bucketName, minio.MakeBucketOptions{ + Region: s.config.Region, + }) }) if err != nil { return fmt.Errorf("failed to create bucket %s: %w", bucketName, err) @@ -168,8 +179,11 @@ func (s *S3Service) DeleteBucket(ctx context.Context, bucketName string) error { return fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err) } - // Call MinIO RemoveBucket API - err = client.RemoveBucket(ctx, bucketName) + // Call MinIO RemoveBucket API with retry logic + retryConfig := utils.DefaultRetryConfig() + err = utils.RetryWithBackoff(ctx, retryConfig, func() error { + return client.RemoveBucket(ctx, bucketName) + }) if err != nil { return fmt.Errorf("failed to delete bucket %s: %w", bucketName, err) } @@ -273,8 +287,15 @@ func (s *S3Service) UploadObject(ctx context.Context, bucketName, key string, bo ContentType: contentType, } - // Call MinIO PutObject API - info, err := client.PutObject(ctx, bucketName, key, body, -1, opts) + var info minio.UploadInfo + + // Call MinIO PutObject API with retry logic + retryConfig := utils.DefaultRetryConfig() + err = utils.RetryWithBackoff(ctx, retryConfig, func() error { + var uploadErr error + info, uploadErr = client.PutObject(ctx, bucketName, key, body, -1, opts) + return uploadErr + }) if err != nil { return nil, fmt.Errorf("failed to upload object %s to bucket %s: %w", key, bucketName, err) } @@ -290,8 +311,15 @@ func (s *S3Service) UploadObject(ctx context.Context, bucketName, key string, bo // GetObject retrieves an object from a bucket func (s *S3Service) GetObject(ctx context.Context, bucketName, key string) (io.ReadCloser, *models.ObjectInfo, error) { - // Call MinIO GetObject API - object, err := s.client.GetObject(ctx, bucketName, key, minio.GetObjectOptions{}) + var object *minio.Object + + // Call MinIO GetObject API with retry logic + retryConfig := utils.DefaultRetryConfig() + err := utils.RetryWithBackoff(ctx, retryConfig, func() error { + var getErr error + object, getErr = s.client.GetObject(ctx, bucketName, key, minio.GetObjectOptions{}) + return getErr + }) if err != nil { return nil, nil, fmt.Errorf("failed to get object %s from bucket %s: %w", key, bucketName, err) } @@ -317,8 +345,11 @@ func (s *S3Service) GetObject(ctx context.Context, bucketName, key string) (io.R // DeleteObject deletes an object from a bucket func (s *S3Service) DeleteObject(ctx context.Context, bucketName, key string) error { - // Call MinIO RemoveObject API - err := s.client.RemoveObject(ctx, bucketName, key, minio.RemoveObjectOptions{}) + // Call MinIO RemoveObject API with retry logic + retryConfig := utils.DefaultRetryConfig() + err := utils.RetryWithBackoff(ctx, retryConfig, func() error { + return s.client.RemoveObject(ctx, bucketName, key, minio.RemoveObjectOptions{}) + }) if err != nil { return fmt.Errorf("failed to delete object %s from bucket %s: %w", key, bucketName, err) } @@ -334,7 +365,15 @@ func (s *S3Service) ObjectExists(ctx context.Context, bucketName, key string) (b return false, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err) } - _, err = client.StatObject(ctx, bucketName, key, minio.StatObjectOptions{}) + var statErr error + + // Call MinIO StatObject API with retry logic + retryConfig := utils.DefaultRetryConfig() + err = utils.RetryWithBackoff(ctx, retryConfig, func() error { + _, statErr = client.StatObject(ctx, bucketName, key, minio.StatObjectOptions{}) + return statErr + }) + if err != nil { // Check if error is "object not found" errResponse := minio.ToErrorResponse(err) @@ -354,7 +393,15 @@ func (s *S3Service) GetObjectMetadata(ctx context.Context, bucketName, key strin return nil, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err) } - stat, err := client.StatObject(ctx, bucketName, key, minio.StatObjectOptions{}) + var stat minio.ObjectInfo + + // Call MinIO StatObject API with retry logic + retryConfig := utils.DefaultRetryConfig() + err = utils.RetryWithBackoff(ctx, retryConfig, func() error { + var statErr error + stat, statErr = client.StatObject(ctx, bucketName, key, minio.StatObjectOptions{}) + return statErr + }) if err != nil { return nil, fmt.Errorf("failed to get metadata for object %s in bucket %s: %w", key, bucketName, err) } @@ -415,8 +462,15 @@ func (s *S3Service) GetPresignedURL(ctx context.Context, bucketName, key string, return "", fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err) } - // Generate presigned GET URL - presignedURL, err := client.PresignedGetObject(ctx, bucketName, key, expiresIn, nil) + var presignedURL *url.URL + + // Generate presigned GET URL with retry logic + retryConfig := utils.DefaultRetryConfig() + err = utils.RetryWithBackoff(ctx, retryConfig, func() error { + var presignErr error + presignedURL, presignErr = client.PresignedGetObject(ctx, bucketName, key, expiresIn, nil) + return presignErr + }) if err != nil { return "", fmt.Errorf("failed to generate presigned URL for %s/%s: %w", bucketName, key, err) } diff --git a/backend/main.go b/backend/main.go index cb2352f..49c8029 100644 --- a/backend/main.go +++ b/backend/main.go @@ -25,9 +25,6 @@ import ( // @description This API provides endpoints for managing buckets, objects, users, and cluster operations. // @termsOfService http://swagger.io/terms/ -// @contact.name API Support -// @contact.email support@garage-ui.io - // @license.name MIT // @license.url https://opensource.org/licenses/MIT @@ -81,8 +78,19 @@ func main() { log.Println("Initializing S3 service...") s3Service := services.NewS3Service(&cfg.Garage, adminService) - log.Printf("Initializing authentication service (mode: %s)...", cfg.Auth.Mode) - authService, err := auth.NewAuthService(&cfg.Auth) + // Determine enabled auth methods for logging + authMethods := []string{} + if cfg.Auth.Admin.Enabled { + authMethods = append(authMethods, "admin") + } + if cfg.Auth.OIDC.Enabled { + authMethods = append(authMethods, "oidc") + } + if len(authMethods) == 0 { + authMethods = append(authMethods, "none") + } + log.Printf("Initializing authentication service (enabled: %v)...", authMethods) + authService, err := auth.NewAuthService(&cfg.Auth, &cfg.Server) if err != nil { log.Fatalf("Failed to initialize auth service: %v", err) } diff --git a/backend/pkg/utils/retry.go b/backend/pkg/utils/retry.go new file mode 100644 index 0000000..bb632ca --- /dev/null +++ b/backend/pkg/utils/retry.go @@ -0,0 +1,104 @@ +package utils + +import ( + "context" + "errors" + "fmt" + "net" + "syscall" + "time" +) + +// RetryConfig configures retry behavior +type RetryConfig struct { + MaxRetries int + InitialBackoff time.Duration + MaxBackoff time.Duration + BackoffFactor float64 +} + +// DefaultRetryConfig returns default retry configuration +func DefaultRetryConfig() RetryConfig { + return RetryConfig{ + MaxRetries: 3, + InitialBackoff: 100 * time.Millisecond, + MaxBackoff: 5 * time.Second, + BackoffFactor: 2.0, + } +} + +// IsConnectionRefused checks if the error is a connection refused error +func IsConnectionRefused(err error) bool { + if err == nil { + return false + } + + // Check for connection refused error + var opErr *net.OpError + if errors.As(err, &opErr) { + // Check if it's a connection refused error + if opErr.Op == "dial" || opErr.Op == "read" { + var syscallErr *syscall.Errno + if errors.As(opErr.Err, &syscallErr) { + return *syscallErr == syscall.ECONNREFUSED + } + } + } + + // Also check for "connection refused" in error message as fallback + if errors.Is(err, syscall.ECONNREFUSED) { + return true + } + + return false +} + +// RetryWithBackoff executes a function with exponential backoff on connection refused errors +func RetryWithBackoff(ctx context.Context, config RetryConfig, fn func() error) error { + var lastErr error + + for attempt := 0; attempt <= config.MaxRetries; attempt++ { + // Execute the function + err := fn() + if err == nil { + return nil + } + + lastErr = err + + // If it's not a connection refused error, don't retry + if !IsConnectionRefused(err) { + return err + } + + // If this was the last attempt, return the error + if attempt == config.MaxRetries { + return fmt.Errorf("max retries (%d) exceeded: %w", config.MaxRetries, err) + } + + // Calculate backoff duration with exponential increase + backoff := time.Duration(float64(config.InitialBackoff) * pow(config.BackoffFactor, float64(attempt))) + if backoff > config.MaxBackoff { + backoff = config.MaxBackoff + } + + // Wait with context cancellation support + select { + case <-ctx.Done(): + return fmt.Errorf("context cancelled during retry: %w", ctx.Err()) + case <-time.After(backoff): + // Continue to next attempt + } + } + + return lastErr +} + +// pow is a simple integer exponentiation helper +func pow(base float64, exp float64) float64 { + result := 1.0 + for i := 0; i < int(exp); i++ { + result *= base + } + return result +} diff --git a/config.yaml.example b/config.yaml.example index 3dff888..891a987 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -5,6 +5,9 @@ server: host: "0.0.0.0" port: 8080 environment: "development" # development, production + domain: "localhost" # Domain name for the application + protocol: "http" # Protocol for internal communication (http/https) + root_url: "http://localhost:8080" # Full external URL for OAuth2 redirects (adjust for production) # Garage S3 Configuration garage: @@ -16,18 +19,19 @@ garage: admin_token: "changeme" # Admin API bearer token # Authentication Configuration +# You can enable one or both authentication methods auth: - # Auth mode: "none", "basic", or "oidc" - mode: "none" - - # Basic Authentication (only used when mode = "basic") - basic: + # Admin Authentication (username/password) + admin: + enabled: false # Set to true to enable admin login username: "admin" password: "changeme" - # OIDC Configuration (only used when mode = "oidc") + # OIDC Configuration + # NOTE: When OIDC is enabled, server.root_url is required for OAuth2 redirects + # The redirect URL will be automatically constructed as: {root_url}/auth/oidc/callback oidc: - enabled: true + enabled: false # Set to true to enable OIDC login provider_name: "Keycloak" client_id: "garage-ui" client_secret: "your-client-secret" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 457a320..770b2f3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,3 +1,4 @@ +import { useEffect } from 'react'; import {BrowserRouter, Route, Routes} from 'react-router-dom'; import {QueryClientProvider} from '@tanstack/react-query'; import {ThemeProvider, useTheme} from '@/components/theme-provider'; @@ -6,8 +7,12 @@ import {Dashboard} from '@/pages/Dashboard'; import {Buckets} from '@/pages/Buckets'; import {Cluster} from '@/pages/Cluster'; import {AccessControl} from '@/pages/AccessControl'; +import {Login} from '@/pages/Login'; import {Toaster} from 'sonner'; import {queryClient} from '@/lib/query-client'; +import {useAuthStore} from '@/store/auth-store'; +import {ProtectedRoute} from '@/components/auth/ProtectedRoute'; +import {LoadingSpinner} from '@/components/auth/LoadingSpinner'; function ThemedToaster() { const { theme } = useTheme(); @@ -16,12 +21,31 @@ function ThemedToaster() { } function App() { + const { initialize, isLoading } = useAuthStore(); + + useEffect(() => { + initialize(); + }, [initialize]); + + if (isLoading) { + return ; + } + return ( - }> + } /> + + + + + } + > } /> } /> } /> diff --git a/frontend/src/components/auth/BasicLoginForm.tsx b/frontend/src/components/auth/BasicLoginForm.tsx new file mode 100644 index 0000000..7c4dcfa --- /dev/null +++ b/frontend/src/components/auth/BasicLoginForm.tsx @@ -0,0 +1,118 @@ +import { useState } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { useAuthStore } from '@/store/auth-store'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Lock, LogIn } from 'lucide-react'; +import type { AuthConfig } from '@/types/auth'; + +interface BasicLoginFormProps { + showOIDC?: boolean; + config?: AuthConfig | null; +} + +export function BasicLoginForm({ showOIDC = false, config }: BasicLoginFormProps) { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const { loginAdmin, loginOIDC } = useAuthStore(); + + const returnUrl = searchParams.get('returnUrl') || '/'; + const providerName = config?.oidc?.provider || 'OIDC Provider'; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + + try { + await loginAdmin(username, password); + // Navigate to return URL on success + navigate(decodeURIComponent(returnUrl)); + } catch (error) { + // Error is already handled by the store and toast + console.error('Login failed:', error); + } finally { + setIsLoading(false); + } + }; + + return ( + + +
+
+ +
+
+ + {showOIDC ? 'Sign in to Garage UI' : 'Admin Login'} + + + {showOIDC ? 'Enter your credentials or use SSO' : 'Enter your credentials to access the dashboard'} + +
+ +
+
+ + setUsername(e.target.value)} + required + disabled={isLoading} + autoComplete="username" + /> +
+
+ + setPassword(e.target.value)} + required + disabled={isLoading} + autoComplete="current-password" + /> +
+ +
+ + {showOIDC && ( +
+
+
+ +
+
+ Or +
+
+ +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/auth/LoadingSpinner.tsx b/frontend/src/components/auth/LoadingSpinner.tsx new file mode 100644 index 0000000..c0c7654 --- /dev/null +++ b/frontend/src/components/auth/LoadingSpinner.tsx @@ -0,0 +1,12 @@ +import { Loader2 } from 'lucide-react'; + +export function LoadingSpinner() { + return ( +
+
+ +

Loading...

+
+
+ ); +} diff --git a/frontend/src/components/auth/OIDCLoginView.tsx b/frontend/src/components/auth/OIDCLoginView.tsx new file mode 100644 index 0000000..170c1c0 --- /dev/null +++ b/frontend/src/components/auth/OIDCLoginView.tsx @@ -0,0 +1,41 @@ +import { useAuthStore } from '@/store/auth-store'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { LogIn } from 'lucide-react'; + +export function OIDCLoginView() { + const { config, loginOIDC } = useAuthStore(); + + const providerName = config?.oidc.provider || 'OIDC Provider'; + + return ( +
+ + +
+
+ +
+
+ Sign in to Garage UI + + Authenticate using your {providerName} account + +
+ + +

+ You will be redirected to {providerName} to complete the sign-in process +

+
+
+
+ ); +} diff --git a/frontend/src/components/auth/ProtectedRoute.tsx b/frontend/src/components/auth/ProtectedRoute.tsx new file mode 100644 index 0000000..d9c1b4c --- /dev/null +++ b/frontend/src/components/auth/ProtectedRoute.tsx @@ -0,0 +1,29 @@ +import { Navigate, useLocation } from 'react-router-dom'; +import { useAuthStore } from '@/store/auth-store'; +import { LoadingSpinner } from './LoadingSpinner'; + +interface ProtectedRouteProps { + children: React.ReactNode; +} + +export function ProtectedRoute({ children }: ProtectedRouteProps) { + const { isAuthenticated, isLoading, config } = useAuthStore(); + const location = useLocation(); + + if (isLoading) { + return ; + } + + // If no auth is enabled, always allow access + if (config && !config.admin.enabled && !config.oidc.enabled) { + return <>{children}; + } + + // If not authenticated, redirect to login with return URL + if (!isAuthenticated) { + const returnUrl = encodeURIComponent(location.pathname + location.search); + return ; + } + + return <>{children}; +} diff --git a/frontend/src/components/layout/sidebar.tsx b/frontend/src/components/layout/sidebar.tsx index 8d5c3a3..2cdd40a 100644 --- a/frontend/src/components/layout/sidebar.tsx +++ b/frontend/src/components/layout/sidebar.tsx @@ -1,6 +1,8 @@ import {Link, useLocation} from 'react-router-dom'; import {cn} from '@/lib/utils'; -import {Database, Key, LayoutDashboard, Server} from 'lucide-react'; +import {Database, Key, LayoutDashboard, LogOut, Server, User} from 'lucide-react'; +import {useAuthStore} from '@/store/auth-store'; +import {Button} from '@/components/ui/button'; interface NavItem { title: string; @@ -38,6 +40,11 @@ interface SidebarProps { export function Sidebar({ isOpen, onClose }: SidebarProps) { const location = useLocation(); + const { user, config, logout } = useAuthStore(); + + const handleLogout = () => { + logout(); + }; return (
- {/*
*/} - {/*
*/} - {/*
*/} - {/* AD*/} - {/*
*/} - {/*
*/} - {/*

Admin User

*/} - {/*

admin@garage.local

*/} - {/*
*/} - {/*
*/} - {/*
*/} + {config && (config.admin.enabled || config.oidc.enabled) && user && ( +
+
+
+ +
+
+

{user.name || user.username}

+ {user.email && ( +

{user.email}

+ )} +
+
+ +
+ )}
); } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index dccf7c7..2913ded 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -15,6 +15,7 @@ import type { S3Object, StorageMetrics, } from '@/types'; +import type { AuthUser } from '@/types/auth'; const api = axios.create({ baseURL: '/api', @@ -23,6 +24,14 @@ const api = axios.create({ }, }); +// Separate axios instance for auth endpoints (which are not under /api) +const authApiClient = axios.create({ + baseURL: '/auth', + headers: { + 'Content-Type': 'application/json', + }, +}); + api.interceptors.request.use((config) => { const token = localStorage.getItem('auth-token'); if (token) { @@ -31,6 +40,14 @@ api.interceptors.request.use((config) => { return config; }); +authApiClient.interceptors.request.use((config) => { + const token = localStorage.getItem('auth-token'); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + api.interceptors.response.use( (response) => { // If response has success=false in data, treat it as an error @@ -50,6 +67,19 @@ api.interceptors.response.use( return response; }, (error) => { + // Handle 401 Unauthorized - redirect to login + if (error.response?.status === 401) { + // Clear auth token + localStorage.removeItem('auth-token'); + + // Only redirect if not already on login page + if (window.location.pathname !== '/login') { + window.location.href = '/login'; + } + + return Promise.reject(error); + } + // Handle axios errors if (error.response) { // Server responded with error status @@ -84,6 +114,44 @@ api.interceptors.response.use( } ); +// Auth API +export const authApi = { + getConfig: async () => { + const response = await authApiClient.get<{ + admin: { enabled: boolean }; + oidc: { enabled: boolean; provider?: string }; + }>('/config'); + return response; + }, + + loginAdmin: async (username: string, password: string) => { + const response = await authApiClient.post<{ success: boolean; token: string; user: AuthUser }>('/login', { + username, + password, + }); + return response; + }, + + me: async () => { + const response = await authApiClient.get<{ success: boolean; user: AuthUser }>('/me'); + return response; + }, + + logoutAdmin: async () => { + // For admin, just clear local storage (no server logout needed) + return Promise.resolve(); + }, + + logoutOIDC: async () => { + const response = await authApiClient.post('/oidc/logout'); + return response; + }, + + loginOIDC: () => { + window.location.href = '/auth/oidc/login'; + }, +}; + // Bucket API export const bucketsApi = { list: async (): Promise => { diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx new file mode 100644 index 0000000..1ce6dba --- /dev/null +++ b/frontend/src/pages/Login.tsx @@ -0,0 +1,80 @@ +import { useEffect } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { useAuthStore } from '@/store/auth-store'; +import { BasicLoginForm } from '@/components/auth/BasicLoginForm'; +import { OIDCLoginView } from '@/components/auth/OIDCLoginView'; +import { LoadingSpinner } from '@/components/auth/LoadingSpinner'; + +export function Login() { + const { config, isLoading, initialize, isAuthenticated } = useAuthStore(); + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + + const loginSuccess = searchParams.get('login'); + const returnUrl = searchParams.get('returnUrl') || '/'; + + useEffect(() => { + // Handle OIDC callback + if (loginSuccess === 'success') { + // OIDC login successful, re-initialize auth to fetch user + initialize().then(() => { + navigate(decodeURIComponent(returnUrl)); + }); + } + }, [loginSuccess, initialize, navigate, returnUrl]); + + useEffect(() => { + // If already authenticated, redirect to return URL + if (isAuthenticated && !loginSuccess) { + navigate(decodeURIComponent(returnUrl)); + } + }, [isAuthenticated, navigate, returnUrl, loginSuccess]); + + if (isLoading || loginSuccess === 'success') { + return ; + } + + // No auth enabled, redirect to dashboard immediately + if (config && !config.admin.enabled && !config.oidc.enabled) { + navigate('/'); + return null; + } + + // Show login options based on what's enabled + const showAdmin = config?.admin.enabled || false; + const showOIDC = config?.oidc.enabled || false; + + // If both are enabled, show both options in single modal + if (showAdmin && showOIDC) { + return ( +
+
+
+

Sign in to Garage UI

+

Enter your credentials to continue

+
+ +
+
+ ); + } + + // Show only OIDC if enabled + if (showOIDC) { + return ; + } + + // Show only admin if enabled + if (showAdmin) { + return ( +
+
+ +
+
+ ); + } + + // Still loading config + return ; +} diff --git a/frontend/src/store/auth-store.ts b/frontend/src/store/auth-store.ts new file mode 100644 index 0000000..02b8986 --- /dev/null +++ b/frontend/src/store/auth-store.ts @@ -0,0 +1,154 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import type { AuthConfig, AuthUser, AuthState } from '@/types/auth'; +import { authApi } from '@/lib/api'; + +interface AuthStore extends AuthState { + config: AuthConfig | null; + + // Actions + setUser: (user: AuthUser | null) => void; + setConfig: (config: AuthConfig) => void; + setLoading: (loading: boolean) => void; + setError: (error: string | null) => void; + setAuthenticated: (authenticated: boolean) => void; + + // Async actions + initialize: () => Promise; + loginAdmin: (username: string, password: string) => Promise; + loginOIDC: () => void; + logout: () => Promise; +} + +export const useAuthStore = create()( + persist( + (set, get) => ({ + user: null, + config: null, + isAuthenticated: false, + isLoading: true, + error: null, + + setUser: (user) => set({ user, isAuthenticated: !!user }), + setConfig: (config) => set({ config }), + setLoading: (isLoading) => set({ isLoading }), + setError: (error) => set({ error }), + setAuthenticated: (isAuthenticated) => set({ isAuthenticated }), + + initialize: async () => { + try { + set({ isLoading: true, error: null }); + + // Fetch auth configuration + const configResponse = await authApi.getConfig(); + const config = configResponse.data as AuthConfig; + set({ config }); + + // If no auth is enabled, mark as authenticated immediately + if (!config.admin.enabled && !config.oidc.enabled) { + set({ + isAuthenticated: true, + isLoading: false, + user: { username: 'guest' } + }); + return; + } + + // Try to get current user (check if already authenticated) + try { + const userResponse = await authApi.me(); + const user = userResponse.data.user; + set({ + user, + isAuthenticated: true, + isLoading: false + }); + } catch (error) { + // Not authenticated - this is okay + set({ + user: null, + isAuthenticated: false, + isLoading: false + }); + } + } catch (error) { + console.error('Failed to initialize auth:', error); + set({ + error: 'Failed to initialize authentication', + isLoading: false, + isAuthenticated: false + }); + } + }, + + loginAdmin: async (username, password) => { + try { + set({ isLoading: true, error: null }); + + const response = await authApi.loginAdmin(username, password); + const { token, user } = response.data; + + // Store token in localStorage + localStorage.setItem('auth-token', token); + + // Update state + set({ + user, + isAuthenticated: true, + isLoading: false, + error: null + }); + } catch (error: any) { + const errorMessage = error.response?.data?.error?.message || 'Login failed'; + set({ + error: errorMessage, + isLoading: false, + isAuthenticated: false, + user: null + }); + throw error; // Re-throw for form handling + } + }, + + loginOIDC: () => { + // Redirect to OIDC login endpoint + window.location.href = '/auth/oidc/login'; + }, + + logout: async () => { + const { config } = get(); + + try { + // Call logout endpoint for OIDC mode + if (config?.oidc.enabled) { + await authApi.logoutOIDC(); + } else if (config?.admin.enabled) { + await authApi.logoutAdmin(); + } + } catch (error) { + console.error('Logout API call failed:', error); + } + + // Clear local storage + localStorage.removeItem('auth-token'); + + // Clear state + set({ + user: null, + isAuthenticated: false, + error: null + }); + + // Redirect to login page + window.location.href = '/login'; + }, + }), + { + name: 'auth-storage', + partialize: (state) => ({ + user: state.user, + // Don't persist config, isLoading, or error + }), + } + ) +); diff --git a/frontend/src/types/auth.ts b/frontend/src/types/auth.ts new file mode 100644 index 0000000..94f3740 --- /dev/null +++ b/frontend/src/types/auth.ts @@ -0,0 +1,22 @@ +export interface AuthConfig { + admin: { + enabled: boolean; + }; + oidc: { + enabled: boolean; + provider?: string; + }; +} + +export interface AuthUser { + username: string; + email?: string; + name?: string; +} + +export interface AuthState { + user: AuthUser | null; + isAuthenticated: boolean; + isLoading: boolean; + error: string | null; +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 3ee1b0c..fe8ff18 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -18,6 +18,10 @@ export default defineConfig({ target: 'http://localhost:8080', changeOrigin: true, }, + '/auth': { + target: 'http://localhost:8080', + changeOrigin: true, + }, }, }, build: { diff --git a/helm/garage-ui/README.md b/helm/garage-ui/README.md index 6a14b63..cc7213e 100644 --- a/helm/garage-ui/README.md +++ b/helm/garage-ui/README.md @@ -147,30 +147,53 @@ config: environment: production # Environment (production/development/staging) ``` -#### Authentication Modes +#### Authentication Configuration **No Authentication** (default, suitable for private networks): ```yaml config: auth: - mode: "none" + admin: + enabled: false + oidc: + enabled: false ``` -**Basic Authentication**: +**Admin Authentication** (username/password with JWT): ```yaml config: auth: - mode: "basic" - basic: + admin: + enabled: true username: "admin" password: "your-secure-password" + oidc: + enabled: false ``` **OIDC/SSO** (recommended for production): ```yaml config: auth: - mode: "oidc" + admin: + enabled: false + oidc: + enabled: true + provider_name: "Keycloak" + client_id: "garage-ui" + client_secret: "your-oidc-secret" + issuer_url: "https://auth.example.com/realms/master" + # ... additional OIDC settings +``` + +**Both Authentication Methods** (admin and OIDC simultaneously): +```yaml +config: + auth: + admin: + enabled: true + username: "admin" + password: "your-secure-password" oidc: enabled: true provider_name: "Keycloak" @@ -324,10 +347,10 @@ Install: helm install garage-ui ./helm/garage-ui -f values-ingress.yaml ``` -### Example 3: With Basic Authentication +### Example 3: With Admin Authentication ```yaml -# values-basic-auth.yaml +# values-admin-auth.yaml config: garage: endpoint: "http://garage:3900" @@ -335,10 +358,12 @@ config: admin_token: "your-admin-token" auth: - mode: "basic" - basic: + admin: + enabled: true username: "admin" password: "super-secret-password-change-me" + oidc: + enabled: false ingress: enabled: true @@ -352,7 +377,7 @@ ingress: Install: ```bash -helm install garage-ui ./helm/garage-ui -f values-basic-auth.yaml +helm install garage-ui ./helm/garage-ui -f values-admin-auth.yaml ``` ### Example 4: Production Setup with OIDC (Keycloak) @@ -372,7 +397,8 @@ config: region: "us-east-1" auth: - mode: "oidc" + admin: + enabled: false oidc: enabled: true provider_name: "Keycloak" @@ -640,7 +666,7 @@ Common issues: - **Missing admin token**: Ensure `config.garage.admin_token` is set or `config.garage.existingSecret.name` points to a valid secret - **Secret not found**: If using `existingSecret`, verify the secret exists: `kubectl get secret ` - **Unreachable Garage**: Verify endpoints are accessible from within the cluster -- **Invalid OIDC config**: Check all OIDC URLs and credentials when using `auth.mode: oidc` +- **Invalid OIDC config**: Check all OIDC URLs and credentials when using `auth.oidc.enabled: true` ### Cannot Access the UI @@ -684,14 +710,17 @@ curl http://garage:3903/health ### Authentication Issues -**Basic Auth:** -- Verify username/password in `config.auth.basic` +**Admin Auth:** +- Verify username/password in `config.auth.admin` +- Ensure `config.auth.admin.enabled` is set to `true` - Check browser developer tools for 401 errors +- Verify JWT token is being sent in Authorization header **OIDC:** +- Ensure `config.auth.oidc.enabled` is set to `true` - Verify all OIDC URLs are accessible - Check OIDC provider logs -- Ensure redirect URI is registered: `https://your-domain/auth/callback` +- Ensure redirect URI is registered: `https://your-domain/auth/oidc/callback` - Verify client ID and secret ### View Application Logs