410 lines
13 KiB
Go
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,
|
|
})
|
|
}
|