feat: implement admin and OIDC authentication methods; add login and user info endpoints

Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
This commit is contained in:
Noste
2025-12-21 23:42:02 +01:00
parent b49c634f17
commit 61dae6c605
23 changed files with 1187 additions and 237 deletions
+34 -27
View File
@@ -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
+24 -20
View File
@@ -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")
}
}
+152
View File
@@ -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"),
)
}
+40 -71
View File
@@ -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"),
)
+24 -24
View File
@@ -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)
+24 -10
View File
@@ -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
+71 -17
View File
@@ -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)
}
+13 -5
View File
@@ -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)
}
+104
View File
@@ -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
}