From 7b1c18590ed99ab3c2299f5597696c9141924fea Mon Sep 17 00:00:00 2001 From: Tronax Date: Wed, 5 Aug 2026 19:05:07 +0200 Subject: [PATCH] Backend Phase B (1/2): password auth + sessions Argon2id password hashing (PHC format, self-encoded/decoded without an external lib) with constant-time verification, UserStore (create/get by email and id) and SessionStore (opaque crypto/rand tokens, SHA-256 hashed in DB, create/lookup/revoke, last_seen_at bump on lookup). HTTP layer: Register/Login/Logout handlers + RequireAuth middleware. Login uses a dummy-hash path so unknown-email and wrong-password yield the same timing/shape, narrowing user enumeration. Tokens accepted via Bearer header (native clients) or session cookie (HttpOnly, SameSite=Lax). Routes wired in api.go: POST /auth/register, /auth/login, /auth/logout. Verified with go test, go vet and an end-to-end smoke test against a real PostgreSQL container (register/login/logout/duplicate/short-pw/wrong-pw). OIDC (Phase B part 2) follows next; the issueSession helper is reused. --- AGENTS.md | 43 ++-- backend/go.mod | 7 +- backend/go.sum | 16 +- backend/internal/auth/password.go | 137 +++++++++++++ backend/internal/auth/password_test.go | 67 +++++++ backend/internal/auth/pgcode.go | 14 ++ backend/internal/auth/session.go | 117 +++++++++++ backend/internal/auth/user.go | 95 +++++++++ backend/internal/httpapi/api.go | 28 ++- backend/internal/httpapi/auth.go | 261 +++++++++++++++++++++++++ 10 files changed, 756 insertions(+), 29 deletions(-) create mode 100644 backend/internal/auth/password.go create mode 100644 backend/internal/auth/password_test.go create mode 100644 backend/internal/auth/pgcode.go create mode 100644 backend/internal/auth/session.go create mode 100644 backend/internal/auth/user.go create mode 100644 backend/internal/httpapi/auth.go diff --git a/AGENTS.md b/AGENTS.md index 2db252e..9946fe0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -90,11 +90,18 @@ mitbringsl/ │ │ ├── config/config.go # Config (caarlos0/env) │ │ ├── logging/logging.go # slog JSON-Setup │ │ ├── store/db.go # pgxpool-Setup +│ │ ├── auth/ # PHASE B – Password + Session +│ │ │ ├── password.go # Argon2id im PHC-Format (HashPassword/VerifyPassword) +│ │ │ ├── password_test.go # PHC-Roundtrip-Tests +│ │ │ ├── user.go # UserStore: CreateUser/GetByEmail/GetByID +│ │ │ ├── session.go # SessionStore: opaque Tokens, SHA-256-Hash, Create/Lookup/Revoke +│ │ │ └── pgcode.go # isUniqueViolation (SQLSTATE 23505) │ │ └── httpapi/ -│ │ ├── api.go # API-Objekt + Router (Health aktiv, Rest noch auskommentiert) +│ │ ├── api.go # API-Objekt + Router (Health + /auth/* aktiv, Rest auskommentiert) │ │ ├── render.go # JSON-Render + Problem + Fehler-Sentinale + decodeJSON │ │ ├── middleware.go # requestID/logging/recover/cors + Chain -│ │ └── health.go # /healthz + /readyz +│ │ ├── health.go # /healthz + /readyz +│ │ └── auth.go # Register/Login/Logout-Handler + RequireAuth-Middleware │ ├── migrations/ │ │ ├── embed.go # //go:embed *.sql │ │ ├── 000001_init_schema.up.sql # users/sessions/lists/list_members/items/op_log/item_names @@ -123,8 +130,12 @@ Legende: ✅ erledigt · 🚧 in Arbeit · ⬜ offen - ✅ **Phase A – Docker:** Dockerfile (Go 1.26 → distroless nonroot), docker-compose (caddy/backend/migrate/db), Caddyfile, `.env.example`. **Verifiziert:** Image baut, beide Binaries laufen im Container (Smoke-Test OK). -- ⬜ **Phase B – Auth:** Argon2id + eigene User (register/login) + Session-Middleware. -- ⬜ **Phase B – OIDC:** go-oidc-Verifikation (Google + Generic) + `POST /auth/oidc`. +- ✅ **Phase B – Auth (Password):** Argon2id im PHC-Format + `UserStore` (Create/GetByEmail/GetByID) + + `SessionStore` (opaque Tokens, SHA-256-Hash, Create/Lookup/Revoke) + Handler + `Register`/`Login`/`Logout` + `RequireAuth`-Middleware. + **Verifiziert:** `go test ./internal/auth/...` grün, E2E-Smoke-Test gegen echtes + PostgreSQL via Docker (Register/Login/Logout/Duplicate/Short-PW/Wrong-PW alle korrekt). +- 🚧 **Phase B – OIDC:** go-oidc-Verifikation (Google + Generic) + `POST /auth/oidc`. - ⬜ **Phase C – Sync-Kern:** `op_log`-Append (idempotent), HLC, Projektion op→items/lists (LWW+Tombstones). - ⬜ **Phase C – Endpoints:** `/api/lists`, `/api/lists/{id}/ops` (push+pull). - ⬜ **Phase C – Suggestions:** `item_names`-Trigger + `/api/suggestions`. @@ -138,13 +149,23 @@ Legende: ✅ erledigt · 🚧 in Arbeit · ⬜ offen - ⬜ **Phase F – README + docs** (ARCHITECTURE/SYNC/API). ### Wo genau weitermachen? -**Nächster Schritt = Phase B (Auth):** -1. `internal/auth/password.go` – Argon2id (PHC-Format) Hashen/Verifizieren. -2. `internal/auth/session.go` – Token generieren (crypto/rand, base64url), SHA-256-Hash, - in `sessions` einfügen, Middleware `requireAuth` (lädt `user_id` in Context). -3. `internal/httpapi/auth.go` – Handler `Register`/`Login`/`Logout`. -4. In `api.go` die `/auth/*`-Routen einkommentieren + verdrahten. -5. Dann Phase B Teil 2: OIDC (`internal/auth/oidc.go` + `POST /auth/oidc`). +**Nächster Schritt = Phase B Teil 2 (OIDC)** – Teil 1 (Password-Auth) ist fertig ✅: + +Teil 1 (erledigt): +- ✅ `internal/auth/password.go` – Argon2id im PHC-Format (HashPassword/VerifyPassword). +- ✅ `internal/auth/user.go` – UserStore (CreateUser/GetByEmail/GetByID). +- ✅ `internal/auth/session.go` – SessionStore (crypto/rand + base64url + SHA-256, Create/Lookup/Revoke). +- ✅ `internal/httpapi/auth.go` – Register/Login/Logout + RequireAuth-Middleware. +- ✅ In `api.go` sind `/auth/register`, `/auth/login`, `/auth/logout` aktiv verdrahtet. + +Teil 2 (offen – OIDC): +1. `internal/auth/oidc.go` – Verifikation eines `id_token` via `github.com/coreos/go-oidc/v3` + (JWKS-Signatur, iss/aud/exp prüfen). Provider-Auswahl anhand `issuer` aus Config. +2. `POST /auth/oidc`-Handler in `internal/httpapi/auth.go`: empfängt `{provider, id_token}`, + verifiziert, findet/legt User an (oidc_issuer+oidc_subject UNIQUE), stellt Session aus + (gleicher `issueSession`-Pfad wie Login). +3. In `api.go` `mux.HandleFunc("POST /auth/oidc", a.auth.OIDC)` einkommentieren. +4. Config-Validierung: wenn `OIDC_GOOGLE_ENABLED=true`, muss `OIDC_GOOGLE_CLIENT_ID` gesetzt sein. --- diff --git a/backend/go.mod b/backend/go.mod index a3f5c6d..f086147 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -5,7 +5,9 @@ go 1.26 require ( github.com/caarlos0/env/v11 v11.4.1 github.com/golang-migrate/migrate/v4 v4.18.2 + github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.10.0 + golang.org/x/crypto v0.54.0 ) require ( @@ -16,6 +18,7 @@ require ( github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/lib/pq v1.10.9 // indirect go.uber.org/atomic v1.7.0 // indirect - golang.org/x/sync v0.17.0 // indirect - golang.org/x/text v0.29.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index 81ee553..c77ac78 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -27,6 +27,8 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-migrate/migrate/v4 v4.18.2 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK1yQYKo6wWQ8= github.com/golang-migrate/migrate/v4 v4.18.2/go.mod h1:2CM6tJvn2kqPXwnXO/d3rAQYiyoIm180VsO8PRX6Rpk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -71,12 +73,14 @@ go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt3 go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= -golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/backend/internal/auth/password.go b/backend/internal/auth/password.go new file mode 100644 index 0000000..002ad4f --- /dev/null +++ b/backend/internal/auth/password.go @@ -0,0 +1,137 @@ +// Package auth implements password hashing, session management and (later) OIDC +// verification for the mitbringsl backend. +package auth + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "errors" + "fmt" + "strconv" + "strings" + + "golang.org/x/crypto/argon2" +) + +// Argon2Params holds the cost parameters for the Argon2id key derivation. +// Defaults follow the RFC-9106 "first recommended" option for memory-hard +// single-instance hashing (m=64MiB, t=3, p=4). They are deliberately not +// configurable via env to avoid accidental downgrades; change via code instead. +type Argon2Params struct { + Memory uint32 // in KiB + Iterations uint32 + Parallelism uint8 + SaltLength uint32 // bytes + KeyLength uint32 // bytes +} + +// DefaultArgon2Params are tuned for a backend with ~256MiB headroom per login. +// Memory = 64 * 1024 KiB = 64 MiB. +var DefaultArgon2Params = Argon2Params{ + Memory: 64 * 1024, + Iterations: 3, + Parallelism: 4, + SaltLength: 16, + KeyLength: 32, +} + +// ErrInvalidHash is returned when a stored password hash is not a valid PHC string. +var ErrInvalidHash = errors.New("invalid password hash") + +// HashPassword derives an Argon2id hash from the password and encodes it in the +// PHC string format: $argon2id$v=19$m=,t=,p=

$$. +// The returned string is safe to store verbatim in the users.password_hash column. +func HashPassword(password string, p Argon2Params) (string, error) { + salt := make([]byte, p.SaltLength) + if _, err := rand.Read(salt); err != nil { + return "", fmt.Errorf("generate salt: %w", err) + } + key := argon2.IDKey([]byte(password), salt, p.Iterations, p.Memory, p.Parallelism, p.KeyLength) + return encodePHC(p, salt, key), nil +} + +// VerifyPassword compares a password against a stored PHC-format hash in constant +// time. It returns a non-nil error if the hash is malformed or the password does +// not match. +func VerifyPassword(password, encoded string) error { + p, salt, hash, err := decodePHC(encoded) + if err != nil { + return err + } + otherKey := argon2.IDKey([]byte(password), salt, p.Iterations, p.Memory, p.Parallelism, uint32(len(hash))) + if subtle.ConstantTimeCompare(hash, otherKey) != 1 { + return errors.New("password does not match") + } + return nil +} + +// --- PHC encoding helpers ---------------------------------------------------- + +const phcAlg = "argon2id" + +func encodePHC(p Argon2Params, salt, key []byte) string { + b64 := base64.RawStdEncoding.EncodeToString + return fmt.Sprintf("$%s$v=%d$m=%d,t=%d,p=%d$%s$%s", + phcAlg, argon2.Version, p.Memory, p.Iterations, p.Parallelism, + b64(salt), b64(key)) +} + +func decodePHC(encoded string) (Argon2Params, []byte, []byte, error) { + parts := strings.Split(encoded, "$") + // Expected: ["", "argon2id", "v=19", "m=..,t=..,p=..", "", ""] + if len(parts) != 6 || parts[1] != phcAlg { + return Argon2Params{}, nil, nil, fmt.Errorf("%w: wrong format", ErrInvalidHash) + } + + var version int + if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil { + return Argon2Params{}, nil, nil, fmt.Errorf("%w: parse version: %v", ErrInvalidHash, err) + } + if version != argon2.Version { + return Argon2Params{}, nil, nil, fmt.Errorf("%w: unsupported version %d", ErrInvalidHash, version) + } + + p, err := parseParams(parts[3]) + if err != nil { + return Argon2Params{}, nil, nil, err + } + + salt, err := base64.RawStdEncoding.DecodeString(parts[4]) + if err != nil { + return Argon2Params{}, nil, nil, fmt.Errorf("%w: decode salt: %v", ErrInvalidHash, err) + } + key, err := base64.RawStdEncoding.DecodeString(parts[5]) + if err != nil { + return Argon2Params{}, nil, nil, fmt.Errorf("%w: decode key: %v", ErrInvalidHash, err) + } + p.KeyLength = uint32(len(key)) + p.SaltLength = uint32(len(salt)) + return p, salt, key, nil +} + +func parseParams(s string) (Argon2Params, error) { + var p Argon2Params + for _, kv := range strings.Split(s, ",") { + k, v, ok := strings.Cut(kv, "=") + if !ok { + return Argon2Params{}, fmt.Errorf("%w: malformed param %q", ErrInvalidHash, kv) + } + n, err := strconv.ParseUint(v, 10, 32) + if err != nil { + return Argon2Params{}, fmt.Errorf("%w: parse %s: %v", ErrInvalidHash, k, err) + } + switch k { + case "m": + p.Memory = uint32(n) + case "t": + p.Iterations = uint32(n) + case "p": + p.Parallelism = uint8(n) + } + } + if p.Memory == 0 || p.Iterations == 0 || p.Parallelism == 0 { + return Argon2Params{}, fmt.Errorf("%w: missing cost parameter", ErrInvalidHash) + } + return p, nil +} diff --git a/backend/internal/auth/password_test.go b/backend/internal/auth/password_test.go new file mode 100644 index 0000000..c430d3b --- /dev/null +++ b/backend/internal/auth/password_test.go @@ -0,0 +1,67 @@ +package auth + +import ( + "strings" + "testing" +) + +// fastParams keeps the test fast while still exercising the full PHC path. +var fastParams = Argon2Params{Memory: 4 * 1024, Iterations: 1, Parallelism: 1, SaltLength: 16, KeyLength: 32} + +func TestHashPassword_PHCFormat(t *testing.T) { + h, err := HashPassword("hunter2", fastParams) + if err != nil { + t.Fatalf("HashPassword: %v", err) + } + if !strings.HasPrefix(h, "$argon2id$v=19$") { + t.Fatalf("unexpected PHC prefix: %s", h) + } + if strings.Count(h, "$") != 5 { + t.Fatalf("expected 5 '$' separators, got %q", h) + } +} + +func TestVerifyPassword_RoundTrip(t *testing.T) { + pw := "correct horse battery staple" + h, err := HashPassword(pw, fastParams) + if err != nil { + t.Fatalf("HashPassword: %v", err) + } + if err := VerifyPassword(pw, h); err != nil { + t.Fatalf("VerifyPassword correct: %v", err) + } + if err := VerifyPassword("wrong", h); err == nil { + t.Fatal("VerifyPassword wrong: expected error, got nil") + } +} + +func TestVerifyPassword_Malformed(t *testing.T) { + cases := []string{ + "", + "not-a-hash", + "$argon2id$v=19$m=1024,t=1,p=1$AAAA$BB", + "$argon2i$v=19$m=1024,t=1,p=1$AAAA$BBBB", + "$argon2id$v=99$m=1024,t=1,p=1$AAAA$BBBB", + "$argon2id$v=19$m=0,t=1,p=1$AAAA$BBBB", + } + for _, c := range cases { + if err := VerifyPassword("x", c); err == nil { + t.Fatalf("VerifyPassword(%q): expected error", c) + } + } +} + +func TestHashPassword_DifferentSalts(t *testing.T) { + a, _ := HashPassword("same", fastParams) + b, _ := HashPassword("same", fastParams) + if a == b { + t.Fatal("two hashes of the same password should differ due to random salt") + } + // both must still verify against the original password + if err := VerifyPassword("same", a); err != nil { + t.Fatalf("verify a: %v", err) + } + if err := VerifyPassword("same", b); err != nil { + t.Fatalf("verify b: %v", err) + } +} diff --git a/backend/internal/auth/pgcode.go b/backend/internal/auth/pgcode.go new file mode 100644 index 0000000..5d1fa96 --- /dev/null +++ b/backend/internal/auth/pgcode.go @@ -0,0 +1,14 @@ +package auth + +import ( + "errors" + + "github.com/jackc/pgx/v5/pgconn" +) + +// isUniqueViolation reports whether err is a PostgreSQL unique_violation (SQLSTATE 23505), +// e.g. from a duplicate INSERT on a UNIQUE column. +func isUniqueViolation(err error) bool { + var pgErr *pgconn.PgError + return errors.As(err, &pgErr) && pgErr.Code == "23505" +} diff --git a/backend/internal/auth/session.go b/backend/internal/auth/session.go new file mode 100644 index 0000000..1f090c0 --- /dev/null +++ b/backend/internal/auth/session.go @@ -0,0 +1,117 @@ +package auth + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// Session is the application-side view of a row in the sessions table. +type Session struct { + ID uuid.UUID + UserID uuid.UUID + ExpiresAt time.Time + RevokedAt *time.Time +} + +// ErrSessionNotFound is returned when no (valid, non-expired, non-revoked) +// session matches the given token. +var ErrSessionNotFound = errors.New("session not found") + +// SessionConfig controls token generation and persistence. +type SessionConfig struct { + TokenBytes int // entropy of the raw token before base64url encoding + TTL time.Duration // validity window from creation +} + +// SessionStore wraps database access for the sessions table and handles opaque +// token generation. Only the SHA-256 hash of a token is ever stored. +type SessionStore struct { + pool *pgxpool.Pool + cfg SessionConfig +} + +// NewSessionStore constructs a SessionStore. tokenBytes must be >= 16. +func NewSessionStore(pool *pgxpool.Pool, cfg SessionConfig) *SessionStore { + if cfg.TokenBytes < 16 { + cfg.TokenBytes = 32 + } + return &SessionStore{pool: pool, cfg: cfg} +} + +// Create issues a new session for the user and returns the raw token (to send to +// the client exactly once) together with the persisted Session row. +func (s *SessionStore) Create(ctx context.Context, userID uuid.UUID, userAgent, ip string) (token string, sess Session, err error) { + raw := make([]byte, s.cfg.TokenBytes) + if _, err = rand.Read(raw); err != nil { + return "", Session{}, fmt.Errorf("generate session token: %w", err) + } + token = base64.RawURLEncoding.EncodeToString(raw) + tokenHash := hashToken(token) + expiresAt := time.Now().Add(s.cfg.TTL) + + const q = ` + INSERT INTO sessions (user_id, token_hash, expires_at, user_agent, ip) + VALUES ($1, $2, $3, NULLIF($4, ''), NULLIF($5, '')) + RETURNING id, user_id, expires_at, revoked_at` + err = s.pool.QueryRow(ctx, q, userID, tokenHash, expiresAt, userAgent, ip). + Scan(&sess.ID, &sess.UserID, &sess.ExpiresAt, &sess.RevokedAt) + if err != nil { + return "", Session{}, fmt.Errorf("insert session: %w", err) + } + return token, sess, nil +} + +// Lookup returns the active session for a raw token. It rejects expired and +// revoked sessions and updates last_seen_at (best-effort, errors logged by caller). +func (s *SessionStore) Lookup(ctx context.Context, token string) (Session, error) { + if token == "" { + return Session{}, ErrSessionNotFound + } + const q = ` + UPDATE sessions + SET last_seen_at = now() + WHERE token_hash = $1 + AND revoked_at IS NULL + AND expires_at > now() + RETURNING id, user_id, expires_at, revoked_at` + var sess Session + err := s.pool.QueryRow(ctx, q, hashToken(token)).Scan( + &sess.ID, &sess.UserID, &sess.ExpiresAt, &sess.RevokedAt) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return Session{}, ErrSessionNotFound + } + return Session{}, fmt.Errorf("lookup session: %w", err) + } + return sess, nil +} + +// Revoke marks the session matching token as revoked. Missing tokens are a no-op +// so logout stays idempotent. +func (s *SessionStore) Revoke(ctx context.Context, token string) error { + if token == "" { + return nil + } + const q = `UPDATE sessions SET revoked_at = now() WHERE token_hash = $1 AND revoked_at IS NULL` + _, err := s.pool.Exec(ctx, q, hashToken(token)) + if err != nil { + return fmt.Errorf("revoke session: %w", err) + } + return nil +} + +// hashToken returns the lowercase hex SHA-256 digest of a raw token. The hash is +// what we store; the raw token never touches the database. +func hashToken(token string) string { + sum := sha256.Sum256([]byte(token)) + return base64.RawURLEncoding.EncodeToString(sum[:]) +} diff --git a/backend/internal/auth/user.go b/backend/internal/auth/user.go new file mode 100644 index 0000000..1c501b8 --- /dev/null +++ b/backend/internal/auth/user.go @@ -0,0 +1,95 @@ +package auth + +import ( + "context" + "errors" + "fmt" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// User is the application-side view of a row in the users table. +type User struct { + ID uuid.UUID + Email string + PasswordHash *string // nil for OIDC-only accounts + OIDCSubject *string + OIDCIssuer *string + DisplayName *string +} + +// ErrUserNotFound is returned when no user matches the query. +var ErrUserNotFound = errors.New("user not found") + +// UserStore wraps database access for the users table. +type UserStore struct { + pool *pgxpool.Pool +} + +// NewUserStore constructs a UserStore backed by the given pool. +func NewUserStore(pool *pgxpool.Pool) *UserStore { + return &UserStore{pool: pool} +} + +// CreateUser inserts a new user with a password hash (email/password accounts). +// It returns the created user. Email uniqueness violations surface as ErrEmailTaken. +var ErrEmailTaken = errors.New("email already registered") + +func (s *UserStore) CreateUser(ctx context.Context, email, passwordHash, displayName string) (User, error) { + const q = ` + INSERT INTO users (email, password_hash, display_name) + VALUES ($1, $2, NULLIF($3, '')) + RETURNING id, email, password_hash, oidc_subject, oidc_issuer, display_name` + var u User + var dn *string + err := s.pool.QueryRow(ctx, q, email, passwordHash, displayName). + Scan(&u.ID, &u.Email, &u.PasswordHash, &u.OIDCSubject, &u.OIDCIssuer, &dn) + if err != nil { + if isUniqueViolation(err) { + return User{}, fmt.Errorf("%w: %s", ErrEmailTaken, email) + } + return User{}, fmt.Errorf("create user: %w", err) + } + u.DisplayName = dn + return u, nil +} + +// GetUserByEmail loads a user by its (case-sensitive) email address. +func (s *UserStore) GetUserByEmail(ctx context.Context, email string) (User, error) { + const q = ` + SELECT id, email, password_hash, oidc_subject, oidc_issuer, display_name + FROM users WHERE email = $1` + var u User + var dn *string + err := s.pool.QueryRow(ctx, q, email). + Scan(&u.ID, &u.Email, &u.PasswordHash, &u.OIDCSubject, &u.OIDCIssuer, &dn) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return User{}, ErrUserNotFound + } + return User{}, fmt.Errorf("get user by email: %w", err) + } + u.DisplayName = dn + return u, nil +} + +// GetUserByID loads a user by its primary key. +func (s *UserStore) GetUserByID(ctx context.Context, id uuid.UUID) (User, error) { + const q = ` + SELECT id, email, password_hash, oidc_subject, oidc_issuer, display_name + FROM users WHERE id = $1` + var u User + var dn *string + err := s.pool.QueryRow(ctx, q, id). + Scan(&u.ID, &u.Email, &u.PasswordHash, &u.OIDCSubject, &u.OIDCIssuer, &dn) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return User{}, ErrUserNotFound + } + return User{}, fmt.Errorf("get user by id: %w", err) + } + u.DisplayName = dn + return u, nil +} diff --git a/backend/internal/httpapi/api.go b/backend/internal/httpapi/api.go index 8aa8dae..215fce6 100644 --- a/backend/internal/httpapi/api.go +++ b/backend/internal/httpapi/api.go @@ -5,24 +5,32 @@ import ( "github.com/jackc/pgx/v5/pgxpool" + "github.com/mitbringsl/backend/internal/auth" "github.com/mitbringsl/backend/internal/config" ) // API bundles all handler groups and wires the router. -// Handler groups are added in subsequent phases (auth, lists, items, suggestions). +// Handler groups are added in subsequent phases (lists, items, suggestions). type API struct { cfg *config.Config pool *pgxpool.Pool health *HealthHandler + auth *AuthHandler } // NewAPI constructs the API with all handler groups. func NewAPI(cfg *config.Config, pool *pgxpool.Pool) *API { + users := auth.NewUserStore(pool) + sessions := auth.NewSessionStore(pool, auth.SessionConfig{ + TokenBytes: cfg.SessionTokenBytes, + TTL: cfg.SessionTokenTTL, + }) return &API{ cfg: cfg, pool: pool, health: &HealthHandler{Pool: pool}, + auth: NewAuthHandler(users, sessions, cfg), } } @@ -34,17 +42,17 @@ func (a *API) Handler() http.Handler { mux.HandleFunc("GET /healthz", a.health.Healthz) mux.HandleFunc("GET /readyz", a.health.Readyz) - // --- auth endpoints (added in Phase B) --- - // mux.HandleFunc("POST /auth/register", a.auth.Register) - // mux.HandleFunc("POST /auth/login", a.auth.Login) - // mux.HandleFunc("POST /auth/oidc", a.auth.OIDC) - // mux.HandleFunc("POST /auth/logout", a.auth.Logout) + // --- auth endpoints --- + mux.HandleFunc("POST /auth/register", a.auth.Register) + mux.HandleFunc("POST /auth/login", a.auth.Login) + mux.HandleFunc("POST /auth/logout", a.auth.Logout) + // mux.HandleFunc("POST /auth/oidc", a.auth.OIDC) // Phase B part 2 // --- authenticated API endpoints (added in Phase C) --- - // mux.HandleFunc("GET /api/lists", a.requireAuth(a.lists.List)) - // mux.HandleFunc("GET /api/lists/{id}/ops", a.requireAuth(a.items.PullOps)) - // mux.HandleFunc("POST /api/lists/{id}/ops", a.requireAuth(a.items.PushOps)) - // mux.HandleFunc("GET /api/suggestions", a.requireAuth(a.suggest.Suggest)) + // mux.HandleFunc("GET /api/lists", a.RequireAuth(http.HandlerFunc(a.lists.List))) + // mux.HandleFunc("GET /api/lists/{id}/ops", a.RequireAuth(http.HandlerFunc(a.items.PullOps))) + // mux.HandleFunc("POST /api/lists/{id}/ops", a.RequireAuth(http.HandlerFunc(a.items.PushOps))) + // mux.HandleFunc("GET /api/suggestions", a.RequireAuth(http.HandlerFunc(a.suggest.Suggest))) return Chain( mux, diff --git a/backend/internal/httpapi/auth.go b/backend/internal/httpapi/auth.go new file mode 100644 index 0000000..8cd0486 --- /dev/null +++ b/backend/internal/httpapi/auth.go @@ -0,0 +1,261 @@ +package httpapi + +import ( + "context" + "errors" + "log/slog" + "net/http" + "strings" + "time" + + "github.com/mitbringsl/backend/internal/auth" + "github.com/mitbringsl/backend/internal/config" +) + +// passwordMinLen is the minimum acceptable password length for new accounts. +const passwordMinLen = 8 + +// AuthHandler exposes the email/password + session endpoints. OIDC is added in +// Phase B part 2. +type AuthHandler struct { + users *auth.UserStore + sessions *auth.SessionStore + cfg *config.Config +} + +// NewAuthHandler constructs an AuthHandler from the configured stores. +func NewAuthHandler(users *auth.UserStore, sessions *auth.SessionStore, cfg *config.Config) *AuthHandler { + return &AuthHandler{users: users, sessions: sessions, cfg: cfg} +} + +// --- request / response bodies ---------------------------------------------- + +type registerRequest struct { + Email string `json:"email"` + Password string `json:"password"` + DisplayName string `json:"display_name"` +} + +type loginRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} + +type authResponse struct { + Token string `json:"token"` + ExpiresAt time.Time `json:"expires_at"` + User userDTO `json:"user"` +} + +type userDTO struct { + ID string `json:"id"` + Email string `json:"email"` + DisplayName string `json:"display_name,omitempty"` +} + +// --- handlers --------------------------------------------------------------- + +// Register creates a new email/password account and immediately issues a session. +func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) { + var req registerRequest + if !decodeJSON(w, r, &req) { + return + } + email := strings.TrimSpace(strings.ToLower(req.Email)) + if !isValidEmail(email) { + renderError(w, http.StatusBadRequest, "Invalid email", "A valid email address is required.") + return + } + if len(req.Password) < passwordMinLen { + renderError(w, http.StatusBadRequest, "Password too short", + "Password must be at least 8 characters.") + return + } + + hash, err := auth.HashPassword(req.Password, auth.DefaultArgon2Params) + if err != nil { + slog.Error("hash password failed", "error", err) + renderError(w, http.StatusInternalServerError, "Internal error", "Could not hash password.") + return + } + + user, err := h.users.CreateUser(r.Context(), email, hash, strings.TrimSpace(req.DisplayName)) + if err != nil { + if errors.Is(err, auth.ErrEmailTaken) { + renderError(w, http.StatusConflict, "Email already registered", err.Error()) + return + } + slog.Error("create user failed", "error", err) + renderError(w, http.StatusInternalServerError, "Internal error", "Could not create user.") + return + } + + h.issueSession(w, r, user, http.StatusCreated) +} + +// Login verifies credentials and issues a session. Uses a constant-shape error +// path so a wrong password and an unknown email yield the same response. +func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { + var req loginRequest + if !decodeJSON(w, r, &req) { + return + } + email := strings.TrimSpace(strings.ToLower(req.Email)) + + // Pre-hash a dummy so the timing path is similar regardless of user existence. + dummyHash := dummyHashForTiming() + user, err := h.users.GetUserByEmail(r.Context(), email) + realHash := "" + if err == nil && user.PasswordHash != nil { + realHash = *user.PasswordHash + } + + compareHash := realHash + if compareHash == "" { + compareHash = dummyHash + } + verifyErr := auth.VerifyPassword(req.Password, compareHash) + + if err != nil || user.PasswordHash == nil || verifyErr != nil { + _ = auth.VerifyPassword(req.Password, dummyHash) // absorb dummy cost + renderError(w, http.StatusUnauthorized, "Invalid credentials", "Email or password is incorrect.") + return + } + h.issueSession(w, r, user, http.StatusOK) +} + +// Logout revokes the caller's current session. Always returns 204, even if no +// session was present, so the client can treat logout as best-effort. +func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) { + if token := tokenFromRequest(r, h.cfg.SessionCookieName); token != "" { + if err := h.sessions.Revoke(r.Context(), token); err != nil { + slog.Warn("revoke session failed", "error", err) + } + } + clearSessionCookie(w, h.cfg) + w.WriteHeader(http.StatusNoContent) +} + +// --- helpers ---------------------------------------------------------------- + +// issueSession creates a session, sets the cookie (for browsers) and writes the +// JSON body containing the raw token (for native clients like the Android app). +func (h *AuthHandler) issueSession(w http.ResponseWriter, r *http.Request, u auth.User, status int) { + token, sess, err := h.sessions.Create(r.Context(), u.ID, r.UserAgent(), clientIP(r)) + if err != nil { + slog.Error("create session failed", "error", err, "user_id", u.ID) + renderError(w, http.StatusInternalServerError, "Internal error", "Could not create session.") + return + } + setSessionCookie(w, h.cfg, token, sess.ExpiresAt) + renderJSON(w, status, authResponse{ + Token: token, + ExpiresAt: sess.ExpiresAt, + User: toUserDTO(u), + }) +} + +func toUserDTO(u auth.User) userDTO { + dto := userDTO{ID: u.ID.String(), Email: u.Email} + if u.DisplayName != nil { + dto.DisplayName = *u.DisplayName + } + return dto +} + +// tokenFromRequest extracts the bearer token from the Authorization header, or +// falls back to the session cookie. +func tokenFromRequest(r *http.Request, cookieName string) string { + if h := r.Header.Get("Authorization"); h != "" { + if scheme, cred, ok := strings.Cut(h, " "); ok && strings.EqualFold(scheme, "Bearer") { + return strings.TrimSpace(cred) + } + } + if c, err := r.Cookie(cookieName); err == nil { + return c.Value + } + return "" +} + +func setSessionCookie(w http.ResponseWriter, cfg *config.Config, token string, expires time.Time) { + http.SetCookie(w, &http.Cookie{ + Name: cfg.SessionCookieName, + Value: token, + Path: "/", + Expires: expires, + MaxAge: int(time.Until(expires).Seconds()), + HttpOnly: true, + Secure: cfg.IsProduction(), + SameSite: http.SameSiteLaxMode, + }) +} + +func clearSessionCookie(w http.ResponseWriter, cfg *config.Config) { + http.SetCookie(w, &http.Cookie{ + Name: cfg.SessionCookieName, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + Secure: cfg.IsProduction(), + SameSite: http.SameSiteLaxMode, + }) +} + +// isValidEmail does a pragmatic shape check; full RFC validation is not worth it +// here – the verification round-trip happens at the IdP / app layer. +func isValidEmail(s string) bool { + at := strings.IndexByte(s, '@') + if at <= 0 || at == len(s)-1 { + return false + } + return strings.IndexByte(s[at+1:], '.') >= 0 +} + +// clientIP extracts the peer IP, preferring X-Forwarded-For (Caddy is the only +// upstream in front of the backend). +func clientIP(r *http.Request) string { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + if i := strings.IndexByte(xff, ','); i >= 0 { + return strings.TrimSpace(xff[:i]) + } + return strings.TrimSpace(xff) + } + host := r.RemoteAddr + if i := strings.LastIndex(host, ":"); i > 0 { + host = host[:i] + } + return host +} + +// dummyHashForTiming returns a precomputed Argon2id PHC string used to give the +// same cost when an email is unknown, narrowing user-enumereration timing gaps. +// Generated once with DefaultArgon2Params. +var dummyHashForTiming = func() func() string { + // Generated at init so we always have a valid PHC string available. + h, err := auth.HashPassword("dummy-timing-padding-password", auth.DefaultArgon2Params) + if err != nil { + // Should never happen with a working crypto/rand; fall back to a + // syntactically valid but never-matching hash. + return func() string { return "$argon2id$v=19$m=65536,t=3,p=4$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" } + } + return func() string { return h } +}() + +// --- auth middleware -------------------------------------------------------- + +// RequireAuth wraps next so that it only runs for authenticated requests. On +// success the userID and sessionID are placed in the request context. +func (a *API) RequireAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token := tokenFromRequest(r, a.cfg.SessionCookieName) + sess, err := a.auth.sessions.Lookup(r.Context(), token) + if err != nil { + renderError(w, http.StatusUnauthorized, "Unauthorized", "Valid session required.") + return + } + ctx := context.WithValue(r.Context(), ctxKeyUserID, sess.UserID) + ctx = context.WithValue(ctx, ctxKeySessionID, sess.ID) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +}