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
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,
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue