Implement owner setup and browser authentication sessions
Some checks failed
CI / Backend (push) Failing after 1s
CI / Frontend (push) Successful in 13s
CI / Contracts and repository policy (push) Failing after 3s
CI / Container (push) Has been skipped

This commit is contained in:
Elijah 2026-07-16 19:45:40 -07:00
parent 077cf7601a
commit 715423ab8e
21 changed files with 2185 additions and 14 deletions

View file

@ -6,7 +6,7 @@ The approved product and architecture baseline is [`Drive_v2_final_plan.md`](Dri
## Repository status ## 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 ## 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. 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 ## Development
- `go test ./cmd/... ./internal/...` runs backend tests. - `go test ./cmd/... ./internal/...` runs backend tests.

View file

@ -27,6 +27,116 @@ paths:
application/problem+json: application/problem+json:
schema: schema:
$ref: "#/components/schemas/Problem" $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: /health/live:
get: get:
security: [] security: []
@ -58,7 +168,113 @@ paths:
schema: schema:
$ref: "#/components/schemas/Problem" $ref: "#/components/schemas/Problem"
components: 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: 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: SetupStatus:
type: object type: object
required: [initialized, phase, version] required: [initialized, phase, version]

View file

@ -10,6 +10,7 @@ import (
"syscall" "syscall"
"time" "time"
"drive.local/drivev2/internal/adapters/authn"
"drive.local/drivev2/internal/adapters/postgres" "drive.local/drivev2/internal/adapters/postgres"
"drive.local/drivev2/internal/adapters/webassets" "drive.local/drivev2/internal/adapters/webassets"
"drive.local/drivev2/internal/application" "drive.local/drivev2/internal/application"
@ -67,7 +68,13 @@ func serve() error {
defer store.Close() defer store.Close()
systemService := application.NewSystemService(store, version) 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) ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop() defer stop()

View file

@ -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;

View file

@ -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.

View file

@ -13,3 +13,4 @@ ADRs are immutable after acceptance. Supersede an earlier decision with a new AD
| 0007 | Open first-run setup | Accepted risk | | 0007 | Open first-run setup | Accepted risk |
| 0008 | Sync-capable server contract | Accepted | | 0008 | Sync-capable server contract | Accepted |
| 0009 | External protocol gateway boundary | Accepted | | 0009 | External protocol gateway boundary | Accepted |
| 0010 | Authentication primitives and browser session policy | Accepted |

View file

@ -4,7 +4,7 @@ Dependencies are pinned in `go.mod`, `web/package.json`, the npm lockfile, conta
## Phase 1 baseline ## 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. Frontend runtime dependencies are React, React DOM, TanStack Query, and TanStack Router. Development dependencies provide TypeScript, Vite, Vitest, ESLint, React tooling, and type declarations.

8
go.mod
View file

@ -4,14 +4,16 @@ go 1.26.0
require ( require (
github.com/golang-migrate/migrate/v4 v4.19.1 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 github.com/jackc/pgx/v5 v5.10.0
golang.org/x/crypto v0.54.0
golang.org/x/text v0.40.0
) )
require ( require (
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/sync v0.18.0 // indirect golang.org/x/sync v0.22.0 // indirect
golang.org/x/text v0.31.0 // indirect golang.org/x/sys v0.47.0 // indirect
) )

14
go.sum
View file

@ -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/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 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= 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/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= 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=

View file

@ -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()
}

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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")
}

View file

@ -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
}

View file

@ -9,8 +9,20 @@ import (
) )
type Querier interface { 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) 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) 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) var _ Querier = (*Queries)(nil)

View file

@ -2,11 +2,26 @@ package postgres
import ( import (
"context" "context"
"errors"
"os" "os"
"sync"
"testing" "testing"
"time" "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) { func TestMigrationsAndStore(t *testing.T) {
databaseURL := os.Getenv("DRIVE_TEST_DATABASE_URL") databaseURL := os.Getenv("DRIVE_TEST_DATABASE_URL")
if databaseURL == "" { if databaseURL == "" {
@ -38,4 +53,85 @@ func TestMigrationsAndStore(t *testing.T) {
if initialized { if initialized {
t.Fatal("OwnerExists = true on a fresh database, want false") 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)
}
} }

View file

@ -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,
})
}

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -3,16 +3,28 @@ package httpapi
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"io"
"log/slog" "log/slog"
"net"
"net/http" "net/http"
"strings"
"time" "time"
"drive.local/drivev2/internal/application" "drive.local/drivev2/internal/application"
"drive.local/drivev2/internal/config" "drive.local/drivev2/internal/config"
"drive.local/drivev2/internal/domain"
) )
var ErrServerClosed = http.ErrServerClosed var ErrServerClosed = http.ErrServerClosed
const (
sessionCookieName = "__Host-drive_session"
csrfCookieName = "__Host-drive_csrf"
csrfHeaderName = "X-CSRF-Token"
maximumJSONBody = 1 << 20
)
type Server struct { type Server struct {
httpServer *http.Server httpServer *http.Server
} }
@ -22,21 +34,155 @@ type SystemService interface {
SetupStatus(context.Context) (application.SetupStatus, error) 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 := http.NewServeMux()
mux.HandleFunc("GET /health/live", jsonResponse(http.StatusOK, map[string]string{"status": "live"})) mux.HandleFunc("GET /health/live", jsonResponse(http.StatusOK, map[string]string{"status": "live"}))
mux.HandleFunc("GET /health/ready", readinessHandler(logger, system)) mux.HandleFunc("GET /health/ready", readinessHandler(logger, system))
mux.HandleFunc("GET /api/v1/setup/status", setupStatusHandler(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) mux.Handle("/", frontend)
return &Server{httpServer: &http.Server{ return &Server{httpServer: &http.Server{
Addr: cfg.HTTPAddress, Addr: cfg.HTTPAddress,
Handler: requestLogger(logger, mux), Handler: securityHeaders(requestLogger(logger, mux)),
ReadHeaderTimeout: 10 * time.Second, ReadHeaderTimeout: 10 * time.Second,
IdleTimeout: 2 * time.Minute, 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 { func readinessHandler(logger *slog.Logger, system SystemService) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
if err := system.Readiness(r.Context()); err != nil { 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 { func requestLogger(logger *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
started := time.Now() started := time.Now()

View file

@ -9,6 +9,7 @@ import (
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"testing" "testing"
"time"
"drive.local/drivev2/internal/application" "drive.local/drivev2/internal/application"
"drive.local/drivev2/internal/config" "drive.local/drivev2/internal/config"
@ -28,6 +29,32 @@ func (s systemServiceStub) SetupStatus(context.Context) (application.SetupStatus
return s.setupStatus, s.setupErr 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) { func TestLiveHealth(t *testing.T) {
t.Parallel() t.Parallel()
@ -35,6 +62,7 @@ func TestLiveHealth(t *testing.T) {
config.Config{HTTPAddress: ":0", WebRoot: t.TempDir()}, config.Config{HTTPAddress: ":0", WebRoot: t.TempDir()},
slog.New(slog.NewTextHandler(io.Discard, nil)), slog.New(slog.NewTextHandler(io.Discard, nil)),
systemServiceStub{}, systemServiceStub{},
authenticationServiceStub{},
http.NotFoundHandler(), http.NotFoundHandler(),
) )
request := httptest.NewRequest(http.MethodGet, "/health/live", nil) request := httptest.NewRequest(http.MethodGet, "/health/live", nil)
@ -57,6 +85,7 @@ func TestReadinessReportsDependencyFailure(t *testing.T) {
config.Config{HTTPAddress: ":0", WebRoot: t.TempDir()}, config.Config{HTTPAddress: ":0", WebRoot: t.TempDir()},
slog.New(slog.NewTextHandler(io.Discard, nil)), slog.New(slog.NewTextHandler(io.Discard, nil)),
systemServiceStub{readinessErr: errors.New("database unavailable")}, systemServiceStub{readinessErr: errors.New("database unavailable")},
authenticationServiceStub{},
http.NotFoundHandler(), http.NotFoundHandler(),
) )
request := httptest.NewRequest(http.MethodGet, "/health/ready", nil) request := httptest.NewRequest(http.MethodGet, "/health/ready", nil)
@ -82,6 +111,7 @@ func TestSetupStatusUsesApplicationService(t *testing.T) {
config.Config{HTTPAddress: ":0", WebRoot: t.TempDir()}, config.Config{HTTPAddress: ":0", WebRoot: t.TempDir()},
slog.New(slog.NewTextHandler(io.Discard, nil)), slog.New(slog.NewTextHandler(io.Discard, nil)),
systemServiceStub{setupStatus: application.SetupStatus{Initialized: true, Phase: "phase-1", Version: "test"}}, systemServiceStub{setupStatus: application.SetupStatus{Initialized: true, Phase: "phase-1", Version: "test"}},
authenticationServiceStub{},
http.NotFoundHandler(), http.NotFoundHandler(),
) )
request := httptest.NewRequest(http.MethodGet, "/api/v1/setup/status", nil) 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()) 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())
}
}