Files
silo-server/internal/api/middleware/auth.go
T

247 lines
7.4 KiB
Go

// Package middleware provides HTTP middleware for the Silo API,
// including authentication and authorization.
package middleware
import (
"context"
"encoding/json"
"net/http"
"strings"
"github.com/Silo-Server/silo-server/internal/activitylog"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/models"
)
// contextKey is an unexported type for context keys in this package.
type contextKey string
// claimsKey is the context key for storing JWT claims.
const claimsKey contextKey = "claims"
// SessionValidator checks whether a session is still valid (not revoked/expired).
type SessionValidator interface {
IsValid(ctx context.Context, sessionID string) (bool, error)
}
// TokenValidator validates a JWT token string and returns the parsed claims.
type TokenValidator interface {
ValidateToken(tokenStr string) (*auth.Claims, error)
}
// APIKeyValidator looks up an API key by its full key string.
type APIKeyValidator interface {
GetByKey(ctx context.Context, key string) (*models.APIKey, error)
UpdateLastUsed(ctx context.Context, id int64) error
}
// APIKeyUserLoader loads a user by ID for API key authentication.
type APIKeyUserLoader interface {
GetByID(ctx context.Context, id int) (*models.User, error)
}
// AuthMiddleware provides HTTP middleware for JWT-based authentication with
// session validity caching.
type AuthMiddleware struct {
tokenValidator TokenValidator
sessionValidator SessionValidator
apiKeyValidator APIKeyValidator // nil if API keys not configured
apiKeyUserLoader APIKeyUserLoader // nil if API keys not configured
}
// NewAuthMiddleware creates a new AuthMiddleware with the given token validator
// and session validator.
func NewAuthMiddleware(tv TokenValidator, sv SessionValidator, akv APIKeyValidator, akul APIKeyUserLoader) *AuthMiddleware {
return &AuthMiddleware{
tokenValidator: tv,
sessionValidator: sv,
apiKeyValidator: akv,
apiKeyUserLoader: akul,
}
}
// RequireAuth is an HTTP middleware that enforces JWT authentication.
// It extracts the Bearer token from the Authorization header, validates the
// JWT, checks session validity (with an in-memory cache), and sets the
// parsed claims in the request context for downstream handlers.
func (am *AuthMiddleware) RequireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token, ok := extractBearerToken(r)
if !ok {
writeUnauthorized(w, "Missing or malformed authorization header")
return
}
var claims *auth.Claims
if strings.HasPrefix(token, "sa_") {
// API key authentication.
if am.apiKeyValidator == nil {
writeUnauthorized(w, "API key authentication not available")
return
}
apiKey, err := am.apiKeyValidator.GetByKey(r.Context(), token)
if err != nil {
writeUnauthorized(w, "Invalid API key")
return
}
user, err := am.apiKeyUserLoader.GetByID(r.Context(), apiKey.UserID)
if err != nil {
writeUnauthorized(w, "Invalid API key")
return
}
if !user.Enabled {
writeUnauthorized(w, "User account is disabled")
return
}
// Update last_used_at asynchronously.
go func(id int64) {
_ = am.apiKeyValidator.UpdateLastUsed(context.Background(), id)
}(apiKey.ID)
claims = &auth.Claims{
UserID: user.ID,
Role: user.Role,
SessionID: "",
TokenType: auth.TokenTypeAPIKey,
APIKeyID: apiKey.ID,
RateTier: apiKey.RateTier,
}
} else {
// JWT authentication (existing flow).
var err error
claims, err = am.tokenValidator.ValidateToken(token)
if err != nil {
writeUnauthorized(w, "Invalid or expired token")
return
}
if claims.TokenType != auth.TokenTypeAccess {
writeUnauthorized(w, "Invalid or expired token")
return
}
valid, err := am.checkSession(r.Context(), claims.SessionID)
if err != nil || !valid {
writeUnauthorized(w, "Session is no longer valid")
return
}
}
// Populate activity log context if present (set by activitylog middleware upstream)
if lc := activitylog.GetLogContext(r.Context()); lc != nil {
uid := claims.UserID
lc.UserID = &uid
lc.ImpersonatorUserID = claims.ImpersonatorUserID
lc.SessionID = claims.SessionID
}
ctx := context.WithValue(r.Context(), claimsKey, claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// RequireAdmin is a standalone HTTP middleware that checks if the authenticated
// user has the "admin" role. It expects RequireAuth to have already placed
// claims in the request context.
func RequireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims := GetClaims(r.Context())
if claims == nil {
writeUnauthorized(w, "Authentication required")
return
}
if claims.Role != "admin" {
writeForbidden(w, "Admin access required")
return
}
next.ServeHTTP(w, r)
})
}
// SetClaims stores JWT claims in the context. This is useful for testing
// handlers that depend on authentication without going through the full
// middleware chain.
func SetClaims(ctx context.Context, claims *auth.Claims) context.Context {
return context.WithValue(ctx, claimsKey, claims)
}
// GetClaims retrieves the JWT claims from the context. Returns nil if no
// claims are present (caller should handle this case).
func GetClaims(ctx context.Context) *auth.Claims {
claims, ok := ctx.Value(claimsKey).(*auth.Claims)
if !ok {
return nil
}
return claims
}
// GetUserID retrieves the user ID from the JWT claims in the context.
// Returns 0 if no claims are present.
func GetUserID(ctx context.Context) int {
claims := GetClaims(ctx)
if claims == nil {
return 0
}
return claims.UserID
}
// checkSession checks whether the session is valid, using the in-memory cache
// first and falling back to the session validator on cache miss.
func (am *AuthMiddleware) checkSession(ctx context.Context, sessionID string) (bool, error) {
return am.sessionValidator.IsValid(ctx, sessionID)
}
// extractBearerToken extracts a JWT from the request. It checks (in order):
// 1. Authorization: Bearer <token> header
// 2. ?token=<token> query parameter (for native media elements that can't set headers)
func extractBearerToken(r *http.Request) (string, bool) {
// Try Authorization header first.
if header := r.Header.Get("Authorization"); header != "" {
parts := strings.SplitN(header, " ", 2)
if len(parts) == 2 && strings.EqualFold(parts[0], "bearer") {
if token := strings.TrimSpace(parts[1]); token != "" {
return token, true
}
}
}
// Fall back to query parameter (used by <video> / <audio> src URLs).
if token := r.URL.Query().Get("token"); token != "" {
return token, true
}
return "", false
}
// errorResponse is the JSON structure for error responses.
type errorResponse struct {
Error string `json:"error"`
Message string `json:"message"`
}
// writeUnauthorized writes a 401 JSON error response.
func writeUnauthorized(w http.ResponseWriter, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_ = json.NewEncoder(w).Encode(errorResponse{
Error: "unauthorized",
Message: message,
})
}
// writeForbidden writes a 403 JSON error response.
func writeForbidden(w http.ResponseWriter, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
_ = json.NewEncoder(w).Encode(errorResponse{
Error: "forbidden",
Message: message,
})
}