| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291 |
- 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[:]
- }
|