initial commit on Federted servers
release gate / check (push) Failing after 8m35s

This commit is contained in:
Andrew S Erwin
2026-10-19 16:23:55 -05:00
commit b9721ff13f
217 changed files with 31534 additions and 0 deletions
+20
View File
@@ -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"`
}
+19
View File
@@ -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
}
+263
View File
@@ -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})
}
+137
View File
@@ -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)
})
}
+302
View File
@@ -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)
}
}
}
+245
View File
@@ -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()
}
+180
View File
@@ -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")
+113
View File
@@ -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")
}
}
+38
View File
@@ -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())
}
})
}
}