This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
package auth
|
||||
|
||||
type SigninRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
type SignupRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// SignupResponse is deliberately value-identical for every accepted
|
||||
// registration attempt (review CR-03): a populated internal identifier used
|
||||
// to distinguish a fresh account from a duplicate/reserved address, which is
|
||||
// an unauthenticated existence oracle. Ownership is established by logging
|
||||
// in; /api/me then reports the id.
|
||||
type SignupResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// bcryptHash hashes a password with cost 12 (porting guide 3.1).
|
||||
func bcryptHashImpl(password string) (string, error) {
|
||||
h, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(h), nil
|
||||
}
|
||||
|
||||
// bcryptCheck compares a password against a bcrypt hash.
|
||||
func bcryptCheckImpl(password, hash string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/andrew/go-computer/internal/config"
|
||||
"github.com/andrew/go-computer/internal/db"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const bcryptCost = 12
|
||||
|
||||
// Signup field caps (review "cap input sizes"); the request-body limit alone
|
||||
// is far too coarse for values copied into every user projection.
|
||||
const (
|
||||
maxSignupEmailLen = 254
|
||||
maxSignupNameLen = 120
|
||||
)
|
||||
|
||||
// dummyPasswordHash is bcrypt-cost-12 hash of a value no user can supply.
|
||||
// Login compares against it for unknown accounts so timing does not reveal
|
||||
// which emails are registered.
|
||||
const dummyPasswordHash = "$2a$12$OeKCcVovKAAb7gKAhWqA..yGR3eoqGQAKzSrWwLryJgAro0Vc60WW"
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
func failure(w http.ResponseWriter, status int, s string) {
|
||||
writeJSON(w, status, map[string]string{"error": s})
|
||||
}
|
||||
func decode(w http.ResponseWriter, r *http.Request, v any) bool {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
||||
if json.NewDecoder(r.Body).Decode(v) != nil {
|
||||
failure(w, 400, "Invalid JSON")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
func HandleSignup(w http.ResponseWriter, r *http.Request) {
|
||||
if !checkAuthRequest(w, r) {
|
||||
return
|
||||
}
|
||||
if os.Getenv("SIGNUP_ENABLED") == "0" {
|
||||
failure(w, 403, "Registration is disabled")
|
||||
return
|
||||
}
|
||||
var req SignupRequest
|
||||
if !decode(w, r, &req) {
|
||||
return
|
||||
}
|
||||
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
||||
if req.Email == "" || req.Password == "" {
|
||||
failure(w, 400, "Email and password are required")
|
||||
return
|
||||
}
|
||||
// Per-field caps (review "cap input sizes"): the 1 MiB body limit alone
|
||||
// lets a name or address bloat every user projection and share picker.
|
||||
if len(req.Email) > maxSignupEmailLen {
|
||||
failure(w, 400, "Invalid email")
|
||||
return
|
||||
}
|
||||
if len([]rune(req.Name)) > maxSignupNameLen {
|
||||
failure(w, 400, "Name must be at most 120 characters")
|
||||
return
|
||||
}
|
||||
if a, e := mail.ParseAddress(req.Email); e != nil || a.Address != req.Email {
|
||||
failure(w, 400, "Invalid email")
|
||||
return
|
||||
}
|
||||
if len(req.Password) < 8 {
|
||||
failure(w, 400, "Password must be at least 8 characters")
|
||||
return
|
||||
}
|
||||
if len(req.Password) > 72 {
|
||||
failure(w, 400, "Password must be at most 72 bytes")
|
||||
return
|
||||
}
|
||||
repo := db.NewUserRepo(db.FromContext(r.Context()))
|
||||
// Account enumeration (review SEC-4, CR-03): a distinct 409 — or a
|
||||
// distinct response BODY — for a registered address is an unauthenticated
|
||||
// existence oracle. Duplicate registrations (and claims of
|
||||
// operator-reserved addresses) are logged for the operator and answered
|
||||
// with the exact same 201 {"ok":true} body as a real creation; the
|
||||
// internal user id never leaves the server before authentication
|
||||
// establishes ownership (/api/me). The subsequent login then either
|
||||
// succeeds (the caller is the owner) or fails on credentials like any
|
||||
// wrong password.
|
||||
var existing bool
|
||||
if _, e := repo.FindByEmail(r.Context(), req.Email); e == nil {
|
||||
existing = true
|
||||
} else if !errors.Is(e, db.ErrNotFound) {
|
||||
failure(w, 500, "Database unavailable")
|
||||
return
|
||||
}
|
||||
if config.IsAdminEmail(req.Email) {
|
||||
log.Printf("signup: rejected claim of operator-reserved address %s", req.Email)
|
||||
existing = true
|
||||
}
|
||||
if existing {
|
||||
log.Printf("signup: duplicate registration attempt")
|
||||
// Same bcrypt cost as real registrations, so timing does not
|
||||
// re-open the existence oracle.
|
||||
_, _ = bcryptHashImpl(req.Password)
|
||||
writeJSON(w, 201, SignupResponse{true})
|
||||
return
|
||||
}
|
||||
hash, e := bcryptHashImpl(req.Password)
|
||||
if e != nil {
|
||||
failure(w, 500, "Unable to hash password")
|
||||
return
|
||||
}
|
||||
if req.Name == "" {
|
||||
req.Name = strings.SplitN(req.Email, "@", 2)[0]
|
||||
}
|
||||
if _, e := repo.Create(r.Context(), &db.NewUserInput{ID: db.NewID(), Name: req.Name, Email: req.Email, Password: hash, Role: "USER"}); e != nil {
|
||||
var pg *pgconn.PgError
|
||||
if errors.As(e, &pg) && pg.Code == "23505" {
|
||||
// Racing creation: still indistinguishable.
|
||||
writeJSON(w, 201, SignupResponse{true})
|
||||
return
|
||||
}
|
||||
failure(w, 500, "Unable to create user")
|
||||
return
|
||||
}
|
||||
writeJSON(w, 201, SignupResponse{true})
|
||||
}
|
||||
|
||||
// checkAuthRequest enforces the JSON Content-Type and same-origin guards on
|
||||
// the unauthenticated auth endpoints. Applied to login and signup alike
|
||||
// (review SEC-4): both are reachable directly on the Go API when the Next
|
||||
// proxy's own Origin checks are bypassed.
|
||||
func checkAuthRequest(w http.ResponseWriter, r *http.Request) bool {
|
||||
if strings.ToLower(strings.TrimSpace(strings.SplitN(r.Header.Get("Content-Type"), ";", 2)[0])) != "application/json" {
|
||||
failure(w, 415, "Content-Type must be application/json")
|
||||
return false
|
||||
}
|
||||
if origin := r.Header.Get("Origin"); origin != "" {
|
||||
allowed, err := url.Parse(config.Load().NextAuthURL)
|
||||
scheme := "http"
|
||||
// Behind a TLS-terminating proxy r.TLS is nil; X-Forwarded-Proto only
|
||||
// affects the scheme half — the host must still match Origin, so this
|
||||
// grants a spoofer nothing they control end-to-end.
|
||||
if r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") {
|
||||
scheme = "https"
|
||||
}
|
||||
if origin != scheme+"://"+r.Host && (err != nil || allowed.Host == "" || origin != allowed.Scheme+"://"+allowed.Host) {
|
||||
failure(w, 403, "Invalid request origin")
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
func HandleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if !checkAuthRequest(w, r) {
|
||||
return
|
||||
}
|
||||
var req SigninRequest
|
||||
if !decode(w, r, &req) {
|
||||
return
|
||||
}
|
||||
email := strings.ToLower(strings.TrimSpace(req.Email))
|
||||
// Review SEC-1: the failure lockout applies to every peer, loopback
|
||||
// included — the exemption previously removed all guessing protection
|
||||
// from same-host deployments.
|
||||
if retry, locked := loginFailures.locked(EffectiveClientIP(r)+"|"+email, time.Now()); locked {
|
||||
tooManyRequests(w, retry)
|
||||
return
|
||||
}
|
||||
u, e := db.NewUserRepo(db.FromContext(r.Context())).FindByEmail(r.Context(), email)
|
||||
if e != nil && !errors.Is(e, db.ErrNotFound) {
|
||||
failure(w, 500, "Database unavailable")
|
||||
return
|
||||
}
|
||||
// Unknown or password-less accounts still pay a bcrypt comparison so
|
||||
// response latency does not reveal which emails are registered.
|
||||
ok := false
|
||||
if e == nil && u.Password != nil {
|
||||
ok = bcryptCheckImpl(req.Password, *u.Password)
|
||||
} else {
|
||||
bcryptCheckImpl(req.Password, dummyPasswordHash)
|
||||
}
|
||||
if !ok {
|
||||
loginFailures.noteFailure(EffectiveClientIP(r)+"|"+email, time.Now())
|
||||
failure(w, 401, "Invalid credentials")
|
||||
return
|
||||
}
|
||||
loginFailures.noteSuccess(EffectiveClientIP(r) + "|" + email)
|
||||
if e = WriteSessionCookie(w, SessionUser{ID: u.ID, Role: u.Role, Ver: u.TokenVersion}, config.Load().Secret()); e != nil {
|
||||
failure(w, 500, "Session unavailable")
|
||||
return
|
||||
}
|
||||
// tokenVersion rides along so the Next server can embed it in its session
|
||||
// JWT and in every assertion it signs (review SEC-02); a revocation bump
|
||||
// then invalidates NextAuth-backed sessions on their next backend call.
|
||||
writeJSON(w, 200, map[string]any{"ok": true, "user": identity(u), "tokenVersion": u.TokenVersion})
|
||||
}
|
||||
func HandleAuth(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/auth/session":
|
||||
su, e := ReadSession(r, config.Load().Secret())
|
||||
if e != nil {
|
||||
writeJSON(w, 200, nil)
|
||||
return
|
||||
}
|
||||
u, e := db.NewUserRepo(db.FromContext(r.Context())).FindByID(r.Context(), su.ID)
|
||||
if e != nil {
|
||||
// Session error classification (review follow-up): distinguish
|
||||
// authentication absence from backend degradation, exactly like
|
||||
// AuthMiddleware — an unreachable database must answer 503 so
|
||||
// client retry logic and monitoring see the difference instead of
|
||||
// a degraded backend masquerading as an ordinary logout.
|
||||
if errors.Is(e, db.ErrNotFound) {
|
||||
writeJSON(w, 200, nil)
|
||||
} else {
|
||||
failure(w, 503, "Service unavailable")
|
||||
}
|
||||
return
|
||||
}
|
||||
if su.Ver > 0 && su.Ver != u.TokenVersion {
|
||||
writeJSON(w, 200, nil)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"user": identity(u)})
|
||||
case "/api/auth/signout":
|
||||
if r.Method != "POST" {
|
||||
failure(w, 405, "Use POST to sign out")
|
||||
return
|
||||
}
|
||||
// Revoke every cookie JWT issued for this user, not just this
|
||||
// browser's copy: signout means "sign out everywhere".
|
||||
if su, e := ReadSession(r, config.Load().Secret()); e == nil {
|
||||
if e := db.NewUserRepo(db.FromContext(r.Context())).BumpTokenVersion(r.Context(), su.ID); e != nil {
|
||||
writeJSON(w, 500, map[string]string{"error": "Unable to sign out"})
|
||||
return
|
||||
}
|
||||
}
|
||||
ClearSessionCookie(w)
|
||||
// Review SEC-6: sign-out ends impersonation too — otherwise the next
|
||||
// admin login on that browser would silently resume view-as.
|
||||
http.SetCookie(w, &http.Cookie{Name: ViewAsCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode})
|
||||
writeJSON(w, 200, map[string]bool{"ok": true})
|
||||
default:
|
||||
failure(w, 404, "Unknown authentication endpoint")
|
||||
}
|
||||
}
|
||||
func HandleMe(w http.ResponseWriter, r *http.Request) {
|
||||
s, _ := SessionFromContext(r.Context())
|
||||
var viewing any
|
||||
if s.ID != s.RealID {
|
||||
viewing = map[string]any{"id": s.ID, "name": s.Name, "email": s.Email}
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"user": s.RealUser, "effectiveUser": map[string]any{"id": s.ID, "name": s.Name, "email": s.Email, "role": s.Role}, "isAdmin": s.RealRole == "ADMIN", "viewingAs": viewing})
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"github.com/andrew/go-computer/internal/config"
|
||||
"github.com/andrew/go-computer/internal/db"
|
||||
"github.com/andrew/go-computer/internal/models"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const ViewAsCookie = "computer_view_as"
|
||||
|
||||
type SessionUser struct {
|
||||
ID, Role, RealID, RealRole string
|
||||
Name, Email *string
|
||||
RealUser map[string]any
|
||||
// Ver is the tokenVersion embedded in the credential (0 = no claim:
|
||||
// pre-revocation legacy cookies only — assertions must carry it), checked
|
||||
// against the user row so signout revokes cookie sessions AND NextAuth
|
||||
// assertions alike (review SEC-02).
|
||||
Ver int
|
||||
}
|
||||
|
||||
func identity(u *models.User) map[string]any {
|
||||
return map[string]any{"id": u.ID, "name": u.Name, "email": u.Email, "image": u.Image, "role": u.Role}
|
||||
}
|
||||
func AuthMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
s, e := ReadSession(r, config.Load().Secret())
|
||||
if e != nil {
|
||||
failure(w, 401, "Unauthorized")
|
||||
return
|
||||
}
|
||||
repo := db.NewUserRepo(db.FromContext(r.Context()))
|
||||
u, e := repo.FindByID(r.Context(), s.ID)
|
||||
if e != nil {
|
||||
// Review CORR-03: a missing user is a credential problem (401),
|
||||
// but backend degradation must not masquerade as one — surface it
|
||||
// as 503 so client retry logic and monitoring see the difference.
|
||||
if errors.Is(e, db.ErrNotFound) {
|
||||
failure(w, 401, "Unauthorized")
|
||||
} else {
|
||||
log.Printf("auth: user lookup failed for session %s: %v", s.ID, e)
|
||||
failure(w, 503, "Service unavailable")
|
||||
}
|
||||
return
|
||||
}
|
||||
// Revoked session: the credential's tokenVersion predates a signout
|
||||
// bump. Applies to cookie sessions and NextAuth assertions alike.
|
||||
if s.Ver > 0 && s.Ver != u.TokenVersion {
|
||||
failure(w, 401, "Unauthorized")
|
||||
return
|
||||
}
|
||||
// Promotion via ADMIN_EMAILS is persistent by design: removing an
|
||||
// address from the env var later does NOT demote the account (use the
|
||||
// Admin screen). Silent demotion on env drift would be worse than an
|
||||
// extra explicit revocation step.
|
||||
//
|
||||
// Code review SEC-04 flagged that this re-promotes an account demoted
|
||||
// via the Admin screen while its address remains in ADMIN_EMAILS. The
|
||||
// env-driven promotion is a deliberate architectural decision here —
|
||||
// demoting an operator account means editing ADMIN_EMAILS too; do not
|
||||
// "fix" this silently.
|
||||
if u.Role != "ADMIN" && u.Email != nil && u.EmailVerified != nil && config.IsAdminEmail(*u.Email) {
|
||||
if e = repo.SetRole(r.Context(), u.ID, "ADMIN"); e != nil {
|
||||
failure(w, 500, "Unable to resolve role")
|
||||
return
|
||||
}
|
||||
u.Role = "ADMIN"
|
||||
}
|
||||
s.RealID, s.RealRole, s.RealUser = u.ID, u.Role, identity(u)
|
||||
s.ID, s.Role, s.Name, s.Email = u.ID, u.Role, u.Name, u.Email
|
||||
if u.Role == "ADMIN" {
|
||||
if c, e := r.Cookie(ViewAsCookie); e == nil && c.Value != "" && c.Value != u.ID {
|
||||
if target, e := repo.FindByID(r.Context(), c.Value); e == nil {
|
||||
s.ID, s.Role, s.Name, s.Email = target.ID, target.Role, target.Name, target.Email
|
||||
// Review SEC-6: impersonation is audited. Log at most once
|
||||
// per admin/target/method combination per process so a
|
||||
// polling UI does not flood the log.
|
||||
auditViewAs(u.ID, target.ID, r.Method)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Review SEC-6: view-as is read-only. Mutations while impersonating
|
||||
// are attributed to the target user, so writes must come from the
|
||||
// admin's own identity — exit view-as first. The view-as endpoint
|
||||
// itself is exempt: it controls the impersonation session and must
|
||||
// be writable to both start and stop the session.
|
||||
if s.ID != s.RealID && r.Method != http.MethodGet && r.Method != http.MethodHead && r.URL.Path != "/api/admin/view-as" {
|
||||
failure(w, 403, "Read-only while viewing as another user")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(WithSession(r.Context(), s)))
|
||||
})
|
||||
}
|
||||
|
||||
type sessionKey struct{}
|
||||
|
||||
// auditViewAs logs each distinct (admin, target, method) impersonation
|
||||
// observation once per process (review SEC-6). Ids only — never names,
|
||||
// emails, or request payloads.
|
||||
var (
|
||||
auditViewAsMu sync.Mutex
|
||||
auditViewAsLog = map[string]bool{}
|
||||
)
|
||||
|
||||
func auditViewAs(adminID, targetID, method string) {
|
||||
key := adminID + "|" + targetID + "|" + method
|
||||
auditViewAsMu.Lock()
|
||||
defer auditViewAsMu.Unlock()
|
||||
if auditViewAsLog[key] {
|
||||
return
|
||||
}
|
||||
auditViewAsLog[key] = true
|
||||
log.Printf("audit: admin %s viewing as %s (%s)", adminID, targetID, method)
|
||||
}
|
||||
|
||||
func WithSession(ctx context.Context, s *SessionUser) context.Context {
|
||||
return context.WithValue(ctx, sessionKey{}, s)
|
||||
}
|
||||
func SessionFromContext(ctx context.Context) (*SessionUser, bool) {
|
||||
s, ok := ctx.Value(sessionKey{}).(*SessionUser)
|
||||
return s, ok
|
||||
}
|
||||
func RequireAdmin(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
s, ok := SessionFromContext(r.Context())
|
||||
if !ok || s.RealRole != "ADMIN" {
|
||||
failure(w, 403, "Forbidden")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
// Abuse protection for the unauthenticated auth endpoints: a fixed-window
|
||||
// counter per IP plus consecutive-failure lockout per (IP, email) on login.
|
||||
//
|
||||
// Review SEC-1: the login endpoint and its failure lockout apply to EVERY
|
||||
// peer, including loopback. In same-host non-Docker deployments the Next
|
||||
// server reaches Go over localhost, and exempting that path removed all
|
||||
// throttling from real browser logins (unlimited online password guessing).
|
||||
// Signup keeps the loopback exemption: it exists for same-host consumers of
|
||||
// the 127.0.0.1 binding, and a forwarded identity never earns it (SEC-2).
|
||||
//
|
||||
// Review AUTH-RATE: keying strictly on the direct peer made every browser
|
||||
// behind the Next proxy share one bucket (in Compose, Go's peer is always
|
||||
// the web container). The trusted Next server therefore forwards the browser
|
||||
// IP it observed, signed with HMAC-SHA256(secret, ip|unix_ts) under the
|
||||
// shared auth secret (web/lib/api.ts, web/auth.ts), with a 60s replay
|
||||
// window. Go only accepts the forwarded identity when the signature and
|
||||
// timestamp verify, so hostile clients cannot forge it; unsigned or invalid
|
||||
// values fall back to the direct peer. Next forwards only X-Real-IP (never
|
||||
// X-Forwarded-For) and only when TRUSTED_PROXY=1 pins the proxy that
|
||||
// overwrites that header — the shipped nginx template does.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"github.com/andrew/go-computer/internal/config"
|
||||
"net"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
loginIPLimit = 20 // logins per IP per window
|
||||
loginIPWindow = time.Minute //
|
||||
signupIPLimit = 10 // signups per IP per window
|
||||
signupIPWindow = time.Hour //
|
||||
failureThreshold = 5 // consecutive failures before lockout
|
||||
failureLockout = 15 * time.Minute
|
||||
failureWindow = time.Hour // failures older than this are forgotten
|
||||
|
||||
// limiterMaxEntries is the soft cap per store. Review SEC-03: the failure
|
||||
// tracker previously never evicted sub-threshold entries (zero until), so
|
||||
// cycling unique emails grew the map without bound. Every mutator now
|
||||
// sweeps once the cap is exceeded.
|
||||
limiterMaxEntries = 4096
|
||||
)
|
||||
|
||||
type counter struct {
|
||||
count int
|
||||
reset time.Time
|
||||
until time.Time // lockout expiry (failure tracker only)
|
||||
}
|
||||
|
||||
type limiterStore struct {
|
||||
mu sync.Mutex
|
||||
m map[string]*counter
|
||||
}
|
||||
|
||||
func newLimiterStore() *limiterStore { return &limiterStore{m: map[string]*counter{}} }
|
||||
|
||||
// sweep bounds memory (review SEC-03). Callers hold s.mu. Pass 1 drops
|
||||
// entries whose window — and lockout, for the failure tracker — have expired.
|
||||
// If a burst of still-live keys (e.g. unique-email login failures inside the
|
||||
// failure window) keeps the store oversized, pass 2 evicts the oldest-tracked
|
||||
// non-locked entries until the cap holds again; active lockouts are left
|
||||
// alone so an eviction can never shorten a lockout.
|
||||
func (s *limiterStore) sweep(now time.Time) {
|
||||
if len(s.m) <= limiterMaxEntries {
|
||||
return
|
||||
}
|
||||
for k, c := range s.m {
|
||||
if now.After(c.reset) && (c.until.IsZero() || now.After(c.until)) {
|
||||
delete(s.m, k)
|
||||
}
|
||||
}
|
||||
if len(s.m) <= limiterMaxEntries {
|
||||
return
|
||||
}
|
||||
keys := make([]string, 0, len(s.m))
|
||||
for k, c := range s.m {
|
||||
if c.until.IsZero() || now.After(c.until) {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool { return s.m[keys[i]].reset.Before(s.m[keys[j]].reset) })
|
||||
for _, k := range keys[:min(len(keys), len(s.m)-limiterMaxEntries)] {
|
||||
delete(s.m, k)
|
||||
}
|
||||
}
|
||||
|
||||
// allow reports whether key is within limit for the window starting at first
|
||||
// use. Expiry returns the remaining time until the window resets so callers
|
||||
// can send an accurate Retry-After header (review ERR-3).
|
||||
func (s *limiterStore) allow(key string, limit int, window time.Duration, now time.Time) (bool, time.Duration) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.sweep(now)
|
||||
c := s.m[key]
|
||||
if c == nil || now.After(c.reset) {
|
||||
s.m[key] = &counter{count: 1, reset: now.Add(window)}
|
||||
return true, 0
|
||||
}
|
||||
c.count++
|
||||
retry := c.reset.Sub(now)
|
||||
if retry < 0 {
|
||||
retry = 0
|
||||
}
|
||||
return c.count <= limit, retry.Truncate(time.Second) + time.Second
|
||||
}
|
||||
|
||||
func (s *limiterStore) locked(key string, now time.Time) (time.Duration, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.sweep(now)
|
||||
c := s.m[key]
|
||||
if c == nil || c.until.IsZero() {
|
||||
return 0, false
|
||||
}
|
||||
if now.After(c.until) {
|
||||
delete(s.m, key)
|
||||
return 0, false
|
||||
}
|
||||
// Consistent clock (review SEC-03): derive the retry delay from the
|
||||
// supplied now rather than mixing in time.Until.
|
||||
return c.until.Sub(now).Truncate(time.Second) + time.Second, true
|
||||
}
|
||||
|
||||
func (s *limiterStore) noteFailure(key string, now time.Time) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.sweep(now)
|
||||
c := s.m[key]
|
||||
if c == nil || now.After(c.reset) {
|
||||
c = &counter{reset: now.Add(failureWindow)}
|
||||
s.m[key] = c
|
||||
}
|
||||
c.count++
|
||||
if c.count >= failureThreshold {
|
||||
c.until = now.Add(failureLockout)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *limiterStore) noteSuccess(key string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.m, key)
|
||||
}
|
||||
|
||||
var (
|
||||
loginIPs = newLimiterStore()
|
||||
signupIPs = newLimiterStore()
|
||||
loginFailures = newLimiterStore()
|
||||
)
|
||||
|
||||
// ResetRateLimits clears all limiter state. Tests only: the stores are
|
||||
// process-global, and integration harnesses reset them per test server so
|
||||
// sequential tests do not inherit each other's buckets (the login endpoint
|
||||
// has no loopback exemption since review SEC-1).
|
||||
func ResetRateLimits() {
|
||||
for _, s := range []*limiterStore{loginIPs, signupIPs, loginFailures} {
|
||||
s.mu.Lock()
|
||||
s.m = map[string]*counter{}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// resetRateLimits clears all limiter state. Tests only.
|
||||
func resetRateLimits() { ResetRateLimits() }
|
||||
|
||||
func clientIP(r *http.Request) string {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// Headers carrying the proxy-attested browser IP (review AUTH-RATE). The
|
||||
// signature is HMAC-SHA256(secret, ip|unix_ts) in hex, computed by the
|
||||
// trusted Next server; anything else falls back to the direct peer address.
|
||||
// The timestamp bounds replay of a captured header pair to ~60s (review
|
||||
// SEC-2). Next only forwards an IP when TRUSTED_PROXY=1 pins the boundary
|
||||
// that overwrites X-Real-IP (the shipped nginx template does).
|
||||
const (
|
||||
headerClientIP = "X-Computer-Client-Ip"
|
||||
headerClientTs = "X-Computer-Client-Ts"
|
||||
headerClientSig = "X-Computer-Client-Sig"
|
||||
// forwardSkew bounds clock skew and replay of the signed identity.
|
||||
forwardSkew = 60 * time.Second
|
||||
)
|
||||
|
||||
func SignClientIP(secret, ip string, ts int64) string {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(ip + "|" + strconv.FormatInt(ts, 10)))
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func signedForwardIP(r *http.Request) (string, bool) {
|
||||
ip := r.Header.Get(headerClientIP)
|
||||
sig := r.Header.Get(headerClientSig)
|
||||
tsRaw := r.Header.Get(headerClientTs)
|
||||
if ip == "" || sig == "" || tsRaw == "" || net.ParseIP(ip) == nil {
|
||||
return "", false
|
||||
}
|
||||
ts, err := strconv.ParseInt(tsRaw, 10, 64)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
if ts > now+int64(forwardSkew/time.Second) || ts < now-int64(forwardSkew/time.Second) {
|
||||
return "", false
|
||||
}
|
||||
secret := config.Load().Secret()
|
||||
if secret == "" {
|
||||
return "", false
|
||||
}
|
||||
if !hmac.Equal([]byte(SignClientIP(secret, ip, ts)), []byte(sig)) {
|
||||
return "", false
|
||||
}
|
||||
return ip, true
|
||||
}
|
||||
|
||||
// EffectiveClientIP is the identity rate limiting buckets on: the signed
|
||||
// forwarded browser IP when the trusted proxy attested one, else the direct
|
||||
// peer. Never trust arbitrary forwarded headers here.
|
||||
func EffectiveClientIP(r *http.Request) string {
|
||||
if ip, ok := signedForwardIP(r); ok {
|
||||
return ip
|
||||
}
|
||||
return clientIP(r)
|
||||
}
|
||||
|
||||
// isLoopback reports whether the DIRECT peer is localhost. A forwarded
|
||||
// identity — even a signed loopback one — never earns the exemption (review
|
||||
// SEC-2): the exemption exists for same-host consumers of the 127.0.0.1
|
||||
// binding, not for anything arriving through a proxy. Plain X-Forwarded-For
|
||||
// is client-controlled and never trusted.
|
||||
func isLoopback(r *http.Request) bool {
|
||||
if _, forwarded := signedForwardIP(r); forwarded {
|
||||
return false
|
||||
}
|
||||
ip := net.ParseIP(clientIP(r))
|
||||
return ip != nil && ip.IsLoopback()
|
||||
}
|
||||
|
||||
func tooManyRequests(w http.ResponseWriter, retry time.Duration) {
|
||||
w.Header().Set("Retry-After", strconv.Itoa(int(retry.Seconds())))
|
||||
failure(w, http.StatusTooManyRequests, "Too many attempts, slow down")
|
||||
}
|
||||
|
||||
// RateLimitLogin caps login attempts per effective client IP for every peer
|
||||
// (review SEC-1: loopback is NOT exempt on the login endpoint).
|
||||
func RateLimitLogin(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// Review ERR-3: Retry-After carries the remaining window time, not
|
||||
// the full window.
|
||||
if ok, retry := loginIPs.allow(EffectiveClientIP(r), loginIPLimit, loginIPWindow, time.Now()); !ok {
|
||||
tooManyRequests(w, retry)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// RateLimitSignup caps account creation per effective client IP for
|
||||
// non-loopback peers.
|
||||
func RateLimitSignup(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !isLoopback(r) {
|
||||
if ok, retry := signupIPs.allow(EffectiveClientIP(r), signupIPLimit, signupIPWindow, time.Now()); !ok {
|
||||
tooManyRequests(w, retry)
|
||||
return
|
||||
}
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// NewLimiterStore creates a new in-memory rate limiter store. Exported so
|
||||
// callers (e.g. public endpoints) can have independent buckets.
|
||||
func NewLimiterStore() *limiterStore { return newLimiterStore() }
|
||||
|
||||
// Limiter wraps a handler with a simple per-IP fixed-window rate limit.
|
||||
// Uses the direct peer IP (no forwarded headers) and does not exempt
|
||||
// loopback — public endpoints apply to every peer.
|
||||
func Limiter(s *limiterStore, limit int, window time.Duration) func(http.HandlerFunc) http.HandlerFunc {
|
||||
return func(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ip := clientIP(r)
|
||||
if ok, retry := s.allow(ip, limit, window, time.Now()); !ok {
|
||||
tooManyRequests(w, retry)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLimiterStoreWindow(t *testing.T) {
|
||||
s := newLimiterStore()
|
||||
now := time.Now()
|
||||
for i := 0; i < 5; i++ {
|
||||
if ok, _ := s.allow("ip", 5, time.Minute, now.Add(time.Duration(i)*time.Second)); !ok {
|
||||
t.Fatalf("request %d within limit rejected", i+1)
|
||||
}
|
||||
}
|
||||
// Review ERR-3: the rejection carries the remaining window time.
|
||||
ok, retry := s.allow("ip", 5, time.Minute, now.Add(10*time.Second))
|
||||
if ok {
|
||||
t.Fatal("request over limit accepted")
|
||||
}
|
||||
if retry <= 0 || retry > time.Minute {
|
||||
t.Fatalf("retry-after %v outside the remaining window", retry)
|
||||
}
|
||||
if ok, _ := s.allow("ip", 5, time.Minute, now.Add(61*time.Second)); !ok {
|
||||
t.Fatal("request after window reset rejected")
|
||||
}
|
||||
if ok, _ := s.allow("other", 5, time.Minute, now); !ok {
|
||||
t.Fatal("separate key shares the limit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailureLockout(t *testing.T) {
|
||||
s := newLimiterStore()
|
||||
now := time.Now()
|
||||
for i := 0; i < failureThreshold-1; i++ {
|
||||
s.noteFailure("ip|user", now.Add(time.Duration(i)*time.Second))
|
||||
}
|
||||
if _, locked := s.locked("ip|user", now.Add(time.Minute)); locked {
|
||||
t.Fatal("locked below threshold")
|
||||
}
|
||||
s.noteFailure("ip|user", now.Add(time.Minute))
|
||||
retry, locked := s.locked("ip|user", now.Add(2*time.Minute))
|
||||
if !locked || retry <= 0 {
|
||||
t.Fatalf("not locked after %d failures: %v %v", failureThreshold, retry, locked)
|
||||
}
|
||||
s.noteSuccess("ip|user")
|
||||
if _, locked := s.locked("ip|user", now.Add(2*time.Minute)); locked {
|
||||
t.Fatal("successful login did not clear the lockout")
|
||||
}
|
||||
if _, locked := s.locked("ip|user", now.Add(failureLockout+time.Hour)); locked {
|
||||
t.Fatal("lockout did not expire")
|
||||
}
|
||||
}
|
||||
|
||||
// Review SEC-03: sub-threshold failure entries (zero until) must not
|
||||
// accumulate without bound when an attacker cycles unique emails.
|
||||
func TestFailureTrackerEviction(t *testing.T) {
|
||||
s := newLimiterStore()
|
||||
now := time.Now()
|
||||
// Entries tracked two hours ago are fully expired (failureWindow passed).
|
||||
old := now.Add(-2 * time.Hour)
|
||||
for i := 0; i < limiterMaxEntries+500; i++ {
|
||||
s.noteFailure(fmt.Sprintf("ip|user%d", i), old)
|
||||
}
|
||||
if len(s.m) > limiterMaxEntries+1 {
|
||||
t.Fatalf("live-key burst exceeded the soft cap: %d entries", len(s.m))
|
||||
}
|
||||
// A later call sweeps the fully expired burst.
|
||||
s.noteFailure("ip|fresh", now)
|
||||
if len(s.m) != 1 {
|
||||
t.Fatalf("expired failure entries not evicted: %d remain", len(s.m))
|
||||
}
|
||||
// Active lockouts survive eviction from an oversized store.
|
||||
s2 := newLimiterStore()
|
||||
for i := 0; i < failureThreshold; i++ {
|
||||
s2.noteFailure("ip|locked", now.Add(time.Duration(i)*time.Second))
|
||||
}
|
||||
for i := 0; i < limiterMaxEntries+500; i++ {
|
||||
s2.noteFailure(fmt.Sprintf("ip|user%d", i), now)
|
||||
}
|
||||
if _, locked := s2.locked("ip|locked", now.Add(time.Minute)); !locked {
|
||||
t.Fatal("active lockout evicted by the memory sweep")
|
||||
}
|
||||
}
|
||||
|
||||
func dummyHandler(w http.ResponseWriter, r *http.Request) { w.WriteHeader(204) }
|
||||
|
||||
func TestRateLimitMiddleware(t *testing.T) {
|
||||
resetRateLimits()
|
||||
t.Cleanup(resetRateLimits)
|
||||
|
||||
// Review SEC-1: loopback is NOT exempt on the login endpoint — same-host
|
||||
// deployments must keep throttling for real browser logins.
|
||||
loop := httptest.NewRequest("POST", "/api/auth/login", nil)
|
||||
loop.RemoteAddr = "127.0.0.1:54321"
|
||||
var loopLast *httptest.ResponseRecorder
|
||||
for i := 0; i < loginIPLimit+1; i++ {
|
||||
loopLast = httptest.NewRecorder()
|
||||
RateLimitLogin(dummyHandler)(loopLast, loop)
|
||||
}
|
||||
if loopLast.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("loopback login exempt: got %d", loopLast.Code)
|
||||
}
|
||||
// Signup keeps the loopback exemption for host-local consumers.
|
||||
resetRateLimits()
|
||||
var loopSignup *httptest.ResponseRecorder
|
||||
for i := 0; i < signupIPLimit*2; i++ {
|
||||
loopSignup = httptest.NewRecorder()
|
||||
RateLimitSignup(dummyHandler)(loopSignup, loop)
|
||||
}
|
||||
if loopSignup.Code != 204 {
|
||||
t.Fatalf("loopback signup limited with %d", loopSignup.Code)
|
||||
}
|
||||
resetRateLimits()
|
||||
|
||||
// Non-loopback peers get 429 after the window limit, with Retry-After.
|
||||
remote := httptest.NewRequest("POST", "/api/auth/login", nil)
|
||||
remote.RemoteAddr = "203.0.113.7:1234"
|
||||
var last *httptest.ResponseRecorder
|
||||
for i := 0; i < loginIPLimit+1; i++ {
|
||||
last = httptest.NewRecorder()
|
||||
RateLimitLogin(dummyHandler)(last, remote)
|
||||
}
|
||||
if last.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("expected 429 after limit, got %d", last.Code)
|
||||
}
|
||||
if last.Header().Get("Retry-After") == "" {
|
||||
t.Fatal("429 without Retry-After header")
|
||||
}
|
||||
|
||||
// Signup limiter counts independently.
|
||||
resetRateLimits()
|
||||
var signupLast *httptest.ResponseRecorder
|
||||
for i := 0; i < signupIPLimit+1; i++ {
|
||||
signupLast = httptest.NewRecorder()
|
||||
RateLimitSignup(dummyHandler)(signupLast, remote)
|
||||
}
|
||||
if signupLast.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("signup: expected 429 after limit, got %d", signupLast.Code)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
RateLimitLogin(dummyHandler)(w, remote)
|
||||
if w.Code != 204 {
|
||||
t.Fatalf("signup exhaustion must not limit login: %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Review AUTH-RATE: Go accepts a forwarded client IP only when the trusted
|
||||
// Next proxy signed it with the shared secret; forged headers fall back to
|
||||
// the direct peer, so one browser cannot evade or poison another's bucket.
|
||||
func TestSignedForwardedClientIP(t *testing.T) {
|
||||
t.Setenv("NEXTAUTH_SECRET", "")
|
||||
t.Setenv("AUTH_SECRET", "ratelimit-forward-secret")
|
||||
ts := time.Now().Unix()
|
||||
sign := func(ip string, when int64) (string, string) {
|
||||
mac := hmac.New(sha256.New, []byte("ratelimit-forward-secret"))
|
||||
mac.Write([]byte(ip + "|" + strconv.FormatInt(when, 10)))
|
||||
return strconv.FormatInt(when, 10), hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
r := httptest.NewRequest("POST", "/api/auth/login", nil)
|
||||
r.RemoteAddr = "203.0.113.9:5555"
|
||||
if got := EffectiveClientIP(r); got != "203.0.113.9" {
|
||||
t.Fatalf("unsigned request: got %s", got)
|
||||
}
|
||||
hts, sig := sign("198.51.100.7", ts)
|
||||
r.Header.Set(headerClientIP, "198.51.100.7")
|
||||
r.Header.Set(headerClientTs, hts)
|
||||
r.Header.Set(headerClientSig, sig)
|
||||
if got := EffectiveClientIP(r); got != "198.51.100.7" {
|
||||
t.Fatalf("signed forwarded IP ignored: got %s", got)
|
||||
}
|
||||
_, badSig := sign("198.51.100.8", ts)
|
||||
r.Header.Set(headerClientSig, badSig)
|
||||
if got := EffectiveClientIP(r); got != "203.0.113.9" {
|
||||
t.Fatalf("forged signature accepted: got %s", got)
|
||||
}
|
||||
goodTs, goodSig := sign("198.51.100.7", ts)
|
||||
r.Header.Set(headerClientSig, goodSig)
|
||||
r.Header.Set(headerClientTs, goodTs)
|
||||
r.Header.Set(headerClientIP, "198.51.100.7; DROP TABLE users")
|
||||
if got := EffectiveClientIP(r); got != "203.0.113.9" {
|
||||
t.Fatalf("non-IP forwarded value accepted: got %s", got)
|
||||
}
|
||||
// Review SEC-2: a forwarded identity never earns the loopback exemption,
|
||||
// even when it attests 127.0.0.1; and stale timestamps fall back to the
|
||||
// direct peer.
|
||||
loopTs, loopSig := sign("127.0.0.1", ts)
|
||||
r.Header.Set(headerClientIP, "127.0.0.1")
|
||||
r.Header.Set(headerClientTs, loopTs)
|
||||
r.Header.Set(headerClientSig, loopSig)
|
||||
if isLoopback(r) {
|
||||
t.Fatal("signed loopback forwarded IP must not earn the exemption")
|
||||
}
|
||||
staleTs, staleSig := sign("198.51.100.7", ts-3600)
|
||||
r.Header.Set(headerClientIP, "198.51.100.7")
|
||||
r.Header.Set(headerClientTs, staleTs)
|
||||
r.Header.Set(headerClientSig, staleSig)
|
||||
if got := EffectiveClientIP(r); got != "203.0.113.9" {
|
||||
t.Fatalf("stale signature accepted: got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardedIPsGetSeparateBuckets(t *testing.T) {
|
||||
t.Setenv("NEXTAUTH_SECRET", "")
|
||||
t.Setenv("AUTH_SECRET", "ratelimit-forward-secret")
|
||||
resetRateLimits()
|
||||
ts := time.Now().Unix()
|
||||
sign := func(ip string) (string, string) {
|
||||
mac := hmac.New(sha256.New, []byte("ratelimit-forward-secret"))
|
||||
mac.Write([]byte(ip + "|" + strconv.FormatInt(ts, 10)))
|
||||
return strconv.FormatInt(ts, 10), hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
handler := RateLimitLogin(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })
|
||||
do := func(ip string) int {
|
||||
r := httptest.NewRequest("POST", "/api/auth/login", nil)
|
||||
r.RemoteAddr = "10.0.0.5:1234" // shared proxy peer
|
||||
hts, sig := sign(ip)
|
||||
r.Header.Set(headerClientIP, ip)
|
||||
r.Header.Set(headerClientTs, hts)
|
||||
r.Header.Set(headerClientSig, sig)
|
||||
w := httptest.NewRecorder()
|
||||
handler(w, r)
|
||||
return w.Code
|
||||
}
|
||||
for i := 0; i < loginIPLimit; i++ {
|
||||
if code := do("198.51.100.1"); code != 200 {
|
||||
t.Fatalf("browser 1 request %d: %d", i+1, code)
|
||||
}
|
||||
}
|
||||
if code := do("198.51.100.1"); code != http.StatusTooManyRequests {
|
||||
t.Fatalf("browser 1 over limit: %d", code)
|
||||
}
|
||||
// An unrelated browser behind the same proxy keeps its own budget.
|
||||
if code := do("198.51.100.2"); code != 200 {
|
||||
t.Fatalf("browser 2 blocked by browser 1: %d", code)
|
||||
}
|
||||
resetRateLimits()
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/andrew/go-computer/internal/config"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// SessionStrategy mirrors the original NextAuth setup: a stateless signed JWT
|
||||
// cookie. The jwt callback stores token.id = user.id and token.provider; the
|
||||
// session callback copies token.id to session.user.id (porting guide 3.1).
|
||||
const (
|
||||
sessionCookieName = "computer_session"
|
||||
// time.Duration is nanoseconds, so multiply by time.Second explicitly.
|
||||
sessionMaxAge = 30 * 24 * 60 * 60 * time.Second // 30 days
|
||||
|
||||
// Token types bind each token to its channel: the trusted Next server
|
||||
// signs short-lived "assertion" tokens for the Bearer header; Go signs
|
||||
// 30-day "session" tokens for its own cookie. Neither is accepted on the
|
||||
// other channel, so a long-lived cookie JWT can never be replayed as a
|
||||
// Bearer credential (and vice versa).
|
||||
TokenTypeSession = "session"
|
||||
TokenTypeAssertion = "assertion"
|
||||
// maxAssertionLifetime is the server-side cap for Bearer assertions; the
|
||||
// Next server signs 60-second tokens.
|
||||
maxAssertionLifetime = 120 * time.Second
|
||||
)
|
||||
|
||||
// SessionClaims is the JWT payload.
|
||||
type SessionClaims struct {
|
||||
UserID string `json:"uid"`
|
||||
Role string `json:"role"`
|
||||
Type string `json:"typ,omitempty"`
|
||||
// Ver mirrors User.tokenVersion (≥1). Required on assertions (review
|
||||
// SEC-02: a tokenVersion bump must revoke NextAuth-backed sessions too,
|
||||
// not just Go cookie sessions); pre-revocation legacy cookies omit it and
|
||||
// skip the revocation check.
|
||||
Ver int `json:"ver,omitempty"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// SignSession issues a signed JWT cookie value for the user.
|
||||
func SignSession(u SessionUser, secret string) (string, error) {
|
||||
if secret == "" {
|
||||
return "", fmt.Errorf("auth: empty session secret")
|
||||
}
|
||||
now := time.Now()
|
||||
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, SessionClaims{
|
||||
UserID: u.ID,
|
||||
Role: u.Role,
|
||||
Type: TokenTypeSession,
|
||||
Ver: u.Ver,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(sessionMaxAge)),
|
||||
Issuer: "computer",
|
||||
},
|
||||
})
|
||||
return tok.SignedString([]byte(secret))
|
||||
}
|
||||
|
||||
// VerifySession decodes a JWT cookie value into the session user. It checks
|
||||
// signature, algorithm, issuer and expiry — but NOT the channel binding; use
|
||||
// ReadSession for request handling.
|
||||
func VerifySession(token, secret string) (*SessionUser, error) {
|
||||
claims, err := verifyClaims(token, secret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &SessionUser{ID: claims.UserID, Role: claims.Role, Ver: claims.Ver}, nil
|
||||
}
|
||||
|
||||
func verifyClaims(token, secret string) (*SessionClaims, error) {
|
||||
if secret == "" {
|
||||
return nil, fmt.Errorf("auth: empty session secret")
|
||||
}
|
||||
tok, err := jwt.ParseWithClaims(token, &SessionClaims{}, func(t *jwt.Token) (interface{}, error) {
|
||||
return []byte(secret), nil
|
||||
}, jwt.WithValidMethods([]string{"HS256"}), jwt.WithIssuer("computer"), jwt.WithExpirationRequired())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
claims, ok := tok.Claims.(*SessionClaims)
|
||||
if !ok || !tok.Valid || claims.UserID == "" {
|
||||
return nil, errors.New("auth: invalid token")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// ReadSession pulls the session credential from the request and verifies it,
|
||||
// enforcing the channel binding: Bearer requires a short-lived assertion; the
|
||||
// cookie requires a session token (tokens issued before typ existed, i.e. no
|
||||
// typ claim, remain valid on the cookie channel only).
|
||||
func ReadSession(r *http.Request, secret string) (*SessionUser, error) {
|
||||
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
|
||||
claims, err := verifyClaims(strings.TrimPrefix(h, "Bearer "), secret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if claims.Type != TokenTypeAssertion {
|
||||
return nil, errors.New("auth: bearer token is not an assertion")
|
||||
}
|
||||
if claims.IssuedAt == nil || claims.ExpiresAt == nil || claims.ExpiresAt.Sub(claims.IssuedAt.Time) > maxAssertionLifetime {
|
||||
return nil, errors.New("auth: assertion lifetime too long")
|
||||
}
|
||||
// Review SEC-02: assertions must carry the session's tokenVersion so a
|
||||
// revocation bump ("sign out everywhere") invalidates NextAuth-backed
|
||||
// browser sessions on their next backend call, instead of leaving them
|
||||
// minting valid assertions until the NextAuth JWT expires.
|
||||
if claims.Ver < 1 {
|
||||
return nil, errors.New("auth: assertion missing token version")
|
||||
}
|
||||
return &SessionUser{ID: claims.UserID, Role: claims.Role, Ver: claims.Ver}, nil
|
||||
}
|
||||
c, err := r.Cookie(sessionCookieName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
claims, err := verifyClaims(c.Value, secret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if claims.Type != "" && claims.Type != TokenTypeSession {
|
||||
return nil, errors.New("auth: cookie token has wrong type")
|
||||
}
|
||||
// Review SEC-7: legacy session cookies without a `ver` claim (Ver == 0)
|
||||
// were minted before revocation was implemented and would bypass the
|
||||
// tokenVersion check entirely ("sign out everywhere" would not affect
|
||||
// them until expiry). Treat a missing/zero ver as revoked so users must
|
||||
// re-authenticate.
|
||||
if claims.Ver < 1 {
|
||||
return nil, errors.New("auth: session lacks required version claim")
|
||||
}
|
||||
return &SessionUser{ID: claims.UserID, Role: claims.Role, Ver: claims.Ver}, nil
|
||||
}
|
||||
|
||||
// WriteSessionCookie sets the signed session cookie on the response.
|
||||
func WriteSessionCookie(w http.ResponseWriter, u SessionUser, secret string) error {
|
||||
tok, err := SignSession(u, secret)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: tok,
|
||||
Path: "/",
|
||||
MaxAge: int(sessionMaxAge.Seconds()),
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: isSecure(),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearSessionCookie expires the session cookie.
|
||||
func ClearSessionCookie(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: isSecure(),
|
||||
})
|
||||
}
|
||||
|
||||
// isSecure derives the cookie Secure flag from configuration: explicit
|
||||
// COOKIE_SECURE wins, otherwise an https NEXTAUTH_URL implies secure.
|
||||
func isSecure() bool {
|
||||
return config.Load().CookieSecure()
|
||||
}
|
||||
|
||||
// ErrUnauthorized is returned when there is no valid session.
|
||||
var ErrUnauthorized = errors.New("auth: unauthorized")
|
||||
@@ -0,0 +1,113 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSessionLifetimeAndVerification(t *testing.T) {
|
||||
token, e := SignSession(SessionUser{ID: "alice"}, "test-secret")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
s, e := VerifySession(token, "test-secret")
|
||||
if e != nil || s.ID != "alice" {
|
||||
t.Fatalf("session: %v %v", s, e)
|
||||
}
|
||||
var claims SessionClaims
|
||||
_, _, e = jwt.NewParser().ParseUnverified(token, &claims)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if d := claims.ExpiresAt.Sub(claims.IssuedAt.Time); d != 30*24*time.Hour {
|
||||
t.Fatalf("token lifetime: %v", d)
|
||||
}
|
||||
if _, e = VerifySession(token, "wrong-secret"); e == nil {
|
||||
t.Fatal("accepted wrong secret")
|
||||
}
|
||||
expired := jwt.NewWithClaims(jwt.SigningMethodHS256, SessionClaims{UserID: "alice", RegisteredClaims: jwt.RegisteredClaims{Issuer: "computer", ExpiresAt: jwt.NewNumericDate(time.Now().Add(-time.Hour))}})
|
||||
v, _ := expired.SignedString([]byte("test-secret"))
|
||||
if _, e = VerifySession(v, "test-secret"); e == nil {
|
||||
t.Fatal("accepted expired token")
|
||||
}
|
||||
token256 := jwt.NewWithClaims(jwt.SigningMethodHS384, SessionClaims{UserID: "alice", RegisteredClaims: jwt.RegisteredClaims{Issuer: "computer", ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour))}})
|
||||
v, _ = token256.SignedString([]byte("test-secret"))
|
||||
if _, e = VerifySession(v, "test-secret"); e == nil {
|
||||
t.Fatal("accepted unexpected algorithm")
|
||||
}
|
||||
}
|
||||
|
||||
func signTestToken(t *testing.T, typ string, ttl time.Duration, ver int) string {
|
||||
t.Helper()
|
||||
now := time.Now()
|
||||
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, SessionClaims{
|
||||
UserID: "alice", Type: typ, Ver: ver,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Issuer: "computer",
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
|
||||
},
|
||||
})
|
||||
v, e := tok.SignedString([]byte("test-secret"))
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// The Bearer channel accepts only short-lived assertions carrying a token
|
||||
// version (review SEC-02); the cookie channel accepts only session tokens (or
|
||||
// legacy tokens issued before typ existed). A 30-day cookie JWT must never
|
||||
// work as a Bearer credential.
|
||||
func TestTokenChannelBinding(t *testing.T) {
|
||||
assertion := signTestToken(t, TokenTypeAssertion, 60*time.Second, 3)
|
||||
versionlessAssertion := signTestToken(t, TokenTypeAssertion, 60*time.Second, 0)
|
||||
longAssertion := signTestToken(t, TokenTypeAssertion, time.Hour, 3)
|
||||
session, e := SignSession(SessionUser{ID: "alice"}, "test-secret")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
legacy := signTestToken(t, "", time.Hour, 0)
|
||||
// Review SEC-7: cookies now require Ver >= 1. Create a versioned session
|
||||
// cookie for the channel-binding test.
|
||||
versionedSession := signTestToken(t, TokenTypeSession, time.Hour, 3)
|
||||
|
||||
bearer := func(token string) (*SessionUser, error) {
|
||||
r := httptest.NewRequest("GET", "/api/me", nil)
|
||||
r.Header.Set("Authorization", "Bearer "+token)
|
||||
return ReadSession(r, "test-secret")
|
||||
}
|
||||
cookie := func(token string) (*SessionUser, error) {
|
||||
r := httptest.NewRequest("GET", "/api/me", nil)
|
||||
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: token})
|
||||
return ReadSession(r, "test-secret")
|
||||
}
|
||||
|
||||
if s, e := bearer(assertion); e != nil || s.ID != "alice" || s.Ver != 3 {
|
||||
t.Fatalf("valid assertion rejected or lost its version: %v %v", s, e)
|
||||
}
|
||||
if _, e := bearer(versionlessAssertion); e == nil {
|
||||
t.Fatal("assertion without token version accepted (revocation bypass)")
|
||||
}
|
||||
if _, e := bearer(session); e == nil {
|
||||
t.Fatal("30-day session token accepted as Bearer assertion")
|
||||
}
|
||||
if _, e := bearer(longAssertion); e == nil {
|
||||
t.Fatal("over-long assertion accepted (exp-iat cap not enforced)")
|
||||
}
|
||||
if _, e := bearer(legacy); e == nil {
|
||||
t.Fatal("typ-less token accepted as Bearer assertion")
|
||||
}
|
||||
if s, e := cookie(versionedSession); e != nil || s.ID != "alice" {
|
||||
t.Fatalf("valid session cookie rejected: %v %v", s, e)
|
||||
}
|
||||
if s, e := cookie(legacy); e == nil {
|
||||
t.Fatalf("legacy typ-less cookie with Ver=0 must be rejected: %v %v", s, e)
|
||||
}
|
||||
if _, e := cookie(assertion); e == nil {
|
||||
t.Fatal("assertion accepted as cookie session")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Signup input validation must reject before any database access, so these
|
||||
// run without a DB context.
|
||||
func TestSignupValidation(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body map[string]any
|
||||
}{
|
||||
{"empty", map[string]any{}},
|
||||
{"missing password", map[string]any{"email": "ok@computer.test"}},
|
||||
{"invalid email", map[string]any{"email": "not-an-email", "password": "long-enough-password"}},
|
||||
{"email with display name", map[string]any{"email": "Name <ok@computer.test>", "password": "long-enough-password"}},
|
||||
{"short password", map[string]any{"email": "ok@computer.test", "password": "short"}},
|
||||
{"7 boundary password", map[string]any{"email": "ok@computer.test", "password": "1234567"}},
|
||||
{"overlong password", map[string]any{"email": "ok@computer.test", "password": strings.Repeat("a", 73)}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
raw, _ := json.Marshal(tc.body)
|
||||
req := httptest.NewRequest("POST", "/api/signup", bytes.NewReader(raw))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
HandleSignup(w, req)
|
||||
if w.Code != 400 {
|
||||
t.Fatalf("wanted 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user