Implement owner setup and browser authentication sessions
This commit is contained in:
parent
077cf7601a
commit
715423ab8e
21 changed files with 2185 additions and 14 deletions
46
internal/adapters/authn/credentials.go
Normal file
46
internal/adapters/authn/credentials.go
Normal 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()
|
||||
}
|
||||
127
internal/adapters/authn/password.go
Normal file
127
internal/adapters/authn/password.go
Normal 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
|
||||
}
|
||||
40
internal/adapters/authn/password_test.go
Normal file
40
internal/adapters/authn/password_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
214
internal/adapters/postgres/authentication.go
Normal file
214
internal/adapters/postgres/authentication.go
Normal 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")
|
||||
}
|
||||
409
internal/adapters/postgres/generated/authentication.sql.go
Normal file
409
internal/adapters/postgres/generated/authentication.sql.go
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
410
internal/application/authentication.go
Normal file
410
internal/application/authentication.go
Normal 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,
|
||||
})
|
||||
}
|
||||
73
internal/domain/identity.go
Normal file
73
internal/domain/identity.go
Normal 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
|
||||
}
|
||||
26
internal/domain/identity_test.go
Normal file
26
internal/domain/identity_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue