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.
94 lines
2.8 KiB
Go
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))
|
|
}
|