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)) }) } }