mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-07-26 15:58:13 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 61dae6c605 | |||
| b49c634f17 | |||
| 5910961825 | |||
| 7a4e187745 |
@@ -85,11 +85,6 @@ docker run -d \
|
||||
docker pull noooste/garage-ui:latest
|
||||
```
|
||||
|
||||
**Available tags:**
|
||||
- `latest` - Latest stable release
|
||||
- `v0.0.4` - Specific version
|
||||
- `0.0` - Minor version track
|
||||
|
||||
The Docker image is multi-platform, supporting `linux/amd64` and `linux/arm64`.
|
||||
|
||||
### Kubernetes Deployment with Helm
|
||||
@@ -156,23 +151,23 @@ server:
|
||||
|
||||
# Garage S3 Configuration
|
||||
garage:
|
||||
endpoint: "http://garage:3900"
|
||||
region: "eu-west-1"
|
||||
endpoint: "garage:3900"
|
||||
region: "eu-west-1" # Ensure to match Garage region from garage.toml
|
||||
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"
|
||||
@@ -192,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
|
||||
```
|
||||
|
||||
@@ -361,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"
|
||||
@@ -406,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
|
||||
@@ -497,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:
|
||||
@@ -623,8 +651,6 @@ GARAGE_UI_LOGGING_LEVEL=debug
|
||||
|
||||
This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
Copyright (c) 2025 Noste
|
||||
|
||||
---
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
}
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"Noooste/garage-ui/internal/config"
|
||||
@@ -24,6 +26,16 @@ type S3Service struct {
|
||||
// NewS3Service creates a new S3 service instance using MinIO SDK
|
||||
func NewS3Service(cfg *config.GarageConfig, adminService *GarageAdminService) *S3Service {
|
||||
// Create MinIO client for Garage
|
||||
// trim http or https from endpoint
|
||||
if strings.HasPrefix(cfg.Endpoint, "http://") {
|
||||
cfg.Endpoint = strings.TrimPrefix(cfg.Endpoint, "http://")
|
||||
}
|
||||
|
||||
if strings.HasPrefix(cfg.Endpoint, "https://") {
|
||||
cfg.Endpoint = strings.TrimPrefix(cfg.Endpoint, "https://")
|
||||
cfg.UseSSL = true
|
||||
}
|
||||
|
||||
client, err := minio.New(cfg.Endpoint, &minio.Options{
|
||||
//Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""),
|
||||
Secure: cfg.UseSSL,
|
||||
@@ -111,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)
|
||||
}
|
||||
@@ -139,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)
|
||||
@@ -157,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)
|
||||
}
|
||||
@@ -262,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)
|
||||
}
|
||||
@@ -279,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)
|
||||
}
|
||||
@@ -306,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)
|
||||
}
|
||||
@@ -323,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)
|
||||
@@ -343,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)
|
||||
}
|
||||
@@ -404,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)
|
||||
}
|
||||
|
||||
+13
-5
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+12
-8
@@ -5,29 +5,33 @@ 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:
|
||||
endpoint: "http://localhost:3900" # Garage S3 API endpoint
|
||||
region: "eu-west-1" # S3 region (can be any value for Garage)
|
||||
region: "eu-west-1" # S3 region (ensure it matches Garage S3 configuration)
|
||||
|
||||
# Garage Admin API configuration
|
||||
admin_endpoint: "http://localhost:3903" # Garage Admin API endpoint
|
||||
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"
|
||||
|
||||
+25
-1
@@ -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 <LoadingSpinner />;
|
||||
}
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider defaultTheme="system" storageKey="Noooste/garage-ui-theme">
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Layout />}>
|
||||
<Route path="/login" element={<Login />} />
|
||||
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="buckets" element={<Buckets />} />
|
||||
<Route path="cluster" element={<Cluster />} />
|
||||
|
||||
@@ -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 (
|
||||
<Card className="w-full">
|
||||
<CardHeader className="space-y-1">
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
|
||||
<Lock className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle className="text-2xl text-center">
|
||||
{showOIDC ? 'Sign in to Garage UI' : 'Admin Login'}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-center">
|
||||
{showOIDC ? 'Enter your credentials or use SSO' : 'Enter your credentials to access the dashboard'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="username" className="text-sm font-medium">Username</label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
placeholder="Enter your username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
disabled={isLoading}
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="password" className="text-sm font-medium">Password</label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
disabled={isLoading}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isLoading || !username || !password}
|
||||
>
|
||||
{isLoading ? 'Signing in...' : 'Sign in'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{showOIDC && (
|
||||
<div className="mt-4">
|
||||
<div className="relative mb-4">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-card px-2 text-muted-foreground">Or</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={loginOIDC}
|
||||
>
|
||||
<LogIn className="mr-2 h-4 w-4" />
|
||||
Sign in with {providerName}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
export function LoadingSpinner() {
|
||||
return (
|
||||
<div className="flex h-screen w-full items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1">
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
|
||||
<LogIn className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle className="text-2xl text-center">Sign in to Garage UI</CardTitle>
|
||||
<CardDescription className="text-center">
|
||||
Authenticate using your {providerName} account
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button
|
||||
onClick={loginOIDC}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
>
|
||||
<LogIn className="mr-2 h-5 w-5" />
|
||||
Continue with {providerName}
|
||||
</Button>
|
||||
<p className="mt-4 text-center text-xs text-muted-foreground">
|
||||
You will be redirected to {providerName} to complete the sign-in process
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 <LoadingSpinner />;
|
||||
}
|
||||
|
||||
// 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 <Navigate to={`/login?returnUrl=${returnUrl}`} replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
@@ -76,17 +83,30 @@ export function Sidebar({ isOpen, onClose }: SidebarProps) {
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
{/*<div className="border-t p-4">*/}
|
||||
{/* <div className="flex items-center gap-3 rounded-lg bg-muted px-3 py-2">*/}
|
||||
{/* <div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground text-xs font-semibold">*/}
|
||||
{/* AD*/}
|
||||
{/* </div>*/}
|
||||
{/* <div className="flex-1 overflow-hidden">*/}
|
||||
{/* <p className="text-sm font-medium truncate">Admin User</p>*/}
|
||||
{/* <p className="text-xs text-muted-foreground truncate">admin@garage.local</p>*/}
|
||||
{/* </div>*/}
|
||||
{/* </div>*/}
|
||||
{/*</div>*/}
|
||||
{config && (config.admin.enabled || config.oidc.enabled) && user && (
|
||||
<div className="border-t p-4 space-y-2">
|
||||
<div className="flex items-center gap-3 rounded-lg bg-muted px-3 py-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground text-xs font-semibold">
|
||||
<User className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<p className="text-sm font-medium truncate">{user.name || user.username}</p>
|
||||
{user.email && (
|
||||
<p className="text-xs text-muted-foreground truncate">{user.email}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<Bucket[]> => {
|
||||
|
||||
@@ -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 <LoadingSpinner />;
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-2xl font-bold">Sign in to Garage UI</h1>
|
||||
<p className="text-muted-foreground mt-2">Enter your credentials to continue</p>
|
||||
</div>
|
||||
<BasicLoginForm showOIDC={true} config={config} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show only OIDC if enabled
|
||||
if (showOIDC) {
|
||||
return <OIDCLoginView />;
|
||||
}
|
||||
|
||||
// Show only admin if enabled
|
||||
if (showAdmin) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<BasicLoginForm />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Still loading config
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
@@ -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<void>;
|
||||
loginAdmin: (username: string, password: string) => Promise<void>;
|
||||
loginOIDC: () => void;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthStore>()(
|
||||
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
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -18,6 +18,10 @@ export default defineConfig({
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/auth': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
|
||||
@@ -2,8 +2,8 @@ apiVersion: v2
|
||||
name: garage-ui
|
||||
description: A Helm chart for Garage UI - Web interface for Garage S3 object storage
|
||||
type: application
|
||||
version: 0.1.2
|
||||
appVersion: "v0.0.6"
|
||||
version: 0.1.3
|
||||
appVersion: "v0.0.7"
|
||||
keywords:
|
||||
- garage
|
||||
- s3
|
||||
|
||||
+45
-16
@@ -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 <secret-name>`
|
||||
- **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
|
||||
|
||||
@@ -33,7 +33,7 @@ spec:
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8080
|
||||
containerPort: {{ .Values.config.server.port }}
|
||||
protocol: TCP
|
||||
env:
|
||||
- name: GARAGE_UI_GARAGE_ADMIN_TOKEN
|
||||
|
||||
Reference in New Issue
Block a user