newdrive/internal/application/authentication.go
Elijah 715423ab8e
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
Implement owner setup and browser authentication sessions
2026-07-16 19:45:40 -07:00

410 lines
13 KiB
Go

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