Files
guestguard/internal/api/auth.go
T
Kwaku Danso 59b8781659 feat: ship Tier 1 — auth, authz, rate limits, real notifications, CSV import, billing, backups/DR, privacy
Closes every block in docs/TIER1_PLAN.md from the Claude-scope side. The
homelab / cloud setup steps (SES verification, restore drill, lawyer-
drafted ToS) remain operator-owned but are unblocked.

Block A — Authentication
- Migration 0003: password_hash, email_verified, email_verification_tokens,
  password_reset_tokens, refresh_tokens (with replaced_by family chain).
- Bcrypt hasher, HS256 JWT signer, single-use refresh tokens with rotation
  + replay-detection (revokes the family on reuse).
- /auth/signup, /login, /refresh, /logout, /verify-email,
  /forgot-password, /reset-password — enumeration-safe.
- requireAuth middleware + GET /me.
- Frontend useAuth/useApi with auto-refresh-on-401, login/signup/verify/
  forgot/reset pages, route-guard middleware.

Block B — Authorisation
- EventRepo.GetForHost; Update/Delete scoped by host_id.
- All host routes behind requireAuth + ownership; cross-tenant returns
  404 (no enumeration). ?host_id removed.
- WS auth via short-lived single-use tickets (POST /auth/ws-ticket).
- Tests: TestCrossTenantIsolation — 9 probes.

Block C — Rate limiting
- Redis sliding-window via Lua (atomic ZADD+ZCARD+PEXPIRE).
- Per-route limits matching the plan (signup IP, login IP+email, RSVP/
  access by token, events/guests/tokens by user_id).
- 429 with Retry-After header and JSON body.
- Auth lockout: 5 failed logins → account locked, only password reset
  clears it.
- Frontend: useErrMessage normalises 429 + locked messaging.

Block D — Real notifications
- Migration 0004: provider_message_id, bounce_type, complained columns
  + unsubscribes (CITEXT) suppression table.
- Branded HTML + plaintext templates for verification, reset, invitation,
  confirmation, reminder. Per-page templates avoid html/template's
  contextual-escape collisions.
- Senders: SESv2, Twilio (SMS), SMTP (Mailpit-friendly), Resend HTTP.
- PickEmailSender priority Resend > SMTP > SES > Log — system boots
  cleanly in dev with Mailpit; production flips one env var.
- Webhook endpoints (Twilio status + SES SNS) — bounces add to suppression;
  signature verification stubbed pending creds.
- Auto-send: POST /tokens publishes invitation.send; notifier renders +
  delivers via the configured backend; suppression list honoured.
- Bulk + per-row invitation flow: POST /events/{id}/guests/invitations/bulk
  returns per-guest tokens so phone-only guests can be SMS'd manually.
- Unsubscribe: signed HMAC token (no TTL) + /unsubscribe/[token] page.
- WhatsApp Option A+: wa.me click-to-chat wizard with per-guest progress
  tracking, isLikelyE164 validation, edit-from-wizard.
- Token rotate (POST /tokens/rotate) invalidates the old URL — used by
  the regenerate-link flow.
- Mailpit added to docker-compose for dev inbox.

Block E — CSV import
- Streaming parser: tolerant header detection, UTF-8 BOM + UTF-16 LE/BE
  decoding, row-level validation, 5,000-row cap.
- Strict E.164 phone validation with helpful error message.
- POST /preview + /import + GET /template; preview UI on event page;
  atomic per-batch with dedup on existing emails.

Phone capture across UI
- PhoneInput component: country picker (~50 ISO codes) + national input +
  live E.164 preview + inline length validation.
- Used in Add Guest and Edit Guest modals. Smart paste-handling extracts
  country code from full E.164 strings.

Block F — Billing (Stripe)
- Migration 0005: subscriptions table (user_id → tier/status/period_end +
  Stripe customer/sub ids). Partial unique index keeps one granting sub
  per user.
- internal/billing: Tier + Limits model (Free 1/50, Pro 10/1000, Business
  ∞/5000), Stripe SDK wrapper with IgnoreAPIVersionMismatch for newer
  account API versions.
- /billing/checkout-session, /billing/portal, /billing/status,
  /webhooks/stripe (signature-verified, lifecycle events).
- Tier enforcement: 402 on POST /events, /guests, /import with
  {error, reason, tier, used, limit, upgrade_url} body.
- Frontend: useBilling composable, /dashboard/billing page (current plan,
  usage bars, tier cards), global UpgradeModal triggered by useApi's
  402 interceptor.
- Customer portal kept for self-service cancel/payment-method changes.

Block G — Backups & DR (application side)
- Every migration has a tested .down.sql.
- TestMigrationRoundtrip applies all ups → all downs → all ups against a
  fresh container; catches asymmetric down migrations.
- cmd/restore-verify: 28-check post-restore invariant tool (schema
  presence, no orphans across 10 FK relationships, email uniqueness,
  single-active subscription, row-count snapshot).
- docs/RUNBOOK_RESTORE.md: 9-step restore procedure with RTO/RPO
  targets, drill instructions, rollback path.

Block H — Privacy compliance (application side)
- Migration 0006: deleted_at + terms_accepted_at + privacy_policy_accepted_at
  on users. Partial index on email for live-only uniqueness.
- GET /me/data-export — synchronous JSON dump (user, events, guests,
  tokens, rsvps, access_logs, notifications).
- DELETE /me — soft-delete with PII scrub + refresh-token revocation;
  re-signup with same email works.
- POST /me/accept-terms — idempotent consent recording.
- Frontend /privacy + /terms placeholder pages with substantive (pending
  legal review) copy; footer links; signup terms checkbox; TermsGateModal
  for accounts created before the rollout; export + delete buttons on
  /dashboard/billing.

Tests
- All migrations verified up/down/up.
- Integration suite: TestE2EHappyPath, TestAuthFlow, TestCrossTenantIsolation,
  TestRateLimitSignup, TestLoginLockout, TestUnsubscribeFlow,
  TestSESBounceWebhook, TestTwilioStatusWebhook, TestCsvImportFlow,
  TestCsvImportAtomicRollback, TestBulkIssueInvitations, TestBulkIssueExplicitSubset,
  TestTokenIssuePublishesInvitation, TestTokenIssueWithoutGuestEmailSkipsInvitation,
  TestGuestUpdate, TestGuestDelete, TestTokenRotate, TestSMTPSenderAgainstMailpit,
  TestFreeTierEventLimit, TestFreeTierGuestLimit, TestBusinessTierBypassesLimits,
  TestDataExport, TestDeleteMe, TestAcceptTerms, TestMigrationRoundtrip.
  Full suite runs in ~120s against real Postgres + NATS + Redis + Mailpit.
- Unit suite green across internal/auth, internal/csvimport,
  internal/notification, internal/ratelimit, internal/domain.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 23:54:22 +01:00

558 lines
17 KiB
Go

package api
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"net/mail"
"net/url"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"github.com/alchemistkay/guestguard/internal/auth"
"github.com/alchemistkay/guestguard/internal/domain"
"github.com/alchemistkay/guestguard/internal/ratelimit"
"github.com/alchemistkay/guestguard/internal/storage"
)
const refreshCookieName = "gg_refresh"
type authHandler struct {
logger *slog.Logger
users *storage.UserRepo
verifications *storage.EmailVerificationRepo
resets *storage.PasswordResetRepo
refreshes *storage.RefreshTokenRepo
hasher *auth.PasswordHasher
signer *auth.JWTSigner
emails auth.EmailSender
lockout *auth.LockoutTracker
limiter *ratelimit.Limiter
publicBaseURL string
emailVerificationTTL time.Duration
passwordResetTTL time.Duration
refreshTTL time.Duration
cookieDomain string
cookieSecure bool
}
type authHandlerDeps struct {
Logger *slog.Logger
Users *storage.UserRepo
Verifications *storage.EmailVerificationRepo
Resets *storage.PasswordResetRepo
Refreshes *storage.RefreshTokenRepo
Hasher *auth.PasswordHasher
Signer *auth.JWTSigner
Emails auth.EmailSender
Lockout *auth.LockoutTracker
Limiter *ratelimit.Limiter
PublicBaseURL string
EmailVerificationTTL time.Duration
PasswordResetTTL time.Duration
RefreshTTL time.Duration
CookieDomain string
CookieSecure bool
}
func newAuthHandler(d authHandlerDeps) *authHandler {
return &authHandler{
logger: d.Logger,
users: d.Users,
verifications: d.Verifications,
resets: d.Resets,
refreshes: d.Refreshes,
hasher: d.Hasher,
signer: d.Signer,
emails: d.Emails,
lockout: d.Lockout,
limiter: d.Limiter,
publicBaseURL: strings.TrimRight(d.PublicBaseURL, "/"),
emailVerificationTTL: d.EmailVerificationTTL,
passwordResetTTL: d.PasswordResetTTL,
refreshTTL: d.RefreshTTL,
cookieDomain: d.CookieDomain,
cookieSecure: d.CookieSecure,
}
}
// --- request/response types ---
type signupRequest struct {
Email string `json:"email"`
Name string `json:"name"`
Password string `json:"password"`
AcceptTerms bool `json:"accept_terms"`
}
type loginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
type verifyEmailRequest struct {
Token string `json:"token"`
}
type forgotPasswordRequest struct {
Email string `json:"email"`
}
type resetPasswordRequest struct {
Token string `json:"token"`
NewPassword string `json:"new_password"`
}
type authSuccess struct {
AccessToken string `json:"access_token"`
ExpiresAt time.Time `json:"expires_at"`
User *domain.User `json:"user"`
}
// --- handlers ---
// POST /auth/signup
func (h *authHandler) signup(w http.ResponseWriter, r *http.Request) {
var req signupRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
if _, err := mail.ParseAddress(req.Email); err != nil {
writeError(w, http.StatusBadRequest, "email is invalid")
return
}
if strings.TrimSpace(req.Name) == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
hash, err := h.hasher.Hash(req.Password)
if err != nil {
if errors.Is(err, auth.ErrPasswordTooShort) || errors.Is(err, auth.ErrPasswordTooLong) {
writeError(w, http.StatusBadRequest, err.Error())
return
}
h.logger.Error("hash password", "err", err)
writeError(w, http.StatusInternalServerError, "failed to create user")
return
}
u, err := h.users.Create(r.Context(), storage.CreateUserParams{
Email: req.Email,
Name: req.Name,
PasswordHash: hash,
AcceptTerms: req.AcceptTerms,
})
if err != nil {
if errors.Is(err, domain.ErrEmailTaken) {
// Don't leak which addresses are registered. Still return 201 and
// trigger a "if-you-already-have-an-account" email asynchronously
// (skipped for the stub). On real auth this should send a "you
// tried to sign up again, here's a reset link" email.
h.logger.Info("signup attempted with existing email", "email", req.Email)
writeJSON(w, http.StatusCreated, map[string]string{"status": "verification_sent"})
return
}
h.logger.Error("create user", "err", err)
writeError(w, http.StatusInternalServerError, "failed to create user")
return
}
if err := h.sendVerificationEmail(r.Context(), u); err != nil {
h.logger.Error("send verification email", "err", err, "user_id", u.ID)
// Don't fail the signup — user can request a resend.
}
writeJSON(w, http.StatusCreated, map[string]string{"status": "verification_sent"})
}
// POST /auth/login
func (h *authHandler) login(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
if req.Email == "" || req.Password == "" {
writeError(w, http.StatusBadRequest, "email and password required")
return
}
// Per-(IP + email) sliding-window — 10 per 5 minutes per the plan.
if !h.checkRate(w, r, "login", clientIP(r)+"|"+strings.ToLower(strings.TrimSpace(req.Email)),
10, 5*time.Minute) {
return
}
u, err := h.users.GetByEmail(r.Context(), req.Email)
if err != nil || u.PasswordHash == "" {
_, _ = h.lockout.RecordFailure(r.Context(), req.Email, nil)
writeError(w, http.StatusUnauthorized, "invalid email or password")
return
}
// If the account is already locked, reject before doing a bcrypt compare.
locked, _ := h.lockout.IsLocked(r.Context(), u.ID)
if locked {
writeError(w, http.StatusForbidden, "account locked — reset your password to unlock")
return
}
if err := h.hasher.Verify(u.PasswordHash, req.Password); err != nil {
locked, _ := h.lockout.RecordFailure(r.Context(), req.Email, &u.ID)
if locked {
writeError(w, http.StatusForbidden, "account locked — reset your password to unlock")
return
}
writeError(w, http.StatusUnauthorized, "invalid email or password")
return
}
if !u.EmailVerified {
writeError(w, http.StatusForbidden, "email not verified")
return
}
h.lockout.ClearOnSuccess(r.Context(), req.Email)
if err := h.issueSession(w, r, u); err != nil {
h.logger.Error("issue session", "err", err, "user_id", u.ID)
writeError(w, http.StatusInternalServerError, "failed to start session")
return
}
}
// checkRate consults the limiter (when one is configured) and writes a 429
// response if the budget is exhausted. Returns false if the caller should
// stop handling the request.
func (h *authHandler) checkRate(w http.ResponseWriter, r *http.Request, name, key string, limit int, window time.Duration) bool {
if h.limiter == nil || key == "" {
return true
}
res, err := h.limiter.Allow(r.Context(), name, key, limit, window)
if err != nil {
h.logger.Warn("ratelimit error (failing open)", "rule", name, "err", err)
return true
}
if !res.Allowed {
retry := int(res.RetryAfter.Round(time.Second).Seconds())
if retry < 1 {
retry = 1
}
w.Header().Set("Retry-After", strconv.Itoa(retry))
writeJSON(w, http.StatusTooManyRequests, map[string]any{
"error": "rate limit exceeded",
"retry_after": retry,
})
return false
}
return true
}
// POST /auth/refresh
func (h *authHandler) refresh(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(refreshCookieName)
if err != nil || cookie.Value == "" {
writeError(w, http.StatusUnauthorized, "missing refresh token")
return
}
oldHash := auth.HashOpaque(cookie.Value)
existing, err := h.refreshes.Get(r.Context(), oldHash)
if err != nil {
if errors.Is(err, domain.ErrAuthTokenNotFound) {
h.clearRefreshCookie(w)
writeError(w, http.StatusUnauthorized, "invalid refresh token")
return
}
h.logger.Error("lookup refresh", "err", err)
writeError(w, http.StatusInternalServerError, "refresh failed")
return
}
if existing.RevokedAt != nil {
// Replay of a revoked token. Revoke the family.
_ = h.refreshes.RevokeAllForUser(r.Context(), existing.UserID)
h.clearRefreshCookie(w)
writeError(w, http.StatusUnauthorized, "refresh token reused")
return
}
if time.Now().After(existing.ExpiresAt) {
h.clearRefreshCookie(w)
writeError(w, http.StatusUnauthorized, "refresh token expired")
return
}
u, err := h.users.GetByID(r.Context(), existing.UserID)
if err != nil {
h.clearRefreshCookie(w)
writeError(w, http.StatusUnauthorized, "user not found")
return
}
newRaw, newHash, err := auth.NewOpaqueToken()
if err != nil {
h.logger.Error("mint refresh", "err", err)
writeError(w, http.StatusInternalServerError, "refresh failed")
return
}
exp := time.Now().Add(h.refreshTTL)
if err := h.refreshes.Rotate(r.Context(), oldHash, storage.CreateRefreshTokenParams{
Hash: newHash,
UserID: u.ID,
ExpiresAt: exp,
UserAgent: r.UserAgent(),
IPAddress: clientIP(r),
}); err != nil {
if errors.Is(err, domain.ErrRefreshTokenRevoked) {
h.clearRefreshCookie(w)
writeError(w, http.StatusUnauthorized, "refresh token reused")
return
}
h.logger.Error("rotate refresh", "err", err)
writeError(w, http.StatusInternalServerError, "refresh failed")
return
}
access, accessExp, err := h.signer.Issue(u.ID, time.Now())
if err != nil {
h.logger.Error("sign access", "err", err)
writeError(w, http.StatusInternalServerError, "refresh failed")
return
}
h.setRefreshCookie(w, newRaw, exp)
writeJSON(w, http.StatusOK, authSuccess{
AccessToken: access,
ExpiresAt: accessExp,
User: u,
})
}
// POST /auth/logout
func (h *authHandler) logout(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(refreshCookieName)
if err == nil && cookie.Value != "" {
_ = h.refreshes.Revoke(r.Context(), auth.HashOpaque(cookie.Value))
}
h.clearRefreshCookie(w)
w.WriteHeader(http.StatusNoContent)
}
// POST /auth/verify-email
func (h *authHandler) verifyEmail(w http.ResponseWriter, r *http.Request) {
var req verifyEmailRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Token == "" {
writeError(w, http.StatusBadRequest, "token required")
return
}
uid, err := h.verifications.Consume(r.Context(), auth.HashOpaque(req.Token))
if err != nil {
switch {
case errors.Is(err, domain.ErrAuthTokenNotFound):
writeError(w, http.StatusBadRequest, "invalid token")
case errors.Is(err, domain.ErrAuthTokenConsumed):
writeError(w, http.StatusBadRequest, "token already used")
case errors.Is(err, domain.ErrAuthTokenExpired):
writeError(w, http.StatusBadRequest, "token expired")
default:
h.logger.Error("consume verification", "err", err)
writeError(w, http.StatusInternalServerError, "verification failed")
}
return
}
if err := h.users.MarkEmailVerified(r.Context(), uid); err != nil {
h.logger.Error("mark verified", "err", err, "user_id", uid)
writeError(w, http.StatusInternalServerError, "verification failed")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "verified"})
}
// POST /auth/forgot-password
func (h *authHandler) forgotPassword(w http.ResponseWriter, r *http.Request) {
var req forgotPasswordRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
if !h.checkRate(w, r, "forgot_password", clientIP(r)+"|"+strings.ToLower(strings.TrimSpace(req.Email)),
3, time.Hour) {
return
}
// Always respond 202 to avoid leaking whether the email exists.
defer func() { writeJSON(w, http.StatusAccepted, map[string]string{"status": "if_known_email_sent"}) }()
u, err := h.users.GetByEmail(r.Context(), req.Email)
if err != nil {
return
}
raw, hash, err := auth.NewOpaqueToken()
if err != nil {
h.logger.Error("mint reset", "err", err)
return
}
exp := time.Now().Add(h.passwordResetTTL)
if err := h.resets.Create(r.Context(), u.ID, hash, exp); err != nil {
h.logger.Error("persist reset", "err", err)
return
}
link := h.publicBaseURL + "/reset-password/" + url.PathEscape(raw)
if err := h.emails.SendPasswordReset(r.Context(), u.Email, u.Name, link); err != nil {
h.logger.Error("send reset email", "err", err)
}
}
// POST /auth/reset-password
func (h *authHandler) resetPassword(w http.ResponseWriter, r *http.Request) {
var req resetPasswordRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Token == "" {
writeError(w, http.StatusBadRequest, "token and new_password required")
return
}
newHash, err := h.hasher.Hash(req.NewPassword)
if err != nil {
if errors.Is(err, auth.ErrPasswordTooShort) || errors.Is(err, auth.ErrPasswordTooLong) {
writeError(w, http.StatusBadRequest, err.Error())
return
}
h.logger.Error("hash password", "err", err)
writeError(w, http.StatusInternalServerError, "reset failed")
return
}
uid, err := h.resets.Consume(r.Context(), auth.HashOpaque(req.Token))
if err != nil {
switch {
case errors.Is(err, domain.ErrAuthTokenNotFound):
writeError(w, http.StatusBadRequest, "invalid token")
case errors.Is(err, domain.ErrAuthTokenConsumed):
writeError(w, http.StatusBadRequest, "token already used")
case errors.Is(err, domain.ErrAuthTokenExpired):
writeError(w, http.StatusBadRequest, "token expired")
default:
h.logger.Error("consume reset", "err", err)
writeError(w, http.StatusInternalServerError, "reset failed")
}
return
}
if err := h.users.UpdatePasswordHash(r.Context(), uid, newHash); err != nil {
h.logger.Error("update password", "err", err, "user_id", uid)
writeError(w, http.StatusInternalServerError, "reset failed")
return
}
// Invalidate all existing sessions.
_ = h.refreshes.RevokeAllForUser(r.Context(), uid)
// Resetting the password is the canonical "unlock" path for the
// account lockout that triggers after repeated bad-credential attempts.
if u, err := h.users.GetByID(r.Context(), uid); err == nil {
_ = h.lockout.ClearForUser(r.Context(), uid, u.Email)
}
writeJSON(w, http.StatusOK, map[string]string{"status": "password_reset"})
}
// --- helpers ---
func (h *authHandler) sendVerificationEmail(ctx context.Context, u *domain.User) error {
raw, hash, err := auth.NewOpaqueToken()
if err != nil {
return err
}
if err := h.verifications.Create(ctx, u.ID, hash, time.Now().Add(h.emailVerificationTTL)); err != nil {
return err
}
link := h.publicBaseURL + "/verify-email?token=" + url.QueryEscape(raw)
return h.emails.SendVerification(ctx, u.Email, u.Name, link)
}
func (h *authHandler) issueSession(w http.ResponseWriter, r *http.Request, u *domain.User) error {
access, accessExp, err := h.signer.Issue(u.ID, time.Now())
if err != nil {
return err
}
raw, hash, err := auth.NewOpaqueToken()
if err != nil {
return err
}
refreshExp := time.Now().Add(h.refreshTTL)
if err := h.refreshes.Create(r.Context(), storage.CreateRefreshTokenParams{
Hash: hash,
UserID: u.ID,
ExpiresAt: refreshExp,
UserAgent: r.UserAgent(),
IPAddress: clientIP(r),
}); err != nil {
return err
}
h.setRefreshCookie(w, raw, refreshExp)
writeJSON(w, http.StatusOK, authSuccess{
AccessToken: access,
ExpiresAt: accessExp,
User: u,
})
return nil
}
func (h *authHandler) setRefreshCookie(w http.ResponseWriter, value string, expires time.Time) {
http.SetCookie(w, &http.Cookie{
Name: refreshCookieName,
Value: value,
Path: "/auth",
Domain: h.cookieDomain,
Expires: expires,
MaxAge: int(time.Until(expires).Seconds()),
HttpOnly: true,
Secure: h.cookieSecure,
SameSite: http.SameSiteLaxMode,
})
}
func (h *authHandler) clearRefreshCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: refreshCookieName,
Value: "",
Path: "/auth",
Domain: h.cookieDomain,
MaxAge: -1,
HttpOnly: true,
Secure: h.cookieSecure,
SameSite: http.SameSiteLaxMode,
})
}
// --- requireAuth middleware ---
type ctxKey int
const userIDCtxKey ctxKey = iota
func UserIDFromContext(ctx context.Context) (uuid.UUID, bool) {
v, ok := ctx.Value(userIDCtxKey).(uuid.UUID)
return v, ok
}
func requireAuth(signer *auth.JWTSigner) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := r.Header.Get("Authorization")
if !strings.HasPrefix(h, "Bearer ") {
writeError(w, http.StatusUnauthorized, "missing bearer token")
return
}
raw := strings.TrimSpace(strings.TrimPrefix(h, "Bearer "))
claims, err := signer.Parse(raw)
if err != nil {
if errors.Is(err, auth.ErrExpiredJWT) {
writeError(w, http.StatusUnauthorized, "token expired")
return
}
writeError(w, http.StatusUnauthorized, "invalid token")
return
}
ctx := context.WithValue(r.Context(), userIDCtxKey, claims.UserID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}