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.
This commit is contained in:
parent
2899eb205b
commit
7b1c18590e
10 changed files with 756 additions and 29 deletions
43
AGENTS.md
43
AGENTS.md
|
|
@ -90,11 +90,18 @@ mitbringsl/
|
||||||
│ │ ├── config/config.go # Config (caarlos0/env)
|
│ │ ├── config/config.go # Config (caarlos0/env)
|
||||||
│ │ ├── logging/logging.go # slog JSON-Setup
|
│ │ ├── logging/logging.go # slog JSON-Setup
|
||||||
│ │ ├── store/db.go # pgxpool-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/
|
│ │ └── 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
|
│ │ ├── render.go # JSON-Render + Problem + Fehler-Sentinale + decodeJSON
|
||||||
│ │ ├── middleware.go # requestID/logging/recover/cors + Chain
|
│ │ ├── middleware.go # requestID/logging/recover/cors + Chain
|
||||||
│ │ └── health.go # /healthz + /readyz
|
│ │ ├── health.go # /healthz + /readyz
|
||||||
|
│ │ └── auth.go # Register/Login/Logout-Handler + RequireAuth-Middleware
|
||||||
│ ├── migrations/
|
│ ├── migrations/
|
||||||
│ │ ├── embed.go # //go:embed *.sql
|
│ │ ├── embed.go # //go:embed *.sql
|
||||||
│ │ ├── 000001_init_schema.up.sql # users/sessions/lists/list_members/items/op_log/item_names
|
│ │ ├── 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
|
- ✅ **Phase A – Docker:** Dockerfile (Go 1.26 → distroless nonroot), docker-compose
|
||||||
(caddy/backend/migrate/db), Caddyfile, `.env.example`.
|
(caddy/backend/migrate/db), Caddyfile, `.env.example`.
|
||||||
**Verifiziert:** Image baut, beide Binaries laufen im Container (Smoke-Test OK).
|
**Verifiziert:** Image baut, beide Binaries laufen im Container (Smoke-Test OK).
|
||||||
- ⬜ **Phase B – Auth:** Argon2id + eigene User (register/login) + Session-Middleware.
|
- ✅ **Phase B – Auth (Password):** Argon2id im PHC-Format + `UserStore` (Create/GetByEmail/GetByID)
|
||||||
- ⬜ **Phase B – OIDC:** go-oidc-Verifikation (Google + Generic) + `POST /auth/oidc`.
|
+ `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 – 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 – Endpoints:** `/api/lists`, `/api/lists/{id}/ops` (push+pull).
|
||||||
- ⬜ **Phase C – Suggestions:** `item_names`-Trigger + `/api/suggestions`.
|
- ⬜ **Phase C – Suggestions:** `item_names`-Trigger + `/api/suggestions`.
|
||||||
|
|
@ -138,13 +149,23 @@ Legende: ✅ erledigt · 🚧 in Arbeit · ⬜ offen
|
||||||
- ⬜ **Phase F – README + docs** (ARCHITECTURE/SYNC/API).
|
- ⬜ **Phase F – README + docs** (ARCHITECTURE/SYNC/API).
|
||||||
|
|
||||||
### Wo genau weitermachen?
|
### Wo genau weitermachen?
|
||||||
**Nächster Schritt = Phase B (Auth):**
|
**Nächster Schritt = Phase B Teil 2 (OIDC)** – Teil 1 (Password-Auth) ist fertig ✅:
|
||||||
1. `internal/auth/password.go` – Argon2id (PHC-Format) Hashen/Verifizieren.
|
|
||||||
2. `internal/auth/session.go` – Token generieren (crypto/rand, base64url), SHA-256-Hash,
|
Teil 1 (erledigt):
|
||||||
in `sessions` einfügen, Middleware `requireAuth` (lädt `user_id` in Context).
|
- ✅ `internal/auth/password.go` – Argon2id im PHC-Format (HashPassword/VerifyPassword).
|
||||||
3. `internal/httpapi/auth.go` – Handler `Register`/`Login`/`Logout`.
|
- ✅ `internal/auth/user.go` – UserStore (CreateUser/GetByEmail/GetByID).
|
||||||
4. In `api.go` die `/auth/*`-Routen einkommentieren + verdrahten.
|
- ✅ `internal/auth/session.go` – SessionStore (crypto/rand + base64url + SHA-256, Create/Lookup/Revoke).
|
||||||
5. Dann Phase B Teil 2: OIDC (`internal/auth/oidc.go` + `POST /auth/oidc`).
|
- ✅ `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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,9 @@ go 1.26
|
||||||
require (
|
require (
|
||||||
github.com/caarlos0/env/v11 v11.4.1
|
github.com/caarlos0/env/v11 v11.4.1
|
||||||
github.com/golang-migrate/migrate/v4 v4.18.2
|
github.com/golang-migrate/migrate/v4 v4.18.2
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
github.com/jackc/pgx/v5 v5.10.0
|
github.com/jackc/pgx/v5 v5.10.0
|
||||||
|
golang.org/x/crypto v0.54.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
|
@ -16,6 +18,7 @@ require (
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/lib/pq v1.10.9 // indirect
|
github.com/lib/pq v1.10.9 // indirect
|
||||||
go.uber.org/atomic v1.7.0 // indirect
|
go.uber.org/atomic v1.7.0 // indirect
|
||||||
golang.org/x/sync v0.17.0 // indirect
|
golang.org/x/sync v0.22.0 // indirect
|
||||||
golang.org/x/text v0.29.0 // indirect
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
|
golang.org/x/text v0.40.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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/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 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK1yQYKo6wWQ8=
|
||||||
github.com/golang-migrate/migrate/v4 v4.18.2/go.mod h1:2CM6tJvn2kqPXwnXO/d3rAQYiyoIm180VsO8PRX6Rpk=
|
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.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 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
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.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 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
|
||||||
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
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/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.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
|
|
||||||
137
backend/internal/auth/password.go
Normal file
137
backend/internal/auth/password.go
Normal file
|
|
@ -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=<m>,t=<t>,p=<p>$<salt_b64>$<hash_b64>.
|
||||||
|
// 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=..", "<salt>", "<hash>"]
|
||||||
|
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
|
||||||
|
}
|
||||||
67
backend/internal/auth/password_test.go
Normal file
67
backend/internal/auth/password_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
14
backend/internal/auth/pgcode.go
Normal file
14
backend/internal/auth/pgcode.go
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
117
backend/internal/auth/session.go
Normal file
117
backend/internal/auth/session.go
Normal file
|
|
@ -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[:])
|
||||||
|
}
|
||||||
95
backend/internal/auth/user.go
Normal file
95
backend/internal/auth/user.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -5,24 +5,32 @@ import (
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"github.com/mitbringsl/backend/internal/auth"
|
||||||
"github.com/mitbringsl/backend/internal/config"
|
"github.com/mitbringsl/backend/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// API bundles all handler groups and wires the router.
|
// 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 {
|
type API struct {
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
pool *pgxpool.Pool
|
pool *pgxpool.Pool
|
||||||
|
|
||||||
health *HealthHandler
|
health *HealthHandler
|
||||||
|
auth *AuthHandler
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAPI constructs the API with all handler groups.
|
// NewAPI constructs the API with all handler groups.
|
||||||
func NewAPI(cfg *config.Config, pool *pgxpool.Pool) *API {
|
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{
|
return &API{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
pool: pool,
|
pool: pool,
|
||||||
health: &HealthHandler{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 /healthz", a.health.Healthz)
|
||||||
mux.HandleFunc("GET /readyz", a.health.Readyz)
|
mux.HandleFunc("GET /readyz", a.health.Readyz)
|
||||||
|
|
||||||
// --- auth endpoints (added in Phase B) ---
|
// --- auth endpoints ---
|
||||||
// mux.HandleFunc("POST /auth/register", a.auth.Register)
|
mux.HandleFunc("POST /auth/register", a.auth.Register)
|
||||||
// mux.HandleFunc("POST /auth/login", a.auth.Login)
|
mux.HandleFunc("POST /auth/login", a.auth.Login)
|
||||||
// mux.HandleFunc("POST /auth/oidc", a.auth.OIDC)
|
mux.HandleFunc("POST /auth/logout", a.auth.Logout)
|
||||||
// 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) ---
|
// --- authenticated API endpoints (added in Phase C) ---
|
||||||
// mux.HandleFunc("GET /api/lists", a.requireAuth(a.lists.List))
|
// mux.HandleFunc("GET /api/lists", a.RequireAuth(http.HandlerFunc(a.lists.List)))
|
||||||
// mux.HandleFunc("GET /api/lists/{id}/ops", a.requireAuth(a.items.PullOps))
|
// mux.HandleFunc("GET /api/lists/{id}/ops", a.RequireAuth(http.HandlerFunc(a.items.PullOps)))
|
||||||
// mux.HandleFunc("POST /api/lists/{id}/ops", a.requireAuth(a.items.PushOps))
|
// mux.HandleFunc("POST /api/lists/{id}/ops", a.RequireAuth(http.HandlerFunc(a.items.PushOps)))
|
||||||
// mux.HandleFunc("GET /api/suggestions", a.requireAuth(a.suggest.Suggest))
|
// mux.HandleFunc("GET /api/suggestions", a.RequireAuth(http.HandlerFunc(a.suggest.Suggest)))
|
||||||
|
|
||||||
return Chain(
|
return Chain(
|
||||||
mux,
|
mux,
|
||||||
|
|
|
||||||
261
backend/internal/httpapi/auth.go
Normal file
261
backend/internal/httpapi/auth.go
Normal file
|
|
@ -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))
|
||||||
|
})
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue