mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-13 03:13:43 +08:00
292 lines
8.0 KiB
Go
292 lines
8.0 KiB
Go
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[:]
|
|
}
|