- 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
196 lines
5.8 KiB
Go
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
|
|
}
|