Files
vikingowl 492bbb350e feat(auth): D2/D3 opaque-token session model — drop JWT
Replace HS256 JWT access tokens with two opaque 32-byte random tokens
(access + refresh), both stored as SHA-256 hashes in sessions + Valkey.

Key changes:
- GenerateOpaqueToken() replaces JWT issuance; TokenService removed
- Sessions now carry access_token_hash, refresh_token_hash, family_id,
  parent_session_id, access_expires_at, absolute_expires_at, last_used_at,
  revoked_at — per migration 000027 (updated to add access_expires_at)
- Refresh rotation is atomic (UPDATE...RETURNING); reuse detection kills
  the entire token family and returns auth.refresh_reuse_detected
- RequireAuth/OptionalAuth now take SessionLookup (Valkey→Postgres) instead
  of *TokenService; sets session_id in context alongside user_id
- last_used_at is bumped on each request, throttled to writes >60s old
- AuthConfig{AccessTTL,RefreshIdleTTL,RefreshAbsoluteTTL} replaces JWT TTL env
  vars (AUTH_ACCESS_TTL=30m, AUTH_REFRESH_IDLE_TTL=168h, AUTH_REFRESH_ABSOLUTE_TTL=720h)
- JWT_SECRET kept for AI-settings key derivation (drops from auth flow)

Forced logout on deploy (D3 behaviour); pre-launch so acceptable.
2026-04-26 12:15:57 +02:00

94 lines
2.8 KiB
Go

package middleware
import (
"context"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"marktvogt.de/backend/internal/domain/auth"
"marktvogt.de/backend/internal/pkg/apierror"
)
// SessionLookup is the subset of auth.Repository needed by the auth middleware.
// Keeping it narrow makes the middleware easy to test without a full repo mock.
type SessionLookup interface {
GetSessionByAccessHash(ctx context.Context, hash string) (auth.Session, error)
BumpLastUsedAt(ctx context.Context, id uuid.UUID) error
}
const lastUsedBumpThreshold = 60 * time.Second
// RequireAuth validates the Bearer access token via Valkey/Postgres lookup.
// On success it sets user_id, user_email, user_role, and session_id in context.
// accessTTL is used to allow the middleware to be tested independently of the session TTL
// configuration without importing the config package.
func RequireAuth(repo SessionLookup, accessTTL time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
session, ok := resolveSession(c, repo, accessTTL)
if !ok {
return
}
c.Set("user_id", session.UserID)
c.Set("user_email", session.UserEmail)
c.Set("user_role", session.UserRole)
c.Set("session_id", session.ID)
c.Next()
}
}
// OptionalAuth is like RequireAuth but allows unauthenticated requests through.
func OptionalAuth(repo SessionLookup, accessTTL time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
header := c.GetHeader("Authorization")
if header == "" || !strings.HasPrefix(header, "Bearer ") {
c.Next()
return
}
if session, ok := resolveSession(c, repo, accessTTL); ok {
c.Set("user_id", session.UserID)
c.Set("user_email", session.UserEmail)
c.Set("user_role", session.UserRole)
c.Set("session_id", session.ID)
}
c.Next()
}
}
func resolveSession(c *gin.Context, repo SessionLookup, accessTTL time.Duration) (auth.Session, bool) {
header := c.GetHeader("Authorization")
if header == "" || !strings.HasPrefix(header, "Bearer ") {
reject(c)
return auth.Session{}, false
}
token := strings.TrimPrefix(header, "Bearer ")
hash := auth.HashToken(token)
session, err := repo.GetSessionByAccessHash(c.Request.Context(), hash)
if err != nil {
reject(c)
return auth.Session{}, false
}
if session.RevokedAt != nil {
reject(c)
return auth.Session{}, false
}
// Throttled last_used_at bump — skips the write when the row was recently updated.
if time.Since(session.LastUsedAt) > lastUsedBumpThreshold {
_ = repo.BumpLastUsedAt(c.Request.Context(), session.ID)
}
_ = accessTTL // TTL is enforced by access_expires_at stored on the session row
return session, true
}
func reject(c *gin.Context) {
apiErr := apierror.Unauthorized("invalid or expired token")
c.AbortWithStatusJSON(apiErr.Status, apierror.NewResponse(apiErr))
}