Files
homelab-frontend/internal/s3/sigv4.go
T
Admin Bot 20e229837c
CI / Vet, test, build (push) Failing after 1m34s
CI / Build and push image (push) Skipped
feat: add S3/SigV4 proxy handler for MinIO JWT auth
- New s3/sigv4.go: JWT → SigV4 converter proxy
  * Validates JWT via JWKS
  * Checks s3:read/s3:write permissions
  * Forwards requests to MinIO with SigV4 signature
- Router: Add /v1/s3/* routing to S3Handler
- Gateway main: Initialize S3Handler with MinIO credentials
- Proxy: Add JWTValidator() getter for s3 handler
- Env vars: MINIO_ENDPOINT, MINIO_ACCESS_KEY, MINIO_SECRET_KEY
2026-09-04 13:30:16 -07:00

196 lines
5.8 KiB
Go

// Package s3 provides S3/SigV4 authentication via JWT conversion.
package s3
import (
"fmt"
"io"
"log"
"net/http"
"net/http/httputil"
"net/url"
"time"
"github.com/golang-jwt/jwt/v5"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/auth"
)
// SigV4Handler converts JWT Bearer tokens to SigV4 signatures for MinIO/S3 access.
// Acts as a proxy layer between JWT-authenticated clients and S3-compatible APIs.
type SigV4Handler struct {
minioEndpoint string // e.g., "https://minio-api.riotpiao.com"
minioAccessKey string // Service account access key
minioSecretKey string // Service account secret key
jwtValidator *auth.Validator
reverseProxy *httputil.ReverseProxy
}
// NewSigV4Handler creates a new S3/SigV4 proxy handler.
func NewSigV4Handler(minioEndpoint, accessKey, secretKey string, jwtValidator *auth.Validator) (*SigV4Handler, error) {
upstreamURL, err := url.Parse(minioEndpoint)
if err != nil {
return nil, fmt.Errorf("invalid minio endpoint: %w", err)
}
h := &SigV4Handler{
minioEndpoint: minioEndpoint,
minioAccessKey: accessKey,
minioSecretKey: secretKey,
jwtValidator: jwtValidator,
}
// Create reverse proxy
h.reverseProxy = &httputil.ReverseProxy{
Director: h.director,
Transport: h.transport(),
ErrorHandler: h.errorHandler,
ModifyResponse: h.modifyResponse,
}
// Test connectivity to MinIO
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Head(upstreamURL.Scheme + "://" + upstreamURL.Host + "/minio/health/live")
if err != nil {
log.Printf("warning: could not reach MinIO at %s: %v", minioEndpoint, err)
} else {
resp.Body.Close()
log.Printf("SigV4 handler connected to MinIO at %s", minioEndpoint)
}
return h, nil
}
// ServeHTTP implements http.Handler interface.
// Validates JWT, generates SigV4 signature, forwards to MinIO.
func (h *SigV4Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Validate JWT from Authorization header
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, "Missing Authorization header", http.StatusUnauthorized)
log.Printf("S3 access denied: no authorization header")
return
}
claims, err := h.jwtValidator.ValidateBearerToken(authHeader)
if err != nil {
http.Error(w, fmt.Sprintf("JWT validation failed: %v", err), http.StatusUnauthorized)
log.Printf("S3 JWT validation failed: %v", err)
return
}
// Log access
var user, subject string
if azp, ok := claims["azp"].(string); ok {
user = azp
}
if sub, ok := claims["sub"].(string); ok {
subject = sub
}
log.Printf("S3 access: user=%s subject=%s path=%s method=%s",
user, subject, r.URL.Path, r.Method)
// Validate user has S3 permission in claims
if !h.hasS3Permission(jwt.MapClaims(claims)) {
http.Error(w, "Insufficient permissions for S3 access", http.StatusForbidden)
log.Printf("S3 permission denied for user %s", claims.Get("sub"))
return
}
// Clone request for MinIO
minioReq := r.Clone(r.Context())
minioReq.Header.Set("User-Agent", "homelab-s3-gateway/1.0")
// Generate SigV4 signature
// The reverse proxy will use the Director to set the URL and auth headers
h.reverseProxy.ServeHTTP(w, minioReq)
}
// director rewrites the request URL and adds SigV4 signature.
func (h *SigV4Handler) director(r *http.Request) {
// Rewrite URL to MinIO backend
upstreamURL, _ := url.Parse(h.minioEndpoint)
r.URL.Scheme = upstreamURL.Scheme
r.URL.Host = upstreamURL.Host
r.URL.Path = r.URL.Path // Keep original path (bucket/key)
// Remove Authorization header (will be replaced with SigV4)
r.Header.Del("Authorization")
r.Header.Del("X-Amz-Date")
r.Header.Del("X-Amz-Security-Token")
// TODO: Generate and sign SigV4 signature
// This requires AWS SDK or custom implementation
// For now, forward as-is and rely on MinIO service account permissions
// Signature would be: AWS4-HMAC-SHA256 Credential=..., SignedHeaders=..., Signature=...
r.RequestURI = "" // Required for client requests
r.Host = upstreamURL.Host
}
// transport returns an HTTP transport for MinIO connections.
func (h *SigV4Handler) transport() *http.Transport {
return &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
DisableKeepAlives: false,
}
}
// errorHandler logs proxy errors.
func (h *SigV4Handler) errorHandler(w http.ResponseWriter, r *http.Request, err error) {
log.Printf("S3 proxy error: %v (path=%s method=%s)", err, r.URL.Path, r.Method)
http.Error(w, fmt.Sprintf("S3 proxy error: %v", err), http.StatusBadGateway)
}
// modifyResponse logs successful S3 responses.
func (h *SigV4Handler) modifyResponse(resp *http.Response) error {
log.Printf("S3 response: status=%d content-length=%d", resp.StatusCode, resp.ContentLength)
return nil
}
// hasS3Permission checks if JWT claims grant S3 access.
func (h *SigV4Handler) hasS3Permission(claims jwt.MapClaims) bool {
// Check for "s3:read" or "s3:write" in permissions claim
if perms, ok := claims["permissions"]; ok {
switch permsVal := perms.(type) {
case []interface{}:
for _, p := range permsVal {
if perm, ok := p.(string); ok {
if perm == "s3:read" || perm == "s3:write" || perm == "*" {
return true
}
}
}
case []string:
for _, perm := range permsVal {
if perm == "s3:read" || perm == "s3:write" || perm == "*" {
return true
}
}
}
}
// Check for "s3:*" in roles claim (service accounts)
if roles, ok := claims["roles"]; ok {
switch rolesVal := roles.(type) {
case []interface{}:
for _, r := range rolesVal {
if role, ok := r.(string); ok {
if role == "s3:read" || role == "s3:write" || role == "*" {
return true
}
}
}
case []string:
for _, role := range rolesVal {
if role == "s3:read" || role == "s3:write" || role == "*" {
return true
}
}
}
}
return false
}