From d8694bc272acf9b035b53b6bb350755dccacd79b Mon Sep 17 00:00:00 2001 From: Noooste <83548733+Noooste@users.noreply.github.com> Date: Mon, 8 Dec 2025 22:37:14 +0100 Subject: [PATCH] feat: enhance authentication with JWT support and clean up unused code --- backend/go.mod | 1 + backend/go.sum | 2 + backend/internal/auth/auth.go | 40 +++++- backend/internal/handlers/buckets.go | 8 +- backend/internal/handlers/cluster.go | 8 +- backend/internal/handlers/monitoring.go | 2 +- backend/internal/handlers/objects.go | 117 ++++++------------ backend/internal/handlers/users.go | 6 +- backend/internal/middleware/auth.go | 18 +-- backend/internal/models/requests.go | 1 - backend/internal/routes/routes.go | 33 +++-- backend/internal/services/s3.go | 52 ++++---- .../components/buckets/ObjectBrowserView.tsx | 1 - frontend/src/lib/api.ts | 10 -- frontend/src/pages/AccessControl.tsx | 2 - 15 files changed, 145 insertions(+), 156 deletions(-) diff --git a/backend/go.mod b/backend/go.mod index 70347c1..e3a2942 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -46,6 +46,7 @@ require ( github.com/goccy/go-json v0.10.5 // indirect github.com/gofiber/schema v1.6.0 // indirect github.com/gofiber/utils/v2 v2.0.0-rc.3 // indirect + github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/google/gopacket v1.1.19 // indirect github.com/google/uuid v1.6.0 // indirect github.com/klauspost/compress v1.18.2 // indirect diff --git a/backend/go.sum b/backend/go.sum index 8df8bac..8cff3c6 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -95,6 +95,8 @@ github.com/gofiber/utils/v2 v2.0.0-rc.2 h1:NvJTf7yMafTq16lUOJv70nr+HIOLNQcvGme/X github.com/gofiber/utils/v2 v2.0.0-rc.2/go.mod h1:gXins5o7up+BQFiubmO8aUJc/+Mhd7EKXIiAK5GBomI= github.com/gofiber/utils/v2 v2.0.0-rc.3 h1:gOL5jAEGUT2UbQkTkgMJctYt4rYewnTIt0Y7YaDATDc= github.com/gofiber/utils/v2 v2.0.0-rc.3/go.mod h1:gXins5o7up+BQFiubmO8aUJc/+Mhd7EKXIiAK5GBomI= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= diff --git a/backend/internal/auth/auth.go b/backend/internal/auth/auth.go index f4a62eb..01592e9 100644 --- a/backend/internal/auth/auth.go +++ b/backend/internal/auth/auth.go @@ -19,6 +19,7 @@ type AuthService struct { oidcProvider *oidc.Provider oidcVerifier *oidc.IDTokenVerifier oauth2Config *oauth2.Config + jwtService *JWTService } // UserInfo represents authenticated user information @@ -31,8 +32,14 @@ type UserInfo struct { // NewAuthService creates a new authentication service func NewAuthService(cfg *config.AuthConfig) (*AuthService, error) { + jwtService, err := NewJWTService() + if err != nil { + return nil, fmt.Errorf("failed to initialize JWT service: %w", err) + } + service := &AuthService{ - config: cfg, + config: cfg, + jwtService: jwtService, } // Initialize OIDC if enabled @@ -265,7 +272,6 @@ func extractRoles(claims map[string]interface{}, path string) []string { return nil } - // If this is the last part, it should be the roles array if i == len(parts)-1 { return extractStringArray(value) } @@ -301,3 +307,33 @@ func extractStringArray(value interface{}) []string { return nil } + +// GenerateStateToken generates a secure CSRF state token +func (a *AuthService) GenerateStateToken() (string, error) { + return a.jwtService.GenerateStateToken() +} + +// ValidateAndConsumeState validates and consumes a CSRF state token +func (a *AuthService) ValidateAndConsumeState(token string) bool { + return a.jwtService.ValidateAndConsumeState(token) +} + +// 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) +} + +// ValidateSessionToken validates a JWT session token and returns user info +func (a *AuthService) ValidateSessionToken(tokenString string) (*UserInfo, error) { + claims, err := a.jwtService.ValidateToken(tokenString) + if err != nil { + return nil, err + } + + return &UserInfo{ + Username: claims.Username, + Email: claims.Email, + Name: claims.Name, + Roles: claims.Roles, + }, nil +} diff --git a/backend/internal/handlers/buckets.go b/backend/internal/handlers/buckets.go index 8390a89..425eb79 100644 --- a/backend/internal/handlers/buckets.go +++ b/backend/internal/handlers/buckets.go @@ -87,11 +87,11 @@ func (h *BucketHandler) ListBuckets(c fiber.Ctx) error { // @Tags Buckets // @Accept json // @Produce json -// @Param payload body models.CreateBucketRequest true "Bucket creation payload" +// @Param payload body models.CreateBucketRequest true "Bucket creation payload" // @Success 201 {object} models.APIResponse{data=object{bucket=string,message=string}} "Bucket created successfully" -// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request body or bucket name is required" -// @Failure 409 {object} models.APIResponse{error=models.APIError} "Bucket already exists" -// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to create bucket" +// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request body or bucket name is required" +// @Failure 409 {object} models.APIResponse{error=models.APIError} "Bucket already exists" +// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to create bucket" // @Router /api/v1/buckets [post] func (h *BucketHandler) CreateBucket(c fiber.Ctx) error { ctx := c.Context() diff --git a/backend/internal/handlers/cluster.go b/backend/internal/handlers/cluster.go index 0bbede7..3ea6371 100644 --- a/backend/internal/handlers/cluster.go +++ b/backend/internal/handlers/cluster.go @@ -26,8 +26,8 @@ func NewClusterHandler(adminService *services.GarageAdminService) *ClusterHandle // @Tags Cluster // @Accept json // @Produce json -// @Success 200 {object} models.APIResponse{data=object} "Successfully retrieved cluster health" -// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to get cluster health" +// @Success 200 {object} models.APIResponse{data=object} "Successfully retrieved cluster health" +// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to get cluster health" // @Router /api/v1/cluster/health [get] func (h *ClusterHandler) GetHealth(c fiber.Ctx) error { ctx := c.Context() @@ -49,8 +49,8 @@ func (h *ClusterHandler) GetHealth(c fiber.Ctx) error { // @Tags Cluster // @Accept json // @Produce json -// @Success 200 {object} models.APIResponse{data=object} "Successfully retrieved cluster status" -// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to get cluster status" +// @Success 200 {object} models.APIResponse{data=object} "Successfully retrieved cluster status" +// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to get cluster status" // @Router /api/v1/cluster/status [get] func (h *ClusterHandler) GetStatus(c fiber.Ctx) error { ctx := c.Context() diff --git a/backend/internal/handlers/monitoring.go b/backend/internal/handlers/monitoring.go index b1bfb00..4f793ae 100644 --- a/backend/internal/handlers/monitoring.go +++ b/backend/internal/handlers/monitoring.go @@ -28,7 +28,7 @@ func NewMonitoringHandler(adminService *services.GarageAdminService, s3Service * // @Tags Monitoring // @Accept json // @Produce text/plain -// @Success 200 {string} string "System metrics in plain text format" +// @Success 200 {string} string "System metrics in plain text format" // @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to retrieve metrics" // @Router /api/v1/monitoring/metrics [get] func (h *MonitoringHandler) GetMetrics(c fiber.Ctx) error { diff --git a/backend/internal/handlers/objects.go b/backend/internal/handlers/objects.go index 25b3f72..b2414da 100644 --- a/backend/internal/handlers/objects.go +++ b/backend/internal/handlers/objects.go @@ -30,14 +30,14 @@ func NewObjectHandler(s3Service *services.S3Service) *ObjectHandler { // @Tags Objects // @Accept json // @Produce json -// @Param bucket path string true "Name of the bucket to list objects from" -// @Param prefix query string false "Filter objects by prefix" -// @Param max_keys query int false "Maximum number of objects to return (default: 100)" -// @Param continuation_token query string false "Token for pagination to retrieve next page of results" +// @Param bucket path string true "Name of the bucket to list objects from" +// @Param prefix query string false "Filter objects by prefix" +// @Param max_keys query int false "Maximum number of objects to return (default: 100)" +// @Param continuation_token query string false "Token for pagination to retrieve next page of results" // @Success 200 {object} models.APIResponse{data=models.ObjectListResponse} "Successfully retrieved list of objects and prefixes" -// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request parameters" -// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket not found" -// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to list objects" +// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request parameters" +// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket not found" +// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to list objects" // @Router /api/v1/buckets/{bucket}/objects [get] func (h *ObjectHandler) ListObjects(c fiber.Ctx) error { ctx := c.Context() @@ -80,13 +80,13 @@ func (h *ObjectHandler) ListObjects(c fiber.Ctx) error { // @Tags Objects // @Accept multipart/form-data // @Produce json -// @Param bucket path string true "Name of the bucket to upload the object to" -// @Param file formData file true "File to upload" -// @Param key formData string false "Object key (path in bucket). If not provided, the filename will be used" -// @Success 201 {object} models.APIResponse{data=models.ObjectUploadResponse} "Object uploaded successfully" -// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request parameters" -// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket not found" -// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to upload object" +// @Param bucket path string true "Name of the bucket to upload the object to" +// @Param file formData file true "File to upload" +// @Param key formData string false "Object key (path in bucket). If not provided, the filename will be used" +// @Success 201 {object} models.APIResponse{data=models.ObjectUploadResponse} "Object uploaded successfully" +// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request parameters" +// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket not found" +// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to upload object" // @Router /api/v1/buckets/{bucket}/objects [post] func (h *ObjectHandler) UploadObject(c fiber.Ctx) error { ctx := c.Context() @@ -195,12 +195,12 @@ func (h *ObjectHandler) GetObject(c fiber.Ctx) error { // @Tags Objects // @Accept json // @Produce json -// @Param bucket path string true "Name of the bucket containing the object" -// @Param key path string true "Key (path) of the object" -// @Success 200 {object} models.APIResponse{data=models.ObjectDeleteResponse} "Successfully deleted the object" -// @Failure 400 {object} models.APIResponse{error=models.APIError} "Bucket name and object key are required" -// @Failure 404 {object} models.APIResponse{error=models.APIError} "Object not found" -// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to delete object" +// @Param bucket path string true "Name of the bucket containing the object" +// @Param key path string true "Key (path) of the object" +// @Success 200 {object} models.APIResponse{data=models.ObjectDeleteResponse} "Successfully deleted the object" +// @Failure 400 {object} models.APIResponse{error=models.APIError} "Bucket name and object key are required" +// @Failure 404 {object} models.APIResponse{error=models.APIError} "Object not found" +// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to delete object" // @Router /api/v1/buckets/{bucket}/objects/{key} [delete] func (h *ObjectHandler) DeleteObject(c fiber.Ctx) error { ctx := c.Context() @@ -290,9 +290,9 @@ func (h *ObjectHandler) GetObjectMetadata(c fiber.Ctx) error { // @Tags Objects // @Accept json // @Produce json -// @Param bucket path string true "Name of the bucket containing the object" -// @Param key path string true "Key (path) of the object" -// @Param expires_in query int false "Expiration time in seconds for the pre-signed URL (default: 3600 seconds)" +// @Param bucket path string true "Name of the bucket containing the object" +// @Param key path string true "Key (path) of the object" +// @Param expires_in query int false "Expiration time in seconds for the pre-signed URL (default: 3600 seconds)" // @Success 200 {object} models.APIResponse{data=models.PresignedURLResponse} "Successfully generated pre-signed URL" // @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request parameters" // @Failure 404 {object} models.APIResponse{error=models.APIError} "Object not found" @@ -366,12 +366,12 @@ func (h *ObjectHandler) GetPresignedURL(c fiber.Ctx) error { // @Tags Objects // @Accept json // @Produce json -// @Param bucket path string true "Name of the bucket containing the objects" -// @Param request body object{keys=[]string,prefix=string} true "List of object keys to delete and optional prefix for path context" -// @Success 200 {object} models.APIResponse{data=models.ObjectDeleteMultipleResponse} "Successfully deleted the objects" -// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request parameters" -// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket not found" -// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to delete objects" +// @Param bucket path string true "Name of the bucket containing the objects" +// @Param request body object{keys=[]string,prefix=string} true "List of object keys to delete and optional prefix for path context" +// @Success 200 {object} models.APIResponse{data=models.ObjectDeleteMultipleResponse} "Successfully deleted the objects" +// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request parameters" +// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket not found" +// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to delete objects" // @Router /api/v1/buckets/{bucket}/objects/delete-multiple [post] func (h *ObjectHandler) DeleteMultipleObjects(c fiber.Ctx) error { ctx := c.Context() @@ -417,52 +417,6 @@ func (h *ObjectHandler) DeleteMultipleObjects(c fiber.Ctx) error { return c.JSON(models.SuccessResponse(response)) } -// UploadObjectStream uploads an object from request body stream (for large files) -// -// @Summary Upload object via stream -// @Description Uploads an object directly from the request body stream, suitable for large files -// @Tags Objects -// @Accept application/octet-stream -// @Produce json -// @Param bucket path string true "Name of the bucket to upload the object to" -// @Param key path string true "Object key (path in bucket)" -// @Param Content-Type header string false "Content type of the object being uploaded" -// @Param body body string true "Raw binary data of the object" -// @Success 201 {object} models.APIResponse{data=models.ObjectUploadResponse} "Object uploaded successfully" -// @Failure 400 {object} models.APIResponse{error=models.APIError} "Bucket name and object key are required" -// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket not found" -// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to upload object" -// @Router /api/v1/buckets/{bucket}/objects/{key} [put] -func (h *ObjectHandler) UploadObjectStream(c fiber.Ctx) error { - ctx := c.Context() - - // Get bucket name and object key from URL parameters - bucketName := c.Params("bucket") - key := c.Params("key") - - if bucketName == "" || key == "" { - return c.Status(fiber.StatusBadRequest).JSON( - models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name and object key are required"), - ) - } - - // Get content type from header - contentType := c.Get("Content-Type", "application/octet-stream") - - // Get request body as reader - bodyReader := c.Request().BodyStream() - - // Upload to Garage - uploadResult, err := h.s3Service.UploadObject(ctx, bucketName, key, io.NopCloser(bodyReader), contentType) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON( - models.ErrorResponse(models.ErrCodeUploadFailed, "Failed to upload object: "+err.Error()), - ) - } - - return c.Status(fiber.StatusCreated).JSON(models.SuccessResponse(uploadResult)) -} - // UploadMultipleObjects uploads multiple objects to a bucket // // @Summary Upload multiple objects to bucket @@ -470,12 +424,12 @@ func (h *ObjectHandler) UploadObjectStream(c fiber.Ctx) error { // @Tags Objects // @Accept multipart/form-data // @Produce json -// @Param bucket path string true "Name of the bucket to upload the objects to" -// @Param files formData file true "Files to upload (can be multiple)" -// @Success 201 {object} models.APIResponse{data=models.ObjectUploadMultipleResponse} "Objects uploaded successfully (including partial failures)" -// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request parameters" -// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket not found" -// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to upload objects" +// @Param bucket path string true "Name of the bucket to upload the objects to" +// @Param files formData file true "Files to upload (can be multiple)" +// @Success 201 {object} models.APIResponse{data=models.ObjectUploadMultipleResponse} "Objects uploaded successfully (including partial failures)" +// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request parameters" +// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket not found" +// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to upload objects" // @Router /api/v1/buckets/{bucket}/objects/upload-multiple [post] func (h *ObjectHandler) UploadMultipleObjects(c fiber.Ctx) error { ctx := c.Context() @@ -496,7 +450,6 @@ func (h *ObjectHandler) UploadMultipleObjects(c fiber.Ctx) error { ) } - // Get all files from the form (they should all be under "files" field) files := form.File["files"] if len(files) == 0 { return c.Status(fiber.StatusBadRequest).JSON( diff --git a/backend/internal/handlers/users.go b/backend/internal/handlers/users.go index 3319fdf..0f4babf 100644 --- a/backend/internal/handlers/users.go +++ b/backend/internal/handlers/users.go @@ -170,10 +170,10 @@ func (h *UserHandler) CreateUser(c fiber.Ctx) error { // @Description Deletes a specific user/access key // @Tags Users // @Produce json -// @Param access_key path string true "Access key of the user to delete" +// @Param access_key path string true "Access key of the user to delete" // @Success 200 {object} models.APIResponse{data=map[string]interface{}} "User deleted successfully" -// @Failure 400 {object} models.APIResponse{error=models.APIError} "Access key is required" -// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to delete user" +// @Failure 400 {object} models.APIResponse{error=models.APIError} "Access key is required" +// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to delete user" // @Router /api/v1/users/{access_key} [delete] func (h *UserHandler) DeleteUser(c fiber.Ctx) error { ctx := c.Context() diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go index 8411981..b30413d 100644 --- a/backend/internal/middleware/auth.go +++ b/backend/internal/middleware/auth.go @@ -78,18 +78,8 @@ func handleOIDCAuth(c fiber.Ctx, authService *auth.AuthService, oidcCfg *config. ) } - // In a production implementation, you would: - // 1. Validate the session token from the cookie - // 2. Look up the session in a session store (Redis, memory, etc.) - // 3. Verify the session hasn't expired - // 4. Extract user information from the session - // - // For now, we'll implement a basic token verification - // You should extend this based on your session management strategy - - // Verify ID token if it's stored in the session - ctx := c.Context() - userInfo, err := authService.VerifyIDToken(ctx, sessionCookie) + // 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"), @@ -104,8 +94,6 @@ func handleOIDCAuth(c fiber.Ctx, authService *auth.AuthService, oidcCfg *config. return c.Next() } -// RequireAuth is a simpler middleware that just checks if auth is enabled -// Use this for routes that should only be accessible when any auth is active func RequireAuth(cfg *config.AuthConfig) fiber.Handler { return func(c fiber.Ctx) error { if cfg.Mode == "none" { @@ -117,10 +105,8 @@ func RequireAuth(cfg *config.AuthConfig) fiber.Handler { } } -// RequireAdmin is middleware that checks if the user has admin role (OIDC only) func RequireAdmin(authService *auth.AuthService) fiber.Handler { return func(c fiber.Ctx) error { - // Get user info from context (set by AuthMiddleware) userInfoInterface := c.Locals("userInfo") if userInfoInterface == nil { return c.Status(fiber.StatusForbidden).JSON( diff --git a/backend/internal/models/requests.go b/backend/internal/models/requests.go index 34f05a0..ed876a2 100644 --- a/backend/internal/models/requests.go +++ b/backend/internal/models/requests.go @@ -26,7 +26,6 @@ type ListObjectsRequest struct { } // UploadObjectRequest represents metadata for an object upload -// Note: The actual file data comes from multipart form or request body type UploadObjectRequest struct { Bucket string `json:"bucket" validate:"required"` Key string `json:"key" validate:"required"` diff --git a/backend/internal/routes/routes.go b/backend/internal/routes/routes.go index 365a2e3..1657694 100644 --- a/backend/internal/routes/routes.go +++ b/backend/internal/routes/routes.go @@ -63,7 +63,6 @@ func SetupRoutes( objects.Post("/upload-multiple", objectHandler.UploadMultipleObjects) // Upload multiple objects objects.Post("/delete-multiple", objectHandler.DeleteMultipleObjects) // Delete multiple objects objects.Get("/:key", objectHandler.GetObject) // Download object - objects.Put("/:key", objectHandler.UploadObjectStream) // Upload object (stream) objects.Delete("/:key", objectHandler.DeleteObject) // Delete object objects.Head("/:key", objectHandler.GetObjectMetadata) // Get object metadata objects.Post("/:key/presign", objectHandler.GetPresignedURL) // Generate pre-signed URL @@ -103,8 +102,13 @@ func SetupRoutes( { // Login endpoint - redirects to OIDC provider authRoutes.Get("/login", func(c fiber.Ctx) error { - // Generate state token for CSRF protection - state := "random-state-token" // In production, use a secure random token + state, err := authService.GenerateStateToken() + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Failed to generate state token", + }) + } + authURL, err := authService.GetAuthorizationURL(state) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ @@ -116,6 +120,14 @@ func SetupRoutes( // Callback endpoint - handles OIDC redirect after login authRoutes.Get("/callback", func(c fiber.Ctx) error { + // Get and validate state token + state := c.Query("state") + if !authService.ValidateAndConsumeState(state) { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "Invalid or expired state token", + }) + } + // Get authorization code from query code := c.Query("code") if code == "" { @@ -149,15 +161,18 @@ func SetupRoutes( }) } - // In production, you should: - // 1. Create a session and store it in Redis/memory - // 2. Set a secure session cookie - // 3. Redirect to the frontend with the session + // Generate JWT session token + sessionToken, err := authService.GenerateSessionToken(userInfo) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Failed to create session", + }) + } - // For now, just set the ID token as a cookie (not recommended for production) + // Set JWT session token as secure cookie c.Cookie(&fiber.Cookie{ Name: cfg.Auth.OIDC.CookieName, - Value: rawIDToken, + Value: sessionToken, MaxAge: cfg.Auth.OIDC.SessionMaxAge, Secure: cfg.Auth.OIDC.CookieSecure, HTTPOnly: cfg.Auth.OIDC.CookieHTTPOnly, diff --git a/backend/internal/services/s3.go b/backend/internal/services/s3.go index ad9e99c..9a5edd8 100644 --- a/backend/internal/services/s3.go +++ b/backend/internal/services/s3.go @@ -174,37 +174,40 @@ func (s *S3Service) ListObjects(ctx context.Context, bucketName, prefix string, // Set default max keys if not specified if maxKeys <= 0 { - maxKeys = 100 + maxKeys = 1000 } - // Create list objects options + // Use ListObjectsV2 for proper pagination support opts := minio.ListObjectsOptions{ - Prefix: prefix, - Recursive: false, // Use delimiter to get folders - MaxKeys: maxKeys, + Prefix: prefix, + Recursive: false, + MaxKeys: maxKeys, + StartAfter: continuationToken, + UseV1: false, } - // Note: MinIO SDK v7 doesn't directly support continuation tokens in the same way - // We'll use the ListObjects which returns a channel - objects := make([]models.ObjectInfo, 0) - prefixes := make(map[string]bool) // Use map to deduplicate prefixes + prefixesMap := make(map[string]bool) - // List objects - objectCh := client.ListObjects(ctx, bucketName, opts) + var lastKey string + isTruncated := false + itemCount := 0 - count := 0 - for object := range objectCh { + // List objects using the channel-based API + for object := range client.ListObjects(ctx, bucketName, opts) { if object.Err != nil { return nil, fmt.Errorf("failed to list objects in bucket %s: %w", bucketName, object.Err) } // Check if this is a prefix (directory) - if object.Key[len(object.Key)-1:] == "/" && object.Size == 0 { - prefixes[object.Key] = true + if len(object.Key) > 0 && object.Key[len(object.Key)-1:] == "/" && object.Size == 0 { + prefixesMap[object.Key] = true continue } + // Track the last key for pagination + lastKey = object.Key + // Add to objects list objects = append(objects, models.ObjectInfo{ Key: object.Key, @@ -215,25 +218,32 @@ func (s *S3Service) ListObjects(ctx context.Context, bucketName, prefix string, StorageClass: object.StorageClass, }) - count++ - if count >= maxKeys { + itemCount++ + if itemCount >= maxKeys { + isTruncated = true break } } // Convert prefixes map to slice - prefixList := make([]string, 0, len(prefixes)) - for p := range prefixes { + prefixList := make([]string, 0, len(prefixesMap)) + for p := range prefixesMap { prefixList = append(prefixList, p) } + // Prepare next continuation token + var nextToken string + if isTruncated && lastKey != "" { + nextToken = lastKey + } + return &models.ObjectListResponse{ Bucket: bucketName, Objects: objects, Prefixes: prefixList, Count: len(objects), - IsTruncated: count >= maxKeys, - NextContinuationToken: "", // MinIO SDK handles this differently + IsTruncated: isTruncated, + NextContinuationToken: nextToken, }, nil } diff --git a/frontend/src/components/buckets/ObjectBrowserView.tsx b/frontend/src/components/buckets/ObjectBrowserView.tsx index 7431446..80abd7f 100644 --- a/frontend/src/components/buckets/ObjectBrowserView.tsx +++ b/frontend/src/components/buckets/ObjectBrowserView.tsx @@ -100,7 +100,6 @@ export function ObjectBrowserView({ return new Promise((resolve) => { if (item.isFile) { item.file((file: File) => { - // Add the relative path to the file object const fullPath = path + file.name; Object.defineProperty(file, 'webkitRelativePath', { value: fullPath, diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 22a42b7..f79d1ea 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -16,7 +16,6 @@ import type { StorageMetrics, } from '@/types'; -// Configure axios instance with base URL const api = axios.create({ baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8080/api', headers: { @@ -24,7 +23,6 @@ const api = axios.create({ }, }); -// Add request interceptor for authentication api.interceptors.request.use((config) => { const token = localStorage.getItem('auth-token'); if (token) { @@ -33,7 +31,6 @@ api.interceptors.request.use((config) => { return config; }); -// Add response interceptor for error handling api.interceptors.response.use( (response) => { // If response has success=false in data, treat it as an error @@ -119,7 +116,6 @@ export const bucketsApi = { }, updateSettings: async (name: string, settings: Partial): Promise => { - // TODO: Implement when backend endpoint is ready await api.patch(`/v1/buckets/${name}/settings`, settings); }, }; @@ -185,12 +181,6 @@ export const objectsApi = { }); }, - uploadStream: async (bucket: string, key: string, data: Blob | File, contentType?: string): Promise => { - await api.put(`/v1/buckets/${bucket}/objects/${encodeURIComponent(key)}`, data, { - headers: { 'Content-Type': contentType || 'application/octet-stream' }, - }); - }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any uploadMultiple: async (bucket: string, files: File[]): Promise => { const formData = new FormData(); diff --git a/frontend/src/pages/AccessControl.tsx b/frontend/src/pages/AccessControl.tsx index 2d5171e..8146de7 100644 --- a/frontend/src/pages/AccessControl.tsx +++ b/frontend/src/pages/AccessControl.tsx @@ -199,10 +199,8 @@ export function AccessControl() { try { const updates: { status?: string; expiration?: string } = {}; - // Add status change updates.status = keyStatus; - // Add expiration if set and not "never expires" if (!neverExpires && expirationDate) { updates.expiration = new Date(expirationDate).toISOString(); } else if (neverExpires) {