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>
This commit is contained in:
@@ -0,0 +1,557 @@
|
||||
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))
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user