From 715423ab8e98bb3155260be987fb1783322e55f8 Mon Sep 17 00:00:00 2001 From: Elijah Date: Thu, 16 Jul 2026 19:45:40 -0700 Subject: [PATCH] Implement owner setup and browser authentication sessions --- README.md | 4 +- api/openapi.yaml | 216 +++++++++ cmd/drive/main.go | 9 +- db/queries/authentication.sql | 162 +++++++ docs/adr/0010-authentication-primitives.md | 24 + docs/adr/README.md | 1 + docs/dependencies.md | 2 +- go.mod | 8 +- go.sum | 14 +- internal/adapters/authn/credentials.go | 46 ++ internal/adapters/authn/password.go | 127 ++++++ internal/adapters/authn/password_test.go | 40 ++ internal/adapters/postgres/authentication.go | 214 +++++++++ .../postgres/generated/authentication.sql.go | 409 +++++++++++++++++ .../adapters/postgres/generated/querier.go | 12 + .../postgres/migrate_integration_test.go | 96 ++++ internal/application/authentication.go | 410 ++++++++++++++++++ internal/domain/identity.go | 73 ++++ internal/domain/identity_test.go | 26 ++ internal/transport/httpapi/server.go | 212 ++++++++- internal/transport/httpapi/server_test.go | 94 ++++ 21 files changed, 2185 insertions(+), 14 deletions(-) create mode 100644 db/queries/authentication.sql create mode 100644 docs/adr/0010-authentication-primitives.md create mode 100644 internal/adapters/authn/credentials.go create mode 100644 internal/adapters/authn/password.go create mode 100644 internal/adapters/authn/password_test.go create mode 100644 internal/adapters/postgres/authentication.go create mode 100644 internal/adapters/postgres/generated/authentication.sql.go create mode 100644 internal/application/authentication.go create mode 100644 internal/domain/identity.go create mode 100644 internal/domain/identity_test.go diff --git a/README.md b/README.md index 929c8ba..a002b89 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ The approved product and architecture baseline is [`Drive_v2_final_plan.md`](Dri ## Repository status -Phase 0 established the repository, module boundaries, contracts, development environment, and CI. Phase 1 is underway with embedded forward-only migrations, the core persistence schema, generated PostgreSQL queries, database-backed readiness, and persistent setup status. Product endpoints are not implemented yet. +Phase 0 established the repository, module boundaries, contracts, development environment, and CI. Phase 1 now includes embedded forward-only migrations, the core persistence schema, atomic single-owner setup, recovery-code generation, Argon2id password hashing, revocable browser sessions, CSRF-protected logout, database-backed readiness, and persistent setup status. File-product endpoints are not implemented yet. ## Prerequisites @@ -25,6 +25,8 @@ Docker is the canonical environment when local toolchains are unavailable. Do not expose an uninitialized production instance through Nginx Proxy Manager. The approved open setup flow must be completed first. +Browser authentication cookies are deliberately `Secure` and use the `__Host-` prefix. Setup and login therefore require HTTPS through Nginx Proxy Manager; direct HTTP remains suitable for health checks and the development shell only. + ## Development - `go test ./cmd/... ./internal/...` runs backend tests. diff --git a/api/openapi.yaml b/api/openapi.yaml index e0d48c1..2be40a3 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -27,6 +27,116 @@ paths: application/problem+json: schema: $ref: "#/components/schemas/Problem" + /api/v1/setup: + post: + security: [] + operationId: createOwner + summary: Atomically create the single owner and initial browser session + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/OwnerSetupRequest" + responses: + "201": + description: Owner, recovery codes, root node, and browser session were created + headers: + Set-Cookie: + description: Secure host-only session and CSRF cookies + schema: + type: string + content: + application/json: + schema: + $ref: "#/components/schemas/OwnerSetupResult" + "400": + $ref: "#/components/responses/InvalidRequest" + "409": + description: Owner setup has already completed + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + "422": + description: Username, display name, or password failed validation + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + "500": + $ref: "#/components/responses/InternalError" + /api/v1/sessions: + post: + security: [] + operationId: createSession + summary: Authenticate the owner and create a revocable browser session + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/LoginRequest" + responses: + "201": + description: Browser session created + headers: + Set-Cookie: + description: Secure host-only session and CSRF cookies + schema: + type: string + content: + application/json: + schema: + $ref: "#/components/schemas/BrowserSession" + "400": + $ref: "#/components/responses/InvalidRequest" + "401": + $ref: "#/components/responses/AuthenticationRequired" + "500": + $ref: "#/components/responses/InternalError" + /api/v1/sessions/current: + get: + security: + - browserSession: [] + operationId: getCurrentSession + summary: Read the current browser session + responses: + "200": + description: Current browser session + content: + application/json: + schema: + $ref: "#/components/schemas/BrowserSession" + "401": + $ref: "#/components/responses/AuthenticationRequired" + "500": + $ref: "#/components/responses/InternalError" + delete: + security: + - browserSession: [] + operationId: deleteCurrentSession + summary: Revoke the current browser session + parameters: + - name: X-CSRF-Token + in: header + required: true + schema: + type: string + minLength: 32 + responses: + "204": + description: Session revoked and browser cookies expired + "401": + $ref: "#/components/responses/AuthenticationRequired" + "403": + description: CSRF validation failed + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + "500": + $ref: "#/components/responses/InternalError" /health/live: get: security: [] @@ -58,7 +168,113 @@ paths: schema: $ref: "#/components/schemas/Problem" components: + securitySchemes: + browserSession: + type: apiKey + in: cookie + name: __Host-drive_session + responses: + InvalidRequest: + description: Request body is malformed or contains unknown fields + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + AuthenticationRequired: + description: A valid browser session is required + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + InternalError: + description: The operation failed unexpectedly + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" schemas: + OwnerSetupRequest: + type: object + additionalProperties: false + required: [username, displayName, password] + properties: + username: + type: string + minLength: 1 + maxLength: 128 + displayName: + type: string + minLength: 1 + maxLength: 128 + password: + type: string + format: password + minLength: 12 + maxLength: 1024 + LoginRequest: + type: object + additionalProperties: false + required: [username, password] + properties: + username: + type: string + minLength: 1 + maxLength: 128 + password: + type: string + format: password + maxLength: 1024 + OwnerSetupResult: + type: object + required: [owner, recoveryCodes, session] + properties: + owner: + $ref: "#/components/schemas/Owner" + recoveryCodes: + type: array + minItems: 10 + maxItems: 10 + items: + type: string + session: + $ref: "#/components/schemas/BrowserSession" + Owner: + type: object + required: [id, username, displayName, rootNodeId] + properties: + id: + type: string + format: uuid + username: + type: string + displayName: + type: string + rootNodeId: + type: string + format: uuid + BrowserSession: + type: object + required: [id, ownerId, username, displayName, authenticatedAt, lastSeenAt, expiresAt] + properties: + id: + type: string + format: uuid + ownerId: + type: string + format: uuid + username: + type: string + displayName: + type: string + authenticatedAt: + type: string + format: date-time + lastSeenAt: + type: string + format: date-time + expiresAt: + type: string + format: date-time SetupStatus: type: object required: [initialized, phase, version] diff --git a/cmd/drive/main.go b/cmd/drive/main.go index 7e2cb70..1659a29 100644 --- a/cmd/drive/main.go +++ b/cmd/drive/main.go @@ -10,6 +10,7 @@ import ( "syscall" "time" + "drive.local/drivev2/internal/adapters/authn" "drive.local/drivev2/internal/adapters/postgres" "drive.local/drivev2/internal/adapters/webassets" "drive.local/drivev2/internal/application" @@ -67,7 +68,13 @@ func serve() error { defer store.Close() systemService := application.NewSystemService(store, version) - server := httpapi.NewServer(cfg, logger, systemService, webassets.New(cfg.WebRoot)) + authenticationService := application.NewAuthenticationService( + store, + authn.NewPasswordHasher(), + authn.CredentialGenerator{}, + authn.SystemClock{}, + ) + server := httpapi.NewServer(cfg, logger, systemService, authenticationService, webassets.New(cfg.WebRoot)) ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() diff --git a/db/queries/authentication.sql b/db/queries/authentication.sql new file mode 100644 index 0000000..d3a7969 --- /dev/null +++ b/db/queries/authentication.sql @@ -0,0 +1,162 @@ +-- name: CreateOwner :one +INSERT INTO users (username, username_key, display_name, password_hash) +VALUES ( + sqlc.arg(username), + sqlc.arg(username_key), + sqlc.arg(display_name), + sqlc.arg(password_hash) +) +RETURNING id::TEXT AS id; + +-- name: CreateOwnerRootNode :one +INSERT INTO nodes ( + owner_id, + parent_id, + name, + name_key, + node_type, + size_bytes, + node_revision, + content_revision, + content_modified_at, + metadata_updated_at +) +VALUES ( + sqlc.arg(owner_id)::UUID, + NULL, + 'Drive', + 'drive', + 'directory', + 0, + 1, + 0, + sqlc.arg(created_at), + sqlc.arg(created_at) +) +RETURNING id::TEXT AS id; + +-- name: SetOwnerRootNode :exec +UPDATE users +SET root_node_id = sqlc.arg(root_node_id)::UUID, + updated_at = sqlc.arg(updated_at) +WHERE id = sqlc.arg(owner_id)::UUID; + +-- name: CreateRecoveryCode :exec +INSERT INTO recovery_codes (user_id, code_hash) +VALUES (sqlc.arg(owner_id)::UUID, sqlc.arg(code_hash)); + +-- name: CreateBrowserSession :one +INSERT INTO sessions ( + owner_id, + token_hash, + csrf_token_hash, + remote_address, + user_agent, + authenticated_at, + last_seen_at, + expires_at +) +VALUES ( + sqlc.arg(owner_id)::UUID, + sqlc.arg(token_hash), + sqlc.arg(csrf_token_hash), + NULLIF(sqlc.arg(remote_address), '')::INET, + NULLIF(sqlc.arg(user_agent), ''), + sqlc.arg(authenticated_at), + sqlc.arg(authenticated_at), + sqlc.arg(expires_at) +) +RETURNING id::TEXT AS id; + +-- name: CreateInitialRootChange :exec +INSERT INTO changes ( + owner_id, + event_type, + node_id, + node_revision, + content_revision, + resulting_state, + mutation_origin, + committed_at +) +VALUES ( + sqlc.arg(owner_id)::UUID, + 'node.created', + sqlc.arg(root_node_id)::UUID, + 1, + 0, + sqlc.arg(resulting_state)::JSONB, + sqlc.arg(mutation_origin)::JSONB, + sqlc.arg(committed_at) +); + +-- name: CreateAuthenticationAuditEvent :exec +INSERT INTO audit_events ( + owner_id, + actor, + action, + resource_type, + resource_id, + outcome, + details, + occurred_at +) +VALUES ( + sqlc.arg(owner_id)::UUID, + sqlc.arg(actor)::JSONB, + sqlc.arg(action), + sqlc.arg(resource_type), + sqlc.arg(resource_id)::UUID, + sqlc.arg(outcome), + sqlc.arg(details)::JSONB, + sqlc.arg(occurred_at) +); + +-- name: GetOwnerAuthenticationRecord :one +SELECT + id::TEXT AS id, + username, + username_key, + display_name, + password_hash +FROM users +LIMIT 1; + +-- name: GetActiveBrowserSession :one +SELECT + sessions.id::TEXT AS session_id, + sessions.owner_id::TEXT AS owner_id, + users.username, + users.display_name, + sessions.csrf_token_hash, + sessions.authenticated_at, + sessions.last_seen_at, + sessions.expires_at +FROM sessions +JOIN users ON users.id = sessions.owner_id +WHERE sessions.token_hash = sqlc.arg(token_hash) + AND sessions.revoked_at IS NULL + AND sessions.expires_at > sqlc.arg(now_at) +LIMIT 1; + +-- name: TouchBrowserSession :exec +UPDATE sessions +SET last_seen_at = sqlc.arg(seen_at) +WHERE id = sqlc.arg(session_id)::UUID + AND revoked_at IS NULL; + +-- name: RevokeBrowserSession :execrows +UPDATE sessions +SET revoked_at = sqlc.arg(revoked_at) +WHERE id = sqlc.arg(session_id)::UUID + AND revoked_at IS NULL; + +-- name: GetAuthenticationInvariantCounts :one +SELECT + (SELECT count(*) FROM users)::BIGINT AS owners, + (SELECT count(*) FROM nodes WHERE parent_id IS NULL)::BIGINT AS roots, + (SELECT count(*) FROM recovery_codes)::BIGINT AS recovery_codes, + (SELECT count(*) FROM changes)::BIGINT AS changes, + (SELECT count(*) FROM audit_events)::BIGINT AS audit_events, + (SELECT count(*) FROM sessions)::BIGINT AS sessions, + (SELECT count(*) FROM sessions WHERE revoked_at IS NULL)::BIGINT AS active_sessions; diff --git a/docs/adr/0010-authentication-primitives.md b/docs/adr/0010-authentication-primitives.md new file mode 100644 index 0000000..39437ac --- /dev/null +++ b/docs/adr/0010-authentication-primitives.md @@ -0,0 +1,24 @@ +# ADR-0010: Authentication primitives and browser session policy + +Status: Accepted + +## Context + +The architecture baseline requires Argon2id password hashing, revocable server-side sessions, secure cookie authentication, and CSRF protection. Phase 1 needs concrete parameters and token handling rules before owner setup and login can be implemented consistently. + +## Decision + +- Passwords are encoded in the standard Argon2id PHC string format with the algorithm version and cost parameters embedded in every hash. +- New hashes use Argon2id version 19 with 64 MiB of memory, three iterations, four lanes, a 16-byte random salt, and a 32-byte derived key. Verification rejects malformed or unreasonable encoded parameters before allocating memory. Parameters may be raised later without invalidating stored hashes. +- `golang.org/x/crypto/argon2` is the only new cryptographic runtime dependency. Drive owns the small PHC encoder/parser around it rather than adding another wrapper library. +- Session and CSRF secrets are independent 256-bit values from `crypto/rand`. PostgreSQL stores only SHA-256 digests. Plain session secrets exist only in the secure cookie and the request that creates it. +- Browser sessions use `Secure`, `HttpOnly`, `SameSite=Strict`, host-only cookies. The CSRF companion cookie is deliberately readable by browser code and its value must match the `X-CSRF-Token` header and the digest stored with the authenticated session. +- Session cookies use the `__Host-` prefix and `Path=/`, so insecure direct-HTTP browser authentication is not supported. Development authentication uses HTTPS through the configured reverse proxy or direct API tests. +- Session lifetime is 30 days. Revocation is authoritative in PostgreSQL; no browser JWT is introduced. +- First-run setup creates the owner, root node, attributed initial change event, audit event, recovery codes, and first session in one PostgreSQL transaction. The existing single-owner unique constraint decides concurrent setup attempts, making setup a single-winner operation. + +## Consequences + +- The implementation follows the baseline without inventing a second credential mechanism. +- Authentication consumes bounded memory per verification. Persistent rate limiting and trusted-proxy client-address handling remain required Phase 1 follow-up work before production exposure. +- Browsers must reach Drive over HTTPS for setup and login. This matches the intended Nginx Proxy Manager deployment. diff --git a/docs/adr/README.md b/docs/adr/README.md index c76336e..8eada40 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -13,3 +13,4 @@ ADRs are immutable after acceptance. Supersede an earlier decision with a new AD | 0007 | Open first-run setup | Accepted risk | | 0008 | Sync-capable server contract | Accepted | | 0009 | External protocol gateway boundary | Accepted | +| 0010 | Authentication primitives and browser session policy | Accepted | diff --git a/docs/dependencies.md b/docs/dependencies.md index 62eaf8e..757c7ef 100644 --- a/docs/dependencies.md +++ b/docs/dependencies.md @@ -4,7 +4,7 @@ Dependencies are pinned in `go.mod`, `web/package.json`, the npm lockfile, conta ## Phase 1 baseline -Backend production code uses pgx v5 for PostgreSQL access and golang-migrate v4 for embedded, forward-only migrations. sqlc 1.31.1 generates the PostgreSQL repository. Chi remains approved by the architecture baseline and will be pinned when routing needs exceed the standard library multiplexer. +Backend production code uses pgx v5 for PostgreSQL access, golang-migrate v4 for embedded forward-only migrations, `golang.org/x/crypto/argon2` for the Argon2id requirement in ADR-0010, and `golang.org/x/text` for Unicode normalization and case folding. `pgerrcode` maps PostgreSQL constraint failures without string parsing. sqlc 1.31.1 generates the PostgreSQL repository. Chi remains approved by the architecture baseline and will be pinned when routing needs exceed the standard library multiplexer. Frontend runtime dependencies are React, React DOM, TanStack Query, and TanStack Router. Development dependencies provide TypeScript, Vite, Vitest, ESLint, React tooling, and type declarations. diff --git a/go.mod b/go.mod index de600e6..c154811 100644 --- a/go.mod +++ b/go.mod @@ -4,14 +4,16 @@ go 1.26.0 require ( github.com/golang-migrate/migrate/v4 v4.19.1 + github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa github.com/jackc/pgx/v5 v5.10.0 + golang.org/x/crypto v0.54.0 + golang.org/x/text v0.40.0 ) require ( - github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - golang.org/x/sync v0.18.0 // indirect - golang.org/x/text v0.31.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect ) diff --git a/go.sum b/go.sum index 2718037..6ca1449 100644 --- a/go.sum +++ b/go.sum @@ -71,12 +71,14 @@ go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/Wgbsd go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= -golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +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/internal/adapters/authn/credentials.go b/internal/adapters/authn/credentials.go new file mode 100644 index 0000000..cac9761 --- /dev/null +++ b/internal/adapters/authn/credentials.go @@ -0,0 +1,46 @@ +package authn + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base32" + "encoding/base64" + "fmt" + "strings" + "time" + + "drive.local/drivev2/internal/application" +) + +type CredentialGenerator struct{} + +func (CredentialGenerator) NewCredential() (application.GeneratedCredential, error) { + secret := make([]byte, 32) + if _, err := rand.Read(secret); err != nil { + return application.GeneratedCredential{}, fmt.Errorf("generate credential: %w", err) + } + plaintext := base64.RawURLEncoding.EncodeToString(secret) + digest := sha256.Sum256([]byte(plaintext)) + return application.GeneratedCredential{Plaintext: plaintext, Digest: digest[:]}, nil +} + +func (CredentialGenerator) NewRecoveryCodes(count int) ([]application.GeneratedCredential, error) { + codes := make([]application.GeneratedCredential, 0, count) + for range count { + raw := make([]byte, 12) + if _, err := rand.Read(raw); err != nil { + return nil, fmt.Errorf("generate recovery code: %w", err) + } + encoded := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(raw) + plaintext := strings.Join([]string{encoded[0:5], encoded[5:10], encoded[10:15], encoded[15:20]}, "-") + digest := sha256.Sum256([]byte(plaintext)) + codes = append(codes, application.GeneratedCredential{Plaintext: plaintext, Digest: digest[:]}) + } + return codes, nil +} + +type SystemClock struct{} + +func (SystemClock) Now() time.Time { + return time.Now() +} diff --git a/internal/adapters/authn/password.go b/internal/adapters/authn/password.go new file mode 100644 index 0000000..8c98f37 --- /dev/null +++ b/internal/adapters/authn/password.go @@ -0,0 +1,127 @@ +// Package authn implements password hashing and high-entropy credential generation. +package authn + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "errors" + "fmt" + "strconv" + "strings" + + "golang.org/x/crypto/argon2" +) + +var ErrInvalidPasswordHash = errors.New("invalid password hash") + +type Argon2Parameters struct { + MemoryKiB uint32 + Time uint32 + Threads uint8 + SaltBytes uint32 + KeyBytes uint32 +} + +type PasswordHasher struct { + parameters Argon2Parameters +} + +func NewPasswordHasher() PasswordHasher { + return PasswordHasher{parameters: Argon2Parameters{ + MemoryKiB: 64 * 1024, + Time: 3, + Threads: 4, + SaltBytes: 16, + KeyBytes: 32, + }} +} + +func newPasswordHasher(parameters Argon2Parameters) PasswordHasher { + return PasswordHasher{parameters: parameters} +} + +func (h PasswordHasher) Hash(password string) (string, error) { + salt := make([]byte, h.parameters.SaltBytes) + if _, err := rand.Read(salt); err != nil { + return "", fmt.Errorf("generate password salt: %w", err) + } + derived := argon2.IDKey([]byte(password), salt, h.parameters.Time, h.parameters.MemoryKiB, h.parameters.Threads, h.parameters.KeyBytes) + return fmt.Sprintf( + "$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", + argon2.Version, + h.parameters.MemoryKiB, + h.parameters.Time, + h.parameters.Threads, + base64.RawStdEncoding.EncodeToString(salt), + base64.RawStdEncoding.EncodeToString(derived), + ), nil +} + +func (h PasswordHasher) Verify(password, encoded string) (bool, error) { + parameters, salt, expected, err := parsePasswordHash(encoded) + if err != nil { + return false, err + } + actual := argon2.IDKey([]byte(password), salt, parameters.Time, parameters.MemoryKiB, parameters.Threads, uint32(len(expected))) + return subtle.ConstantTimeCompare(actual, expected) == 1, nil +} + +func parsePasswordHash(encoded string) (Argon2Parameters, []byte, []byte, error) { + parts := strings.Split(encoded, "$") + if len(parts) != 6 || parts[0] != "" || parts[1] != "argon2id" { + return Argon2Parameters{}, nil, nil, ErrInvalidPasswordHash + } + + version, err := parseNamedUint(parts[2], "v", 8) + if err != nil || version != argon2.Version { + return Argon2Parameters{}, nil, nil, ErrInvalidPasswordHash + } + parameters, err := parseArgon2Parameters(parts[3]) + if err != nil { + return Argon2Parameters{}, nil, nil, err + } + salt, err := base64.RawStdEncoding.Strict().DecodeString(parts[4]) + if err != nil || len(salt) < 16 || len(salt) > 64 { + return Argon2Parameters{}, nil, nil, ErrInvalidPasswordHash + } + derived, err := base64.RawStdEncoding.Strict().DecodeString(parts[5]) + if err != nil || len(derived) < 16 || len(derived) > 64 { + return Argon2Parameters{}, nil, nil, ErrInvalidPasswordHash + } + parameters.SaltBytes = uint32(len(salt)) + parameters.KeyBytes = uint32(len(derived)) + return parameters, salt, derived, nil +} + +func parseArgon2Parameters(value string) (Argon2Parameters, error) { + parts := strings.Split(value, ",") + if len(parts) != 3 { + return Argon2Parameters{}, ErrInvalidPasswordHash + } + memory, err := parseNamedUint(parts[0], "m", 32) + if err != nil || memory < 8*1024 || memory > 1024*1024 { + return Argon2Parameters{}, ErrInvalidPasswordHash + } + timeCost, err := parseNamedUint(parts[1], "t", 32) + if err != nil || timeCost == 0 || timeCost > 10 { + return Argon2Parameters{}, ErrInvalidPasswordHash + } + threads, err := parseNamedUint(parts[2], "p", 8) + if err != nil || threads == 0 || threads > 16 { + return Argon2Parameters{}, ErrInvalidPasswordHash + } + return Argon2Parameters{MemoryKiB: uint32(memory), Time: uint32(timeCost), Threads: uint8(threads)}, nil +} + +func parseNamedUint(value, name string, bitSize int) (uint64, error) { + prefix := name + "=" + if !strings.HasPrefix(value, prefix) { + return 0, ErrInvalidPasswordHash + } + parsed, err := strconv.ParseUint(strings.TrimPrefix(value, prefix), 10, bitSize) + if err != nil { + return 0, ErrInvalidPasswordHash + } + return parsed, nil +} diff --git a/internal/adapters/authn/password_test.go b/internal/adapters/authn/password_test.go new file mode 100644 index 0000000..73648a3 --- /dev/null +++ b/internal/adapters/authn/password_test.go @@ -0,0 +1,40 @@ +package authn + +import ( + "errors" + "testing" +) + +func TestPasswordHasherRoundTrip(t *testing.T) { + t.Parallel() + + hasher := newPasswordHasher(Argon2Parameters{MemoryKiB: 8 * 1024, Time: 1, Threads: 1, SaltBytes: 16, KeyBytes: 32}) + encoded, err := hasher.Hash("a correct horse battery staple") + if err != nil { + t.Fatalf("Hash returned an error: %v", err) + } + valid, err := hasher.Verify("a correct horse battery staple", encoded) + if err != nil { + t.Fatalf("Verify returned an error: %v", err) + } + if !valid { + t.Fatal("Verify = false, want true") + } + valid, err = hasher.Verify("wrong password", encoded) + if err != nil { + t.Fatalf("Verify wrong password returned an error: %v", err) + } + if valid { + t.Fatal("Verify wrong password = true, want false") + } +} + +func TestPasswordHasherRejectsUnboundedParameters(t *testing.T) { + t.Parallel() + + hasher := NewPasswordHasher() + _, err := hasher.Verify("password", "$argon2id$v=19$m=4294967295,t=3,p=4$c2FsdHNhbHRzYWx0c2FsdA$ZGVyaXZlZGRlcml2ZWRkZXJpdmVkZGVyaXZlZA") + if !errors.Is(err, ErrInvalidPasswordHash) { + t.Fatalf("Verify error = %v, want %v", err, ErrInvalidPasswordHash) + } +} diff --git a/internal/adapters/postgres/authentication.go b/internal/adapters/postgres/authentication.go new file mode 100644 index 0000000..b61eb97 --- /dev/null +++ b/internal/adapters/postgres/authentication.go @@ -0,0 +1,214 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + "time" + + dbgen "drive.local/drivev2/internal/adapters/postgres/generated" + "drive.local/drivev2/internal/application" + "github.com/jackc/pgerrcode" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgtype" +) + +func (s *Store) WithAuthTransaction(ctx context.Context, operation func(application.AuthTransaction) error) (returnErr error) { + transaction, err := s.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable}) + if err != nil { + return fmt.Errorf("begin authentication transaction: %w", err) + } + defer func() { + if err := transaction.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) { + returnErr = errors.Join(returnErr, fmt.Errorf("rollback authentication transaction: %w", err)) + } + }() + + transactionStore := &Store{queries: s.queries.WithTx(transaction)} + if err := operation(transactionStore); err != nil { + return err + } + if err := transaction.Commit(ctx); err != nil { + return fmt.Errorf("commit authentication transaction: %w", err) + } + return nil +} + +func (s *Store) CreateOwner(ctx context.Context, record application.CreateOwnerRecord) (string, error) { + id, err := s.queries.CreateOwner(ctx, dbgen.CreateOwnerParams{ + Username: record.Username, UsernameKey: record.UsernameKey, DisplayName: record.DisplayName, PasswordHash: record.PasswordHash, + }) + if ownerAlreadyExists(err) { + return "", application.ErrSetupAlreadyComplete + } + if err != nil { + return "", fmt.Errorf("create owner: %w", err) + } + return id, nil +} + +func (s *Store) CreateOwnerRoot(ctx context.Context, ownerID string, createdAt time.Time) (string, error) { + ownerUUID, err := parseUUID(ownerID) + if err != nil { + return "", err + } + id, err := s.queries.CreateOwnerRootNode(ctx, dbgen.CreateOwnerRootNodeParams{ + OwnerID: ownerUUID, CreatedAt: timestamp(createdAt), + }) + if err != nil { + return "", fmt.Errorf("create owner root node: %w", err) + } + return id, nil +} + +func (s *Store) SetOwnerRoot(ctx context.Context, ownerID, rootNodeID string, updatedAt time.Time) error { + ownerUUID, err := parseUUID(ownerID) + if err != nil { + return err + } + rootUUID, err := parseUUID(rootNodeID) + if err != nil { + return err + } + if err := s.queries.SetOwnerRootNode(ctx, dbgen.SetOwnerRootNodeParams{ + RootNodeID: rootUUID, UpdatedAt: timestamp(updatedAt), OwnerID: ownerUUID, + }); err != nil { + return fmt.Errorf("set owner root node: %w", err) + } + return nil +} + +func (s *Store) CreateRecoveryCode(ctx context.Context, ownerID string, codeHash []byte) error { + ownerUUID, err := parseUUID(ownerID) + if err != nil { + return err + } + if err := s.queries.CreateRecoveryCode(ctx, dbgen.CreateRecoveryCodeParams{OwnerID: ownerUUID, CodeHash: codeHash}); err != nil { + return fmt.Errorf("create recovery code: %w", err) + } + return nil +} + +func (s *Store) CreateBrowserSession(ctx context.Context, record application.CreateSessionRecord) (string, error) { + ownerUUID, err := parseUUID(record.OwnerID) + if err != nil { + return "", err + } + id, err := s.queries.CreateBrowserSession(ctx, dbgen.CreateBrowserSessionParams{ + OwnerID: ownerUUID, TokenHash: record.TokenHash, CsrfTokenHash: record.CSRFTokenHash, + RemoteAddress: record.RemoteAddress, UserAgent: record.UserAgent, + AuthenticatedAt: timestamp(record.AuthenticatedAt), ExpiresAt: timestamp(record.ExpiresAt), + }) + if err != nil { + return "", fmt.Errorf("create browser session: %w", err) + } + return id, nil +} + +func (s *Store) CreateInitialRootChange(ctx context.Context, record application.InitialRootChangeRecord) error { + ownerUUID, err := parseUUID(record.OwnerID) + if err != nil { + return err + } + rootUUID, err := parseUUID(record.RootNodeID) + if err != nil { + return err + } + if err := s.queries.CreateInitialRootChange(ctx, dbgen.CreateInitialRootChangeParams{ + OwnerID: ownerUUID, RootNodeID: rootUUID, ResultingState: record.ResultingState, + MutationOrigin: record.MutationOrigin, CommittedAt: timestamp(record.CommittedAt), + }); err != nil { + return fmt.Errorf("create initial root change: %w", err) + } + return nil +} + +func (s *Store) CreateAuthenticationAudit(ctx context.Context, record application.AuthenticationAuditRecord) error { + ownerUUID, err := parseUUID(record.OwnerID) + if err != nil { + return err + } + resourceUUID, err := parseUUID(record.ResourceID) + if err != nil { + return err + } + resourceType := record.ResourceType + if err := s.queries.CreateAuthenticationAuditEvent(ctx, dbgen.CreateAuthenticationAuditEventParams{ + OwnerID: ownerUUID, Actor: record.Actor, Action: record.Action, ResourceType: &resourceType, + ResourceID: resourceUUID, Outcome: record.Outcome, Details: record.Details, OccurredAt: timestamp(record.OccurredAt), + }); err != nil { + return fmt.Errorf("create authentication audit event: %w", err) + } + return nil +} + +func (s *Store) GetOwnerAuthenticationRecord(ctx context.Context) (application.OwnerAuthenticationRecord, error) { + record, err := s.queries.GetOwnerAuthenticationRecord(ctx) + if errors.Is(err, pgx.ErrNoRows) { + return application.OwnerAuthenticationRecord{}, application.ErrOwnerNotFound + } + if err != nil { + return application.OwnerAuthenticationRecord{}, fmt.Errorf("get owner authentication record: %w", err) + } + return application.OwnerAuthenticationRecord{ + ID: record.ID, Username: record.Username, UsernameKey: record.UsernameKey, + DisplayName: record.DisplayName, PasswordHash: record.PasswordHash, + }, nil +} + +func (s *Store) GetActiveBrowserSession(ctx context.Context, tokenHash []byte, now time.Time) (application.BrowserSession, error) { + record, err := s.queries.GetActiveBrowserSession(ctx, dbgen.GetActiveBrowserSessionParams{TokenHash: tokenHash, NowAt: timestamp(now)}) + if errors.Is(err, pgx.ErrNoRows) { + return application.BrowserSession{}, application.ErrSessionNotFound + } + if err != nil { + return application.BrowserSession{}, fmt.Errorf("get active browser session: %w", err) + } + return application.BrowserSession{ + ID: record.SessionID, OwnerID: record.OwnerID, Username: record.Username, DisplayName: record.DisplayName, + CSRFTokenHash: record.CsrfTokenHash, AuthenticatedAt: record.AuthenticatedAt.Time, + LastSeenAt: record.LastSeenAt.Time, ExpiresAt: record.ExpiresAt.Time, + }, nil +} + +func (s *Store) TouchBrowserSession(ctx context.Context, sessionID string, seenAt time.Time) error { + sessionUUID, err := parseUUID(sessionID) + if err != nil { + return err + } + if err := s.queries.TouchBrowserSession(ctx, dbgen.TouchBrowserSessionParams{SeenAt: timestamp(seenAt), SessionID: sessionUUID}); err != nil { + return fmt.Errorf("touch browser session: %w", err) + } + return nil +} + +func (s *Store) RevokeBrowserSession(ctx context.Context, sessionID string, revokedAt time.Time) (bool, error) { + sessionUUID, err := parseUUID(sessionID) + if err != nil { + return false, err + } + rows, err := s.queries.RevokeBrowserSession(ctx, dbgen.RevokeBrowserSessionParams{RevokedAt: timestamp(revokedAt), SessionID: sessionUUID}) + if err != nil { + return false, fmt.Errorf("revoke browser session: %w", err) + } + return rows == 1, nil +} + +func parseUUID(value string) (pgtype.UUID, error) { + var parsed pgtype.UUID + if err := parsed.Scan(value); err != nil { + return pgtype.UUID{}, fmt.Errorf("parse UUID: %w", err) + } + return parsed, nil +} + +func timestamp(value time.Time) pgtype.Timestamptz { + return pgtype.Timestamptz{Time: value, Valid: true} +} + +func ownerAlreadyExists(err error) bool { + var postgresError *pgconn.PgError + return errors.As(err, &postgresError) && postgresError.Code == pgerrcode.UniqueViolation && + (postgresError.ConstraintName == "users_single_owner" || postgresError.ConstraintName == "users_username_key_unique") +} diff --git a/internal/adapters/postgres/generated/authentication.sql.go b/internal/adapters/postgres/generated/authentication.sql.go new file mode 100644 index 0000000..bb09e92 --- /dev/null +++ b/internal/adapters/postgres/generated/authentication.sql.go @@ -0,0 +1,409 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: authentication.sql + +package dbgen + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createAuthenticationAuditEvent = `-- name: CreateAuthenticationAuditEvent :exec +INSERT INTO audit_events ( + owner_id, + actor, + action, + resource_type, + resource_id, + outcome, + details, + occurred_at +) +VALUES ( + $1::UUID, + $2::JSONB, + $3, + $4, + $5::UUID, + $6, + $7::JSONB, + $8 +) +` + +type CreateAuthenticationAuditEventParams struct { + OwnerID pgtype.UUID `db:"owner_id" json:"owner_id"` + Actor []byte `db:"actor" json:"actor"` + Action string `db:"action" json:"action"` + ResourceType *string `db:"resource_type" json:"resource_type"` + ResourceID pgtype.UUID `db:"resource_id" json:"resource_id"` + Outcome string `db:"outcome" json:"outcome"` + Details []byte `db:"details" json:"details"` + OccurredAt pgtype.Timestamptz `db:"occurred_at" json:"occurred_at"` +} + +func (q *Queries) CreateAuthenticationAuditEvent(ctx context.Context, arg CreateAuthenticationAuditEventParams) error { + _, err := q.db.Exec(ctx, createAuthenticationAuditEvent, + arg.OwnerID, + arg.Actor, + arg.Action, + arg.ResourceType, + arg.ResourceID, + arg.Outcome, + arg.Details, + arg.OccurredAt, + ) + return err +} + +const createBrowserSession = `-- name: CreateBrowserSession :one +INSERT INTO sessions ( + owner_id, + token_hash, + csrf_token_hash, + remote_address, + user_agent, + authenticated_at, + last_seen_at, + expires_at +) +VALUES ( + $1::UUID, + $2, + $3, + NULLIF($4, '')::INET, + NULLIF($5, ''), + $6, + $6, + $7 +) +RETURNING id::TEXT AS id +` + +type CreateBrowserSessionParams struct { + OwnerID pgtype.UUID `db:"owner_id" json:"owner_id"` + TokenHash []byte `db:"token_hash" json:"token_hash"` + CsrfTokenHash []byte `db:"csrf_token_hash" json:"csrf_token_hash"` + RemoteAddress interface{} `db:"remote_address" json:"remote_address"` + UserAgent interface{} `db:"user_agent" json:"user_agent"` + AuthenticatedAt pgtype.Timestamptz `db:"authenticated_at" json:"authenticated_at"` + ExpiresAt pgtype.Timestamptz `db:"expires_at" json:"expires_at"` +} + +func (q *Queries) CreateBrowserSession(ctx context.Context, arg CreateBrowserSessionParams) (string, error) { + row := q.db.QueryRow(ctx, createBrowserSession, + arg.OwnerID, + arg.TokenHash, + arg.CsrfTokenHash, + arg.RemoteAddress, + arg.UserAgent, + arg.AuthenticatedAt, + arg.ExpiresAt, + ) + var id string + err := row.Scan(&id) + return id, err +} + +const createInitialRootChange = `-- name: CreateInitialRootChange :exec +INSERT INTO changes ( + owner_id, + event_type, + node_id, + node_revision, + content_revision, + resulting_state, + mutation_origin, + committed_at +) +VALUES ( + $1::UUID, + 'node.created', + $2::UUID, + 1, + 0, + $3::JSONB, + $4::JSONB, + $5 +) +` + +type CreateInitialRootChangeParams struct { + OwnerID pgtype.UUID `db:"owner_id" json:"owner_id"` + RootNodeID pgtype.UUID `db:"root_node_id" json:"root_node_id"` + ResultingState []byte `db:"resulting_state" json:"resulting_state"` + MutationOrigin []byte `db:"mutation_origin" json:"mutation_origin"` + CommittedAt pgtype.Timestamptz `db:"committed_at" json:"committed_at"` +} + +func (q *Queries) CreateInitialRootChange(ctx context.Context, arg CreateInitialRootChangeParams) error { + _, err := q.db.Exec(ctx, createInitialRootChange, + arg.OwnerID, + arg.RootNodeID, + arg.ResultingState, + arg.MutationOrigin, + arg.CommittedAt, + ) + return err +} + +const createOwner = `-- name: CreateOwner :one +INSERT INTO users (username, username_key, display_name, password_hash) +VALUES ( + $1, + $2, + $3, + $4 +) +RETURNING id::TEXT AS id +` + +type CreateOwnerParams struct { + Username string `db:"username" json:"username"` + UsernameKey string `db:"username_key" json:"username_key"` + DisplayName string `db:"display_name" json:"display_name"` + PasswordHash string `db:"password_hash" json:"password_hash"` +} + +func (q *Queries) CreateOwner(ctx context.Context, arg CreateOwnerParams) (string, error) { + row := q.db.QueryRow(ctx, createOwner, + arg.Username, + arg.UsernameKey, + arg.DisplayName, + arg.PasswordHash, + ) + var id string + err := row.Scan(&id) + return id, err +} + +const createOwnerRootNode = `-- name: CreateOwnerRootNode :one +INSERT INTO nodes ( + owner_id, + parent_id, + name, + name_key, + node_type, + size_bytes, + node_revision, + content_revision, + content_modified_at, + metadata_updated_at +) +VALUES ( + $1::UUID, + NULL, + 'Drive', + 'drive', + 'directory', + 0, + 1, + 0, + $2, + $2 +) +RETURNING id::TEXT AS id +` + +type CreateOwnerRootNodeParams struct { + OwnerID pgtype.UUID `db:"owner_id" json:"owner_id"` + CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` +} + +func (q *Queries) CreateOwnerRootNode(ctx context.Context, arg CreateOwnerRootNodeParams) (string, error) { + row := q.db.QueryRow(ctx, createOwnerRootNode, arg.OwnerID, arg.CreatedAt) + var id string + err := row.Scan(&id) + return id, err +} + +const createRecoveryCode = `-- name: CreateRecoveryCode :exec +INSERT INTO recovery_codes (user_id, code_hash) +VALUES ($1::UUID, $2) +` + +type CreateRecoveryCodeParams struct { + OwnerID pgtype.UUID `db:"owner_id" json:"owner_id"` + CodeHash []byte `db:"code_hash" json:"code_hash"` +} + +func (q *Queries) CreateRecoveryCode(ctx context.Context, arg CreateRecoveryCodeParams) error { + _, err := q.db.Exec(ctx, createRecoveryCode, arg.OwnerID, arg.CodeHash) + return err +} + +const getActiveBrowserSession = `-- name: GetActiveBrowserSession :one +SELECT + sessions.id::TEXT AS session_id, + sessions.owner_id::TEXT AS owner_id, + users.username, + users.display_name, + sessions.csrf_token_hash, + sessions.authenticated_at, + sessions.last_seen_at, + sessions.expires_at +FROM sessions +JOIN users ON users.id = sessions.owner_id +WHERE sessions.token_hash = $1 + AND sessions.revoked_at IS NULL + AND sessions.expires_at > $2 +LIMIT 1 +` + +type GetActiveBrowserSessionParams struct { + TokenHash []byte `db:"token_hash" json:"token_hash"` + NowAt pgtype.Timestamptz `db:"now_at" json:"now_at"` +} + +type GetActiveBrowserSessionRow struct { + SessionID string `db:"session_id" json:"session_id"` + OwnerID string `db:"owner_id" json:"owner_id"` + Username string `db:"username" json:"username"` + DisplayName string `db:"display_name" json:"display_name"` + CsrfTokenHash []byte `db:"csrf_token_hash" json:"csrf_token_hash"` + AuthenticatedAt pgtype.Timestamptz `db:"authenticated_at" json:"authenticated_at"` + LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"` + ExpiresAt pgtype.Timestamptz `db:"expires_at" json:"expires_at"` +} + +func (q *Queries) GetActiveBrowserSession(ctx context.Context, arg GetActiveBrowserSessionParams) (GetActiveBrowserSessionRow, error) { + row := q.db.QueryRow(ctx, getActiveBrowserSession, arg.TokenHash, arg.NowAt) + var i GetActiveBrowserSessionRow + err := row.Scan( + &i.SessionID, + &i.OwnerID, + &i.Username, + &i.DisplayName, + &i.CsrfTokenHash, + &i.AuthenticatedAt, + &i.LastSeenAt, + &i.ExpiresAt, + ) + return i, err +} + +const getAuthenticationInvariantCounts = `-- name: GetAuthenticationInvariantCounts :one +SELECT + (SELECT count(*) FROM users)::BIGINT AS owners, + (SELECT count(*) FROM nodes WHERE parent_id IS NULL)::BIGINT AS roots, + (SELECT count(*) FROM recovery_codes)::BIGINT AS recovery_codes, + (SELECT count(*) FROM changes)::BIGINT AS changes, + (SELECT count(*) FROM audit_events)::BIGINT AS audit_events, + (SELECT count(*) FROM sessions)::BIGINT AS sessions, + (SELECT count(*) FROM sessions WHERE revoked_at IS NULL)::BIGINT AS active_sessions +` + +type GetAuthenticationInvariantCountsRow struct { + Owners int64 `db:"owners" json:"owners"` + Roots int64 `db:"roots" json:"roots"` + RecoveryCodes int64 `db:"recovery_codes" json:"recovery_codes"` + Changes int64 `db:"changes" json:"changes"` + AuditEvents int64 `db:"audit_events" json:"audit_events"` + Sessions int64 `db:"sessions" json:"sessions"` + ActiveSessions int64 `db:"active_sessions" json:"active_sessions"` +} + +func (q *Queries) GetAuthenticationInvariantCounts(ctx context.Context) (GetAuthenticationInvariantCountsRow, error) { + row := q.db.QueryRow(ctx, getAuthenticationInvariantCounts) + var i GetAuthenticationInvariantCountsRow + err := row.Scan( + &i.Owners, + &i.Roots, + &i.RecoveryCodes, + &i.Changes, + &i.AuditEvents, + &i.Sessions, + &i.ActiveSessions, + ) + return i, err +} + +const getOwnerAuthenticationRecord = `-- name: GetOwnerAuthenticationRecord :one +SELECT + id::TEXT AS id, + username, + username_key, + display_name, + password_hash +FROM users +LIMIT 1 +` + +type GetOwnerAuthenticationRecordRow struct { + ID string `db:"id" json:"id"` + Username string `db:"username" json:"username"` + UsernameKey string `db:"username_key" json:"username_key"` + DisplayName string `db:"display_name" json:"display_name"` + PasswordHash string `db:"password_hash" json:"password_hash"` +} + +func (q *Queries) GetOwnerAuthenticationRecord(ctx context.Context) (GetOwnerAuthenticationRecordRow, error) { + row := q.db.QueryRow(ctx, getOwnerAuthenticationRecord) + var i GetOwnerAuthenticationRecordRow + err := row.Scan( + &i.ID, + &i.Username, + &i.UsernameKey, + &i.DisplayName, + &i.PasswordHash, + ) + return i, err +} + +const revokeBrowserSession = `-- name: RevokeBrowserSession :execrows +UPDATE sessions +SET revoked_at = $1 +WHERE id = $2::UUID + AND revoked_at IS NULL +` + +type RevokeBrowserSessionParams struct { + RevokedAt pgtype.Timestamptz `db:"revoked_at" json:"revoked_at"` + SessionID pgtype.UUID `db:"session_id" json:"session_id"` +} + +func (q *Queries) RevokeBrowserSession(ctx context.Context, arg RevokeBrowserSessionParams) (int64, error) { + result, err := q.db.Exec(ctx, revokeBrowserSession, arg.RevokedAt, arg.SessionID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const setOwnerRootNode = `-- name: SetOwnerRootNode :exec +UPDATE users +SET root_node_id = $1::UUID, + updated_at = $2 +WHERE id = $3::UUID +` + +type SetOwnerRootNodeParams struct { + RootNodeID pgtype.UUID `db:"root_node_id" json:"root_node_id"` + UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"` + OwnerID pgtype.UUID `db:"owner_id" json:"owner_id"` +} + +func (q *Queries) SetOwnerRootNode(ctx context.Context, arg SetOwnerRootNodeParams) error { + _, err := q.db.Exec(ctx, setOwnerRootNode, arg.RootNodeID, arg.UpdatedAt, arg.OwnerID) + return err +} + +const touchBrowserSession = `-- name: TouchBrowserSession :exec +UPDATE sessions +SET last_seen_at = $1 +WHERE id = $2::UUID + AND revoked_at IS NULL +` + +type TouchBrowserSessionParams struct { + SeenAt pgtype.Timestamptz `db:"seen_at" json:"seen_at"` + SessionID pgtype.UUID `db:"session_id" json:"session_id"` +} + +func (q *Queries) TouchBrowserSession(ctx context.Context, arg TouchBrowserSessionParams) error { + _, err := q.db.Exec(ctx, touchBrowserSession, arg.SeenAt, arg.SessionID) + return err +} diff --git a/internal/adapters/postgres/generated/querier.go b/internal/adapters/postgres/generated/querier.go index 0958516..aacc14d 100644 --- a/internal/adapters/postgres/generated/querier.go +++ b/internal/adapters/postgres/generated/querier.go @@ -9,8 +9,20 @@ import ( ) type Querier interface { + CreateAuthenticationAuditEvent(ctx context.Context, arg CreateAuthenticationAuditEventParams) error + CreateBrowserSession(ctx context.Context, arg CreateBrowserSessionParams) (string, error) + CreateInitialRootChange(ctx context.Context, arg CreateInitialRootChangeParams) error + CreateOwner(ctx context.Context, arg CreateOwnerParams) (string, error) + CreateOwnerRootNode(ctx context.Context, arg CreateOwnerRootNodeParams) (string, error) + CreateRecoveryCode(ctx context.Context, arg CreateRecoveryCodeParams) error DatabaseHealth(ctx context.Context) (int64, error) + GetActiveBrowserSession(ctx context.Context, arg GetActiveBrowserSessionParams) (GetActiveBrowserSessionRow, error) + GetAuthenticationInvariantCounts(ctx context.Context) (GetAuthenticationInvariantCountsRow, error) + GetOwnerAuthenticationRecord(ctx context.Context) (GetOwnerAuthenticationRecordRow, error) OwnerExists(ctx context.Context) (bool, error) + RevokeBrowserSession(ctx context.Context, arg RevokeBrowserSessionParams) (int64, error) + SetOwnerRootNode(ctx context.Context, arg SetOwnerRootNodeParams) error + TouchBrowserSession(ctx context.Context, arg TouchBrowserSessionParams) error } var _ Querier = (*Queries)(nil) diff --git a/internal/adapters/postgres/migrate_integration_test.go b/internal/adapters/postgres/migrate_integration_test.go index bb31a6b..fc0ac3e 100644 --- a/internal/adapters/postgres/migrate_integration_test.go +++ b/internal/adapters/postgres/migrate_integration_test.go @@ -2,11 +2,26 @@ package postgres import ( "context" + "errors" "os" + "sync" "testing" "time" + + "drive.local/drivev2/internal/adapters/authn" + "drive.local/drivev2/internal/application" ) +type integrationPasswordHasher struct{} + +func (integrationPasswordHasher) Hash(password string) (string, error) { + return "integration:" + password, nil +} + +func (integrationPasswordHasher) Verify(password, encoded string) (bool, error) { + return encoded == "integration:"+password, nil +} + func TestMigrationsAndStore(t *testing.T) { databaseURL := os.Getenv("DRIVE_TEST_DATABASE_URL") if databaseURL == "" { @@ -38,4 +53,85 @@ func TestMigrationsAndStore(t *testing.T) { if initialized { t.Fatal("OwnerExists = true on a fresh database, want false") } + + authentication := application.NewAuthenticationService( + store, + integrationPasswordHasher{}, + authn.CredentialGenerator{}, + authn.SystemClock{}, + ) + inputs := []application.SetupOwnerInput{ + {Username: "owner-one", DisplayName: "Owner One", Password: "correct horse battery staple"}, + {Username: "owner-two", DisplayName: "Owner Two", Password: "correct horse battery staple"}, + } + results := make([]application.SetupOwnerResult, len(inputs)) + errorsByAttempt := make([]error, len(inputs)) + var waitGroup sync.WaitGroup + for index := range inputs { + waitGroup.Add(1) + go func() { + defer waitGroup.Done() + results[index], errorsByAttempt[index] = authentication.SetupOwner(ctx, inputs[index]) + }() + } + waitGroup.Wait() + + winner := -1 + losers := 0 + for index, setupErr := range errorsByAttempt { + switch { + case setupErr == nil: + winner = index + case errors.Is(setupErr, application.ErrSetupAlreadyComplete): + losers++ + default: + t.Fatalf("setup attempt %d returned unexpected error: %v", index, setupErr) + } + } + if winner < 0 || losers != 1 { + t.Fatalf("winner = %d, losing attempts = %d, want one of each", winner, losers) + } + if len(results[winner].RecoveryCodes) != application.RecoveryCodeCount { + t.Fatalf("recovery code count = %d, want %d", len(results[winner].RecoveryCodes), application.RecoveryCodeCount) + } + + counts, err := store.queries.GetAuthenticationInvariantCounts(ctx) + if err != nil { + t.Fatalf("GetAuthenticationInvariantCounts returned an error: %v", err) + } + if counts.Owners != 1 || counts.Roots != 1 || counts.RecoveryCodes != 10 || counts.Changes != 1 || + counts.AuditEvents != 1 || counts.Sessions != 1 || counts.ActiveSessions != 1 { + t.Fatalf("unexpected post-setup counts: %#v", counts) + } + + loggedIn, err := authentication.Login(ctx, application.LoginInput{ + Username: inputs[winner].Username, Password: inputs[winner].Password, + }) + if err != nil { + t.Fatalf("Login returned an error: %v", err) + } + current, err := authentication.CurrentSession(ctx, loggedIn.SessionToken) + if err != nil { + t.Fatalf("CurrentSession returned an error: %v", err) + } + if current.ID != loggedIn.Session.ID { + t.Fatalf("current session ID = %q, want %q", current.ID, loggedIn.Session.ID) + } + if err := authentication.Logout(ctx, loggedIn.SessionToken, loggedIn.CSRFToken, "wrong-csrf"); !errors.Is(err, application.ErrCSRFValidation) { + t.Fatalf("Logout with invalid CSRF error = %v, want %v", err, application.ErrCSRFValidation) + } + if err := authentication.Logout(ctx, loggedIn.SessionToken, loggedIn.CSRFToken, loggedIn.CSRFToken); err != nil { + t.Fatalf("Logout returned an error: %v", err) + } + if _, err := authentication.CurrentSession(ctx, loggedIn.SessionToken); !errors.Is(err, application.ErrSessionNotFound) { + t.Fatalf("CurrentSession after logout error = %v, want %v", err, application.ErrSessionNotFound) + } + + counts, err = store.queries.GetAuthenticationInvariantCounts(ctx) + if err != nil { + t.Fatalf("GetAuthenticationInvariantCounts after logout returned an error: %v", err) + } + if counts.Sessions != 2 || counts.ActiveSessions != 1 || counts.AuditEvents != 3 { + t.Fatalf("unexpected post-logout counts: %#v", counts) + } } diff --git a/internal/application/authentication.go b/internal/application/authentication.go new file mode 100644 index 0000000..e7dfae4 --- /dev/null +++ b/internal/application/authentication.go @@ -0,0 +1,410 @@ +package application + +import ( + "context" + "crypto/sha256" + "crypto/subtle" + "encoding/json" + "errors" + "fmt" + "time" + + "drive.local/drivev2/internal/domain" +) + +const ( + RecoveryCodeCount = 10 + SessionLifetime = 30 * 24 * time.Hour +) + +var ( + ErrSetupAlreadyComplete = errors.New("owner setup is already complete") + ErrOwnerNotFound = errors.New("owner not found") + ErrInvalidCredentials = errors.New("invalid credentials") + ErrSessionNotFound = errors.New("session not found") + ErrCSRFValidation = errors.New("CSRF validation failed") +) + +type PasswordHasher interface { + Hash(password string) (string, error) + Verify(password, encoded string) (bool, error) +} + +type GeneratedCredential struct { + Plaintext string + Digest []byte +} + +type CredentialGenerator interface { + NewCredential() (GeneratedCredential, error) + NewRecoveryCodes(count int) ([]GeneratedCredential, error) +} + +type Clock interface { + Now() time.Time +} + +type OwnerAuthenticationRecord struct { + ID string + Username string + UsernameKey string + DisplayName string + PasswordHash string +} + +type BrowserSession struct { + ID string `json:"id"` + OwnerID string `json:"ownerId"` + Username string `json:"username"` + DisplayName string `json:"displayName"` + AuthenticatedAt time.Time `json:"authenticatedAt"` + LastSeenAt time.Time `json:"lastSeenAt"` + ExpiresAt time.Time `json:"expiresAt"` + CSRFTokenHash []byte `json:"-"` +} + +type AuthTransaction interface { + CreateOwner(context.Context, CreateOwnerRecord) (string, error) + CreateOwnerRoot(context.Context, string, time.Time) (string, error) + SetOwnerRoot(context.Context, string, string, time.Time) error + CreateRecoveryCode(context.Context, string, []byte) error + CreateBrowserSession(context.Context, CreateSessionRecord) (string, error) + CreateInitialRootChange(context.Context, InitialRootChangeRecord) error + CreateAuthenticationAudit(context.Context, AuthenticationAuditRecord) error + RevokeBrowserSession(context.Context, string, time.Time) (bool, error) +} + +type AuthRepository interface { + OwnerExists(context.Context) (bool, error) + GetOwnerAuthenticationRecord(context.Context) (OwnerAuthenticationRecord, error) + GetActiveBrowserSession(context.Context, []byte, time.Time) (BrowserSession, error) + TouchBrowserSession(context.Context, string, time.Time) error + WithAuthTransaction(context.Context, func(AuthTransaction) error) error +} + +type CreateOwnerRecord struct { + Username string + UsernameKey string + DisplayName string + PasswordHash string +} + +type CreateSessionRecord struct { + OwnerID string + TokenHash []byte + CSRFTokenHash []byte + RemoteAddress string + UserAgent string + AuthenticatedAt time.Time + ExpiresAt time.Time +} + +type InitialRootChangeRecord struct { + OwnerID string + RootNodeID string + ResultingState []byte + MutationOrigin []byte + CommittedAt time.Time +} + +type AuthenticationAuditRecord struct { + OwnerID string + Actor []byte + Action string + ResourceType string + ResourceID string + Outcome string + Details []byte + OccurredAt time.Time +} + +type SetupOwnerInput struct { + Username string + DisplayName string + Password string + RemoteAddress string + UserAgent string +} + +type LoginInput struct { + Username string + Password string + RemoteAddress string + UserAgent string +} + +type AuthenticatedSession struct { + Session BrowserSession + SessionToken string + CSRFToken string +} + +type SetupOwnerResult struct { + OwnerID string + RootNodeID string + Username string + DisplayName string + RecoveryCodes []string + Authentication AuthenticatedSession +} + +type AuthenticationService struct { + repository AuthRepository + hasher PasswordHasher + credentials CredentialGenerator + clock Clock +} + +func NewAuthenticationService(repository AuthRepository, hasher PasswordHasher, credentials CredentialGenerator, clock Clock) *AuthenticationService { + return &AuthenticationService{repository: repository, hasher: hasher, credentials: credentials, clock: clock} +} + +func (s *AuthenticationService) SetupOwner(ctx context.Context, input SetupOwnerInput) (SetupOwnerResult, error) { + initialized, err := s.repository.OwnerExists(ctx) + if err != nil { + return SetupOwnerResult{}, fmt.Errorf("read setup state: %w", err) + } + if initialized { + return SetupOwnerResult{}, ErrSetupAlreadyComplete + } + + identity, err := domain.NewOwnerIdentity(input.Username, input.DisplayName) + if err != nil { + return SetupOwnerResult{}, err + } + if err := domain.ValidatePassword(input.Password); err != nil { + return SetupOwnerResult{}, err + } + passwordHash, err := s.hasher.Hash(input.Password) + if err != nil { + return SetupOwnerResult{}, fmt.Errorf("hash owner password: %w", err) + } + recoveryCodes, err := s.credentials.NewRecoveryCodes(RecoveryCodeCount) + if err != nil { + return SetupOwnerResult{}, err + } + sessionSecret, csrfSecret, err := s.newSessionSecrets() + if err != nil { + return SetupOwnerResult{}, err + } + + now := s.clock.Now().UTC() + expiresAt := now.Add(SessionLifetime) + result := SetupOwnerResult{Username: identity.Username, DisplayName: identity.DisplayName} + err = s.repository.WithAuthTransaction(ctx, func(transaction AuthTransaction) error { + ownerID, err := transaction.CreateOwner(ctx, CreateOwnerRecord{ + Username: identity.Username, UsernameKey: identity.UsernameKey, DisplayName: identity.DisplayName, PasswordHash: passwordHash, + }) + if err != nil { + return err + } + rootNodeID, err := transaction.CreateOwnerRoot(ctx, ownerID, now) + if err != nil { + return err + } + if err := transaction.SetOwnerRoot(ctx, ownerID, rootNodeID, now); err != nil { + return err + } + for _, recoveryCode := range recoveryCodes { + if err := transaction.CreateRecoveryCode(ctx, ownerID, recoveryCode.Digest); err != nil { + return err + } + } + + sessionID, err := transaction.CreateBrowserSession(ctx, CreateSessionRecord{ + OwnerID: ownerID, TokenHash: sessionSecret.Digest, CSRFTokenHash: csrfSecret.Digest, + RemoteAddress: input.RemoteAddress, UserAgent: input.UserAgent, AuthenticatedAt: now, ExpiresAt: expiresAt, + }) + if err != nil { + return err + } + if err := createSetupChangeAndAudit(ctx, transaction, ownerID, rootNodeID, sessionID, now); err != nil { + return err + } + + result.OwnerID = ownerID + result.RootNodeID = rootNodeID + result.Authentication = AuthenticatedSession{ + Session: BrowserSession{ID: sessionID, OwnerID: ownerID, Username: identity.Username, DisplayName: identity.DisplayName, AuthenticatedAt: now, LastSeenAt: now, ExpiresAt: expiresAt}, + SessionToken: sessionSecret.Plaintext, + CSRFToken: csrfSecret.Plaintext, + } + return nil + }) + if err != nil { + return SetupOwnerResult{}, err + } + for _, recoveryCode := range recoveryCodes { + result.RecoveryCodes = append(result.RecoveryCodes, recoveryCode.Plaintext) + } + return result, nil +} + +func (s *AuthenticationService) Login(ctx context.Context, input LoginInput) (AuthenticatedSession, error) { + if len(input.Password) > domain.MaximumPasswordBytes { + return AuthenticatedSession{}, ErrInvalidCredentials + } + _, usernameKey, err := domain.NormalizeUsername(input.Username) + if err != nil { + return AuthenticatedSession{}, ErrInvalidCredentials + } + record, err := s.repository.GetOwnerAuthenticationRecord(ctx) + if err != nil { + if errors.Is(err, ErrOwnerNotFound) { + return AuthenticatedSession{}, ErrInvalidCredentials + } + return AuthenticatedSession{}, fmt.Errorf("read owner authentication record: %w", err) + } + passwordMatches, err := s.hasher.Verify(input.Password, record.PasswordHash) + if err != nil { + return AuthenticatedSession{}, fmt.Errorf("verify password: %w", err) + } + requestedKeyHash := sha256.Sum256([]byte(usernameKey)) + storedKeyHash := sha256.Sum256([]byte(record.UsernameKey)) + usernameMatches := subtle.ConstantTimeCompare(requestedKeyHash[:], storedKeyHash[:]) == 1 + if !usernameMatches || !passwordMatches { + return AuthenticatedSession{}, ErrInvalidCredentials + } + + sessionSecret, csrfSecret, err := s.newSessionSecrets() + if err != nil { + return AuthenticatedSession{}, err + } + now := s.clock.Now().UTC() + expiresAt := now.Add(SessionLifetime) + result := AuthenticatedSession{} + err = s.repository.WithAuthTransaction(ctx, func(transaction AuthTransaction) error { + sessionID, err := transaction.CreateBrowserSession(ctx, CreateSessionRecord{ + OwnerID: record.ID, TokenHash: sessionSecret.Digest, CSRFTokenHash: csrfSecret.Digest, + RemoteAddress: input.RemoteAddress, UserAgent: input.UserAgent, AuthenticatedAt: now, ExpiresAt: expiresAt, + }) + if err != nil { + return err + } + actor, err := json.Marshal(map[string]any{"type": "owner", "id": record.ID}) + if err != nil { + return err + } + details, err := json.Marshal(map[string]any{"sessionId": sessionID}) + if err != nil { + return err + } + if err := transaction.CreateAuthenticationAudit(ctx, AuthenticationAuditRecord{ + OwnerID: record.ID, Actor: actor, Action: "session.created", ResourceType: "session", ResourceID: sessionID, + Outcome: "succeeded", Details: details, OccurredAt: now, + }); err != nil { + return err + } + result = AuthenticatedSession{ + Session: BrowserSession{ID: sessionID, OwnerID: record.ID, Username: record.Username, DisplayName: record.DisplayName, AuthenticatedAt: now, LastSeenAt: now, ExpiresAt: expiresAt}, + SessionToken: sessionSecret.Plaintext, CSRFToken: csrfSecret.Plaintext, + } + return nil + }) + if err != nil { + return AuthenticatedSession{}, err + } + return result, nil +} + +func (s *AuthenticationService) CurrentSession(ctx context.Context, sessionToken string) (BrowserSession, error) { + if sessionToken == "" { + return BrowserSession{}, ErrSessionNotFound + } + digest := sha256.Sum256([]byte(sessionToken)) + now := s.clock.Now().UTC() + session, err := s.repository.GetActiveBrowserSession(ctx, digest[:], now) + if err != nil { + if errors.Is(err, ErrSessionNotFound) { + return BrowserSession{}, ErrSessionNotFound + } + return BrowserSession{}, fmt.Errorf("read active session: %w", err) + } + if now.Sub(session.LastSeenAt) >= 5*time.Minute { + if err := s.repository.TouchBrowserSession(ctx, session.ID, now); err != nil { + return BrowserSession{}, fmt.Errorf("touch session: %w", err) + } + session.LastSeenAt = now + } + return session, nil +} + +func (s *AuthenticationService) Logout(ctx context.Context, sessionToken, csrfCookie, csrfHeader string) error { + session, err := s.CurrentSession(ctx, sessionToken) + if err != nil { + return err + } + cookieDigest := sha256.Sum256([]byte(csrfCookie)) + headerDigest := sha256.Sum256([]byte(csrfHeader)) + if csrfCookie == "" || csrfHeader == "" || + subtle.ConstantTimeCompare(cookieDigest[:], headerDigest[:]) != 1 || + subtle.ConstantTimeCompare(cookieDigest[:], session.CSRFTokenHash) != 1 { + return ErrCSRFValidation + } + + now := s.clock.Now().UTC() + return s.repository.WithAuthTransaction(ctx, func(transaction AuthTransaction) error { + revoked, err := transaction.RevokeBrowserSession(ctx, session.ID, now) + if err != nil { + return err + } + if !revoked { + return ErrSessionNotFound + } + actor, err := json.Marshal(map[string]any{"type": "owner", "id": session.OwnerID}) + if err != nil { + return err + } + details, err := json.Marshal(map[string]any{"sessionId": session.ID}) + if err != nil { + return err + } + return transaction.CreateAuthenticationAudit(ctx, AuthenticationAuditRecord{ + OwnerID: session.OwnerID, Actor: actor, Action: "session.revoked", ResourceType: "session", ResourceID: session.ID, + Outcome: "succeeded", Details: details, OccurredAt: now, + }) + }) +} + +func (s *AuthenticationService) newSessionSecrets() (GeneratedCredential, GeneratedCredential, error) { + sessionSecret, err := s.credentials.NewCredential() + if err != nil { + return GeneratedCredential{}, GeneratedCredential{}, err + } + csrfSecret, err := s.credentials.NewCredential() + if err != nil { + return GeneratedCredential{}, GeneratedCredential{}, err + } + return sessionSecret, csrfSecret, nil +} + +func createSetupChangeAndAudit(ctx context.Context, transaction AuthTransaction, ownerID, rootNodeID, sessionID string, now time.Time) error { + resultingState, err := json.Marshal(map[string]any{ + "id": rootNodeID, "ownerId": ownerID, "parentId": nil, "name": "Drive", "type": "directory", + "nodeRevision": 1, "contentRevision": 0, "size": 0, "createdAt": now, "metadataUpdatedAt": now, + }) + if err != nil { + return err + } + mutationOrigin, err := json.Marshal(map[string]any{"source": "setup", "actorId": ownerID}) + if err != nil { + return err + } + if err := transaction.CreateInitialRootChange(ctx, InitialRootChangeRecord{ + OwnerID: ownerID, RootNodeID: rootNodeID, ResultingState: resultingState, MutationOrigin: mutationOrigin, CommittedAt: now, + }); err != nil { + return err + } + actor, err := json.Marshal(map[string]any{"type": "owner", "id": ownerID}) + if err != nil { + return err + } + details, err := json.Marshal(map[string]any{"rootNodeId": rootNodeID, "sessionId": sessionID}) + if err != nil { + return err + } + return transaction.CreateAuthenticationAudit(ctx, AuthenticationAuditRecord{ + OwnerID: ownerID, Actor: actor, Action: "owner.setup", ResourceType: "user", ResourceID: ownerID, + Outcome: "succeeded", Details: details, OccurredAt: now, + }) +} diff --git a/internal/domain/identity.go b/internal/domain/identity.go new file mode 100644 index 0000000..ac157ce --- /dev/null +++ b/internal/domain/identity.go @@ -0,0 +1,73 @@ +package domain + +import ( + "errors" + "strings" + "unicode" + "unicode/utf8" + + "golang.org/x/text/cases" + "golang.org/x/text/unicode/norm" +) + +const ( + MinimumPasswordRunes = 12 + MaximumPasswordBytes = 1024 + MaximumIdentityRunes = 128 +) + +var ( + ErrInvalidUsername = errors.New("invalid username") + ErrInvalidDisplayName = errors.New("invalid display name") + ErrInvalidPassword = errors.New("invalid password") +) + +type OwnerIdentity struct { + Username string + UsernameKey string + DisplayName string +} + +func NewOwnerIdentity(username, displayName string) (OwnerIdentity, error) { + username, usernameKey, err := NormalizeUsername(username) + if err != nil { + return OwnerIdentity{}, err + } + displayName = norm.NFC.String(strings.TrimSpace(displayName)) + if !validIdentityText(displayName) { + return OwnerIdentity{}, ErrInvalidDisplayName + } + return OwnerIdentity{ + Username: username, + UsernameKey: usernameKey, + DisplayName: displayName, + }, nil +} + +func NormalizeUsername(username string) (string, string, error) { + username = norm.NFC.String(strings.TrimSpace(username)) + if !validIdentityText(username) { + return "", "", ErrInvalidUsername + } + return username, cases.Fold().String(username), nil +} + +func ValidatePassword(password string) error { + if !utf8.ValidString(password) || len(password) > MaximumPasswordBytes || utf8.RuneCountInString(password) < MinimumPasswordRunes { + return ErrInvalidPassword + } + return nil +} + +func validIdentityText(value string) bool { + count := utf8.RuneCountInString(value) + if count == 0 || count > MaximumIdentityRunes { + return false + } + for _, character := range value { + if unicode.IsControl(character) { + return false + } + } + return true +} diff --git a/internal/domain/identity_test.go b/internal/domain/identity_test.go new file mode 100644 index 0000000..973cd03 --- /dev/null +++ b/internal/domain/identity_test.go @@ -0,0 +1,26 @@ +package domain + +import ( + "errors" + "testing" +) + +func TestNewOwnerIdentityNormalizesAndFoldsUsername(t *testing.T) { + t.Parallel() + + identity, err := NewOwnerIdentity(" ÉLIJAH ", " Elijah Kuntz ") + if err != nil { + t.Fatalf("NewOwnerIdentity returned an error: %v", err) + } + if identity.Username != "ÉLIJAH" || identity.UsernameKey != "élijah" || identity.DisplayName != "Elijah Kuntz" { + t.Fatalf("unexpected identity: %#v", identity) + } +} + +func TestValidatePasswordRejectsShortPassword(t *testing.T) { + t.Parallel() + + if err := ValidatePassword("too-short"); !errors.Is(err, ErrInvalidPassword) { + t.Fatalf("ValidatePassword error = %v, want %v", err, ErrInvalidPassword) + } +} diff --git a/internal/transport/httpapi/server.go b/internal/transport/httpapi/server.go index 6021d61..6af828b 100644 --- a/internal/transport/httpapi/server.go +++ b/internal/transport/httpapi/server.go @@ -3,16 +3,28 @@ package httpapi import ( "context" "encoding/json" + "errors" + "io" "log/slog" + "net" "net/http" + "strings" "time" "drive.local/drivev2/internal/application" "drive.local/drivev2/internal/config" + "drive.local/drivev2/internal/domain" ) var ErrServerClosed = http.ErrServerClosed +const ( + sessionCookieName = "__Host-drive_session" + csrfCookieName = "__Host-drive_csrf" + csrfHeaderName = "X-CSRF-Token" + maximumJSONBody = 1 << 20 +) + type Server struct { httpServer *http.Server } @@ -22,21 +34,155 @@ type SystemService interface { SetupStatus(context.Context) (application.SetupStatus, error) } -func NewServer(cfg config.Config, logger *slog.Logger, system SystemService, frontend http.Handler) *Server { +type AuthenticationService interface { + SetupOwner(context.Context, application.SetupOwnerInput) (application.SetupOwnerResult, error) + Login(context.Context, application.LoginInput) (application.AuthenticatedSession, error) + CurrentSession(context.Context, string) (application.BrowserSession, error) + Logout(context.Context, string, string, string) error +} + +func NewServer(cfg config.Config, logger *slog.Logger, system SystemService, authentication AuthenticationService, frontend http.Handler) *Server { mux := http.NewServeMux() mux.HandleFunc("GET /health/live", jsonResponse(http.StatusOK, map[string]string{"status": "live"})) mux.HandleFunc("GET /health/ready", readinessHandler(logger, system)) mux.HandleFunc("GET /api/v1/setup/status", setupStatusHandler(logger, system)) + mux.HandleFunc("POST /api/v1/setup", ownerSetupHandler(logger, authentication)) + mux.HandleFunc("POST /api/v1/sessions", loginHandler(logger, authentication)) + mux.HandleFunc("GET /api/v1/sessions/current", currentSessionHandler(logger, authentication)) + mux.HandleFunc("DELETE /api/v1/sessions/current", logoutHandler(logger, authentication)) mux.Handle("/", frontend) return &Server{httpServer: &http.Server{ Addr: cfg.HTTPAddress, - Handler: requestLogger(logger, mux), + Handler: securityHeaders(requestLogger(logger, mux)), ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 2 * time.Minute, }} } +type ownerSetupRequest struct { + Username string `json:"username"` + DisplayName string `json:"displayName"` + Password string `json:"password"` +} + +type loginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type ownerResponse struct { + ID string `json:"id"` + Username string `json:"username"` + DisplayName string `json:"displayName"` + RootNodeID string `json:"rootNodeId"` +} + +type setupResponse struct { + Owner ownerResponse `json:"owner"` + RecoveryCodes []string `json:"recoveryCodes"` + Session application.BrowserSession `json:"session"` +} + +func ownerSetupHandler(logger *slog.Logger, authentication AuthenticationService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var request ownerSetupRequest + if err := decodeJSON(w, r, &request); err != nil { + problemResponse(w, http.StatusBadRequest, "invalid_request", "The request body is not valid JSON") + return + } + result, err := authentication.SetupOwner(r.Context(), application.SetupOwnerInput{ + Username: request.Username, DisplayName: request.DisplayName, Password: request.Password, + RemoteAddress: remoteAddress(r), UserAgent: r.UserAgent(), + }) + if err != nil { + switch { + case errors.Is(err, application.ErrSetupAlreadyComplete): + problemResponse(w, http.StatusConflict, "setup_already_complete", "Owner setup has already been completed") + case errors.Is(err, domain.ErrInvalidUsername), errors.Is(err, domain.ErrInvalidDisplayName), errors.Is(err, domain.ErrInvalidPassword): + problemResponse(w, http.StatusUnprocessableEntity, "validation_failed", "Username, display name, or password is invalid") + default: + logger.Error("owner setup failed", "error", err) + problemResponse(w, http.StatusInternalServerError, "internal_error", "Owner setup could not be completed") + } + return + } + setAuthenticationCookies(w, result.Authentication) + writeJSON(w, http.StatusCreated, setupResponse{ + Owner: ownerResponse{ID: result.OwnerID, Username: result.Username, DisplayName: result.DisplayName, RootNodeID: result.RootNodeID}, + RecoveryCodes: result.RecoveryCodes, + Session: result.Authentication.Session, + }) + } +} + +func loginHandler(logger *slog.Logger, authentication AuthenticationService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var request loginRequest + if err := decodeJSON(w, r, &request); err != nil { + problemResponse(w, http.StatusBadRequest, "invalid_request", "The request body is not valid JSON") + return + } + authenticated, err := authentication.Login(r.Context(), application.LoginInput{ + Username: request.Username, Password: request.Password, RemoteAddress: remoteAddress(r), UserAgent: r.UserAgent(), + }) + if err != nil { + if errors.Is(err, application.ErrInvalidCredentials) { + problemResponse(w, http.StatusUnauthorized, "invalid_credentials", "The username or password is invalid") + return + } + logger.Error("login failed", "error", err) + problemResponse(w, http.StatusInternalServerError, "internal_error", "Login could not be completed") + return + } + setAuthenticationCookies(w, authenticated) + writeJSON(w, http.StatusCreated, authenticated.Session) + } +} + +func currentSessionHandler(logger *slog.Logger, authentication AuthenticationService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + session, err := authentication.CurrentSession(r.Context(), cookieValue(r, sessionCookieName)) + if err != nil { + if errors.Is(err, application.ErrSessionNotFound) { + clearAuthenticationCookies(w) + problemResponse(w, http.StatusUnauthorized, "authentication_required", "A valid browser session is required") + return + } + logger.Error("current session failed", "error", err) + problemResponse(w, http.StatusInternalServerError, "internal_error", "The current session could not be read") + return + } + writeJSON(w, http.StatusOK, session) + } +} + +func logoutHandler(logger *slog.Logger, authentication AuthenticationService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + err := authentication.Logout( + r.Context(), + cookieValue(r, sessionCookieName), + cookieValue(r, csrfCookieName), + r.Header.Get(csrfHeaderName), + ) + if err != nil { + switch { + case errors.Is(err, application.ErrSessionNotFound): + clearAuthenticationCookies(w) + problemResponse(w, http.StatusUnauthorized, "authentication_required", "A valid browser session is required") + case errors.Is(err, application.ErrCSRFValidation): + problemResponse(w, http.StatusForbidden, "csrf_validation_failed", "The CSRF token is missing or invalid") + default: + logger.Error("logout failed", "error", err) + problemResponse(w, http.StatusInternalServerError, "internal_error", "The session could not be revoked") + } + return + } + clearAuthenticationCookies(w) + w.WriteHeader(http.StatusNoContent) + } +} + func readinessHandler(logger *slog.Logger, system SystemService) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if err := system.Readiness(r.Context()); err != nil { @@ -96,6 +242,68 @@ func problemResponse(w http.ResponseWriter, status int, code, detail string) { } } +func decodeJSON(w http.ResponseWriter, r *http.Request, destination any) error { + r.Body = http.MaxBytesReader(w, r.Body, maximumJSONBody) + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(destination); err != nil { + return err + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return errors.New("request body must contain one JSON value") + } + return nil +} + +func setAuthenticationCookies(w http.ResponseWriter, authenticated application.AuthenticatedSession) { + maxAge := int(time.Until(authenticated.Session.ExpiresAt).Seconds()) + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, Value: authenticated.SessionToken, Path: "/", MaxAge: maxAge, + Expires: authenticated.Session.ExpiresAt, Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode, + }) + http.SetCookie(w, &http.Cookie{ + Name: csrfCookieName, Value: authenticated.CSRFToken, Path: "/", MaxAge: maxAge, + Expires: authenticated.Session.ExpiresAt, Secure: true, HttpOnly: false, SameSite: http.SameSiteStrictMode, + }) +} + +func clearAuthenticationCookies(w http.ResponseWriter) { + expires := time.Unix(1, 0).UTC() + for _, name := range []string{sessionCookieName, csrfCookieName} { + http.SetCookie(w, &http.Cookie{ + Name: name, Value: "", Path: "/", MaxAge: -1, Expires: expires, + Secure: true, HttpOnly: name == sessionCookieName, SameSite: http.SameSiteStrictMode, + }) + } +} + +func cookieValue(r *http.Request, name string) string { + cookie, err := r.Cookie(name) + if err != nil { + return "" + } + return cookie.Value +} + +func remoteAddress(r *http.Request) string { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err == nil { + return host + } + return strings.TrimSpace(r.RemoteAddr) +} + +func securityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Security-Policy", "default-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'") + w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()") + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + next.ServeHTTP(w, r) + }) +} + func requestLogger(logger *slog.Logger, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { started := time.Now() diff --git a/internal/transport/httpapi/server_test.go b/internal/transport/httpapi/server_test.go index ae42195..b32b32d 100644 --- a/internal/transport/httpapi/server_test.go +++ b/internal/transport/httpapi/server_test.go @@ -9,6 +9,7 @@ import ( "net/http/httptest" "strings" "testing" + "time" "drive.local/drivev2/internal/application" "drive.local/drivev2/internal/config" @@ -28,6 +29,32 @@ func (s systemServiceStub) SetupStatus(context.Context) (application.SetupStatus return s.setupStatus, s.setupErr } +type authenticationServiceStub struct { + setupResult application.SetupOwnerResult + setupErr error + loginResult application.AuthenticatedSession + loginErr error + session application.BrowserSession + sessionErr error + logoutErr error +} + +func (s authenticationServiceStub) SetupOwner(context.Context, application.SetupOwnerInput) (application.SetupOwnerResult, error) { + return s.setupResult, s.setupErr +} + +func (s authenticationServiceStub) Login(context.Context, application.LoginInput) (application.AuthenticatedSession, error) { + return s.loginResult, s.loginErr +} + +func (s authenticationServiceStub) CurrentSession(context.Context, string) (application.BrowserSession, error) { + return s.session, s.sessionErr +} + +func (s authenticationServiceStub) Logout(context.Context, string, string, string) error { + return s.logoutErr +} + func TestLiveHealth(t *testing.T) { t.Parallel() @@ -35,6 +62,7 @@ func TestLiveHealth(t *testing.T) { config.Config{HTTPAddress: ":0", WebRoot: t.TempDir()}, slog.New(slog.NewTextHandler(io.Discard, nil)), systemServiceStub{}, + authenticationServiceStub{}, http.NotFoundHandler(), ) request := httptest.NewRequest(http.MethodGet, "/health/live", nil) @@ -57,6 +85,7 @@ func TestReadinessReportsDependencyFailure(t *testing.T) { config.Config{HTTPAddress: ":0", WebRoot: t.TempDir()}, slog.New(slog.NewTextHandler(io.Discard, nil)), systemServiceStub{readinessErr: errors.New("database unavailable")}, + authenticationServiceStub{}, http.NotFoundHandler(), ) request := httptest.NewRequest(http.MethodGet, "/health/ready", nil) @@ -82,6 +111,7 @@ func TestSetupStatusUsesApplicationService(t *testing.T) { config.Config{HTTPAddress: ":0", WebRoot: t.TempDir()}, slog.New(slog.NewTextHandler(io.Discard, nil)), systemServiceStub{setupStatus: application.SetupStatus{Initialized: true, Phase: "phase-1", Version: "test"}}, + authenticationServiceStub{}, http.NotFoundHandler(), ) request := httptest.NewRequest(http.MethodGet, "/api/v1/setup/status", nil) @@ -96,3 +126,67 @@ func TestSetupStatusUsesApplicationService(t *testing.T) { t.Fatalf("unexpected body: %s", recorder.Body.String()) } } + +func TestOwnerSetupSetsHardenedAuthenticationCookies(t *testing.T) { + t.Parallel() + + expiresAt := time.Now().Add(time.Hour).UTC() + authenticated := application.AuthenticatedSession{ + Session: application.BrowserSession{ID: "session", OwnerID: "owner", ExpiresAt: expiresAt}, + SessionToken: "session-secret", CSRFToken: "csrf-secret", + } + server := NewServer( + config.Config{HTTPAddress: ":0", WebRoot: t.TempDir()}, + slog.New(slog.NewTextHandler(io.Discard, nil)), + systemServiceStub{}, + authenticationServiceStub{setupResult: application.SetupOwnerResult{ + OwnerID: "owner", RootNodeID: "root", Username: "owner", DisplayName: "Owner", + RecoveryCodes: []string{"AAAAA-BBBBB-CCCCC-DDDDD"}, Authentication: authenticated, + }}, + http.NotFoundHandler(), + ) + request := httptest.NewRequest(http.MethodPost, "/api/v1/setup", strings.NewReader(`{"username":"owner","displayName":"Owner","password":"correct horse battery staple"}`)) + request.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + + server.httpServer.Handler.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusCreated { + t.Fatalf("status = %d, want %d: %s", recorder.Code, http.StatusCreated, recorder.Body.String()) + } + cookies := recorder.Result().Cookies() + if len(cookies) != 2 { + t.Fatalf("cookie count = %d, want 2", len(cookies)) + } + for _, cookie := range cookies { + if !cookie.Secure || cookie.SameSite != http.SameSiteStrictMode || cookie.Path != "/" { + t.Fatalf("cookie is not hardened: %#v", cookie) + } + } + if !cookies[0].HttpOnly || cookies[1].HttpOnly { + t.Fatalf("unexpected HttpOnly policy: %#v", cookies) + } +} + +func TestLogoutRejectsInvalidCSRF(t *testing.T) { + t.Parallel() + + server := NewServer( + config.Config{HTTPAddress: ":0", WebRoot: t.TempDir()}, + slog.New(slog.NewTextHandler(io.Discard, nil)), + systemServiceStub{}, + authenticationServiceStub{logoutErr: application.ErrCSRFValidation}, + http.NotFoundHandler(), + ) + request := httptest.NewRequest(http.MethodDelete, "/api/v1/sessions/current", nil) + recorder := httptest.NewRecorder() + + server.httpServer.Handler.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusForbidden { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusForbidden) + } + if !strings.Contains(recorder.Body.String(), `"code":"csrf_validation_failed"`) { + t.Fatalf("unexpected body: %s", recorder.Body.String()) + } +}