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

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

View file

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

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 (
"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()

View file

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