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