package auth import ( "context" "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/base64" "errors" "fmt" "strings" "time" "golang.org/x/crypto/bcrypt" "vocat/internal/store" ) var ( ErrInvalidCredentials = errors.New("invalid credentials") ErrUnauthorized = errors.New("unauthorized") ErrInvalidCSRF = errors.New("invalid csrf token") ) type Options struct { SessionTTL time.Duration BcryptCost int } type Service struct { store *store.Store sessionTTL time.Duration bcryptCost int dummyHash []byte } type Principal struct { ID int64 `json:"-"` Username string `json:"username"` } type Credentials struct { SessionToken string CSRFToken string ExpiresAt time.Time Principal Principal } type AuthenticatedSession struct { Principal Principal ExpiresAt time.Time tokenHash []byte csrfHash []byte } func New(database *store.Store, options Options) (*Service, error) { if database == nil { return nil, errors.New("auth: store is required") } if options.SessionTTL <= 0 { return nil, errors.New("auth: session TTL must be positive") } if options.BcryptCost == 0 { options.BcryptCost = 12 } if options.BcryptCost < bcrypt.MinCost || options.BcryptCost > bcrypt.MaxCost { return nil, errors.New("auth: bcrypt cost is out of range") } dummyHash, err := bcrypt.GenerateFromPassword([]byte("not-a-real-password"), options.BcryptCost) if err != nil { return nil, fmt.Errorf("auth: generate timing hash: %w", err) } return &Service{ store: database, sessionTTL: options.SessionTTL, bcryptCost: options.BcryptCost, dummyHash: dummyHash, }, nil } // EnsureAdmin configures the single administrator. Existing sessions are // revoked only when the configured username or password changes. func (s *Service) EnsureAdmin(ctx context.Context, username string, password string) error { username = strings.TrimSpace(username) current, err := s.store.CurrentAdmin(ctx) if err == nil && current.Username == username && bcrypt.CompareHashAndPassword(current.PasswordHash, []byte(password)) == nil { return nil } if err != nil && !errors.Is(err, store.ErrNotFound) { return fmt.Errorf("auth: read configured admin: %w", err) } passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), s.bcryptCost) if err != nil { return fmt.Errorf("auth: hash admin password: %w", err) } if err := s.store.SetAdmin(ctx, username, passwordHash); err != nil { return err } return nil } func (s *Service) Login(ctx context.Context, username string, password string) (Credentials, error) { admin, err := s.store.AdminByUsername(ctx, strings.TrimSpace(username)) if errors.Is(err, store.ErrNotFound) { _ = bcrypt.CompareHashAndPassword(s.dummyHash, []byte(password)) return Credentials{}, ErrInvalidCredentials } if err != nil { return Credentials{}, fmt.Errorf("auth: find admin: %w", err) } if bcrypt.CompareHashAndPassword(admin.PasswordHash, []byte(password)) != nil { return Credentials{}, ErrInvalidCredentials } if err := s.store.DeleteExpiredSessions(ctx, time.Now()); err != nil { return Credentials{}, err } sessionToken, err := randomToken() if err != nil { return Credentials{}, err } csrfToken, err := randomToken() if err != nil { return Credentials{}, err } expiresAt := time.Now().UTC().Add(s.sessionTTL) if err := s.store.CreateSession( ctx, admin.ID, hashToken(sessionToken), hashToken(csrfToken), expiresAt, ); err != nil { return Credentials{}, err } return Credentials{ SessionToken: sessionToken, CSRFToken: csrfToken, ExpiresAt: expiresAt, Principal: Principal{ ID: admin.ID, Username: admin.Username, }, }, nil } func (s *Service) Authenticate(ctx context.Context, sessionToken string) (AuthenticatedSession, error) { if sessionToken == "" { return AuthenticatedSession{}, ErrUnauthorized } tokenHash := hashToken(sessionToken) session, err := s.store.SessionByTokenHash(ctx, tokenHash) if errors.Is(err, store.ErrNotFound) { return AuthenticatedSession{}, ErrUnauthorized } if err != nil { return AuthenticatedSession{}, fmt.Errorf("auth: load session: %w", err) } if !session.ExpiresAt.After(time.Now().UTC()) { _ = s.store.DeleteSession(ctx, tokenHash) return AuthenticatedSession{}, ErrUnauthorized } return AuthenticatedSession{ Principal: Principal{ ID: session.Admin.ID, Username: session.Admin.Username, }, ExpiresAt: session.ExpiresAt, tokenHash: tokenHash, csrfHash: session.CSRFHash, }, nil } // RotateCSRF replaces the session-bound CSRF value and returns the new raw // token. Only its SHA-256 digest is persisted. func (s *Service) RotateCSRF(ctx context.Context, sessionToken string) (AuthenticatedSession, string, error) { return s.CSRFToken(ctx, sessionToken, "") } // CSRFToken reuses a valid CSRF cookie or rotates it when the cookie is absent // or stale. Reuse prevents one browser tab from invalidating another tab's // session-bound token. func (s *Service) CSRFToken( ctx context.Context, sessionToken string, existingToken string, ) (AuthenticatedSession, string, error) { session, err := s.Authenticate(ctx, sessionToken) if err != nil { return AuthenticatedSession{}, "", err } if existingToken != "" { existingHash := hashToken(existingToken) if subtle.ConstantTimeCompare(existingHash, session.csrfHash) == 1 { return session, existingToken, nil } } csrfToken, err := randomToken() if err != nil { return AuthenticatedSession{}, "", err } csrfHash := hashToken(csrfToken) if err := s.store.UpdateSessionCSRF(ctx, session.tokenHash, csrfHash); err != nil { if errors.Is(err, store.ErrNotFound) { return AuthenticatedSession{}, "", ErrUnauthorized } return AuthenticatedSession{}, "", err } session.csrfHash = csrfHash return session, csrfToken, nil } func (s *Service) ValidateCSRF( ctx context.Context, sessionToken string, csrfToken string, ) (AuthenticatedSession, error) { if csrfToken == "" { return AuthenticatedSession{}, ErrInvalidCSRF } session, err := s.Authenticate(ctx, sessionToken) if err != nil { return AuthenticatedSession{}, err } providedHash := hashToken(csrfToken) if subtle.ConstantTimeCompare(providedHash, session.csrfHash) != 1 { return AuthenticatedSession{}, ErrInvalidCSRF } return session, nil } func (s *Service) Logout(ctx context.Context, sessionToken string) error { if sessionToken == "" { return nil } if err := s.store.DeleteSession(ctx, hashToken(sessionToken)); err != nil { return err } return nil } // ChangePassword verifies the current password, replaces it with a fresh // bcrypt hash and revokes every session through Store.SetAdmin. func (s *Service) ChangePassword( ctx context.Context, username string, currentPassword string, newPassword string, ) error { if len(newPassword) < 12 || len(newPassword) > 1024 { return errors.New("new password must contain between 12 and 1024 characters") } admin, err := s.store.AdminByUsername(ctx, strings.TrimSpace(username)) if errors.Is(err, store.ErrNotFound) { _ = bcrypt.CompareHashAndPassword(s.dummyHash, []byte(currentPassword)) return ErrInvalidCredentials } if err != nil { return fmt.Errorf("auth: find admin: %w", err) } if bcrypt.CompareHashAndPassword(admin.PasswordHash, []byte(currentPassword)) != nil { return ErrInvalidCredentials } if bcrypt.CompareHashAndPassword(admin.PasswordHash, []byte(newPassword)) == nil { return errors.New("new password must differ from the current password") } passwordHash, err := bcrypt.GenerateFromPassword([]byte(newPassword), s.bcryptCost) if err != nil { return fmt.Errorf("auth: hash new password: %w", err) } if err := s.store.SetAdmin(ctx, admin.Username, passwordHash); err != nil { return fmt.Errorf("auth: save new password: %w", err) } return nil } func randomToken() (string, error) { buffer := make([]byte, 32) if _, err := rand.Read(buffer); err != nil { return "", fmt.Errorf("auth: generate random token: %w", err) } return base64.RawURLEncoding.EncodeToString(buffer), nil } func hashToken(token string) []byte { digest := sha256.Sum256([]byte(token)) return digest[:] }