259 lines
7.5 KiB
Go
259 lines
7.5 KiB
Go
package service
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/lixenwraith/chess/internal/server/storage"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/lixenwraith/auth"
|
|
)
|
|
|
|
var (
|
|
ErrStorageDisabled = errors.New("storage disabled")
|
|
ErrStorageUnavailable = errors.New("storage unavailable")
|
|
ErrAtCapacity = errors.New("at capacity")
|
|
ErrUserExists = errors.New("username or email already exists")
|
|
ErrInvalidCredentials = errors.New("invalid credentials")
|
|
ErrAuthBusy = errors.New("authentication capacity exhausted")
|
|
)
|
|
|
|
const (
|
|
// JWTIssuer and JWTAudience scope chess tokens so a token minted by another
|
|
// service sharing a key is rejected.
|
|
JWTIssuer = "chess-server"
|
|
JWTAudience = "chess-api"
|
|
|
|
// MaxConcurrentKDF bounds simultaneous Argon2id derivations (64 MiB each at
|
|
// the auth defaults), capping password-hashing memory near 256 MiB.
|
|
MaxConcurrentKDF = 4
|
|
kdfWaitTimeout = 5 * time.Second
|
|
)
|
|
|
|
// dummyPasswordHash is verified for unknown identifiers so a login for a
|
|
// missing account costs the same Argon2id work as one with a wrong password.
|
|
var dummyPasswordHash = sync.OnceValues(func() (string, error) {
|
|
return auth.HashPassword(uuid.NewString())
|
|
})
|
|
|
|
// User represents a registered user account
|
|
type User struct {
|
|
UserID string
|
|
Username string
|
|
Email string
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
// acquireKDF reserves one Argon2id slot, waiting at most kdfWaitTimeout.
|
|
func (s *Service) acquireKDF() (release func(), err error) {
|
|
timer := time.NewTimer(kdfWaitTimeout)
|
|
defer timer.Stop()
|
|
select {
|
|
case s.kdf <- struct{}{}:
|
|
return func() { <-s.kdf }, nil
|
|
case <-timer.C:
|
|
return nil, ErrAuthBusy
|
|
}
|
|
}
|
|
|
|
func (s *Service) hashPassword(password string) (string, error) {
|
|
release, err := s.acquireKDF()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer release()
|
|
return auth.HashPassword(password)
|
|
}
|
|
|
|
func (s *Service) verifyPassword(password, hash string) error {
|
|
release, err := s.acquireKDF()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer release()
|
|
return auth.VerifyPassword(password, hash)
|
|
}
|
|
|
|
// RegisterUser creates the account and its initial session in one database
|
|
// transaction, so a successful registration always returns a usable account.
|
|
// Registered accounts are identical to CLI-created ones and never expire.
|
|
func (s *Service) RegisterUser(username, email, password string) (*User, string, error) {
|
|
if s.store == nil {
|
|
return nil, "", ErrStorageDisabled
|
|
}
|
|
|
|
// Hash before touching storage; account creation itself is serialized by
|
|
// the store, so concurrent registrations only contend for KDF slots.
|
|
passwordHash, err := s.hashPassword(password)
|
|
if err != nil {
|
|
if errors.Is(err, ErrAuthBusy) {
|
|
return nil, "", err
|
|
}
|
|
return nil, "", fmt.Errorf("failed to hash password: %w", err)
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
|
|
// A random UUIDv4 needs no existence probe; the primary key rejects the
|
|
// negligible collision case.
|
|
userID := uuid.NewString()
|
|
user := &User{
|
|
UserID: userID,
|
|
Username: strings.ToLower(username),
|
|
Email: strings.ToLower(email),
|
|
CreatedAt: now,
|
|
}
|
|
record := storage.UserRecord{
|
|
UserID: userID,
|
|
Username: user.Username,
|
|
Email: user.Email,
|
|
PasswordHash: passwordHash,
|
|
CreatedAt: now,
|
|
}
|
|
sessionID := uuid.NewString()
|
|
session := &storage.SessionRecord{
|
|
SessionID: sessionID,
|
|
UserID: userID,
|
|
CreatedAt: now,
|
|
ExpiresAt: now.Add(SessionTTL),
|
|
}
|
|
limits := storage.UserLimits{MaxUsers: int(s.maxUsers.Load())}
|
|
if err = s.store.CreateUserWithinLimits(record, session, limits); err != nil {
|
|
switch {
|
|
case errors.Is(err, storage.ErrUserAlreadyExists):
|
|
return nil, "", ErrUserExists
|
|
case errors.Is(err, storage.ErrUserCapacity):
|
|
return nil, "", fmt.Errorf("%w: registration is limited to %d accounts", ErrAtCapacity, limits.MaxUsers)
|
|
default:
|
|
return nil, "", fmt.Errorf("%w: create user: %v", ErrStorageUnavailable, err)
|
|
}
|
|
}
|
|
slog.Debug("user created", "user_id", userID)
|
|
|
|
return user, sessionID, nil
|
|
}
|
|
|
|
// AuthenticateUser verifies credentials and creates a new session. Unknown
|
|
// identifiers and wrong passwords both return ErrInvalidCredentials after the
|
|
// same Argon2id work.
|
|
func (s *Service) AuthenticateUser(identifier, password string) (*User, string, error) {
|
|
if s.store == nil {
|
|
return nil, "", ErrStorageDisabled
|
|
}
|
|
|
|
var userRecord *storage.UserRecord
|
|
var err error
|
|
if strings.Contains(identifier, "@") {
|
|
userRecord, err = s.store.GetUserByEmail(identifier)
|
|
} else {
|
|
userRecord, err = s.store.GetUserByUsername(identifier)
|
|
}
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
return nil, "", fmt.Errorf("%w: look up user: %v", ErrStorageUnavailable, err)
|
|
}
|
|
|
|
hash := ""
|
|
if userRecord != nil {
|
|
hash = userRecord.PasswordHash
|
|
} else if hash, err = dummyPasswordHash(); err != nil {
|
|
return nil, "", fmt.Errorf("prepare credential check: %w", err)
|
|
}
|
|
if err := s.verifyPassword(password, hash); err != nil || userRecord == nil {
|
|
if errors.Is(err, ErrAuthBusy) {
|
|
return nil, "", err
|
|
}
|
|
return nil, "", ErrInvalidCredentials
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
// Replaces any existing session and records the login time.
|
|
sessionID := uuid.NewString()
|
|
if err := s.store.CreateSession(storage.SessionRecord{
|
|
SessionID: sessionID,
|
|
UserID: userRecord.UserID,
|
|
CreatedAt: now,
|
|
ExpiresAt: now.Add(SessionTTL),
|
|
}); err != nil {
|
|
return nil, "", fmt.Errorf("%w: create session: %v", ErrStorageUnavailable, err)
|
|
}
|
|
slog.Debug("user authenticated", "user_id", userRecord.UserID)
|
|
|
|
return &User{
|
|
UserID: userRecord.UserID,
|
|
Username: userRecord.Username,
|
|
Email: userRecord.Email,
|
|
CreatedAt: userRecord.CreatedAt,
|
|
}, sessionID, nil
|
|
}
|
|
|
|
// InvalidateSession removes a session (logout)
|
|
func (s *Service) InvalidateSession(sessionID string) error {
|
|
if s.store == nil {
|
|
return ErrStorageDisabled
|
|
}
|
|
if err := s.store.DeleteSession(sessionID); err != nil {
|
|
return fmt.Errorf("%w: invalidate session: %v", ErrStorageUnavailable, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetUserByID retrieves user information by user ID
|
|
func (s *Service) GetUserByID(userID string) (*User, error) {
|
|
if s.store == nil {
|
|
return nil, ErrStorageDisabled
|
|
}
|
|
|
|
userRecord, err := s.store.GetUserByID(userID)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, fmt.Errorf("user not found")
|
|
}
|
|
return nil, fmt.Errorf("%w: get user: %v", ErrStorageUnavailable, err)
|
|
}
|
|
|
|
return &User{
|
|
UserID: userRecord.UserID,
|
|
Username: userRecord.Username,
|
|
Email: userRecord.Email,
|
|
CreatedAt: userRecord.CreatedAt,
|
|
}, nil
|
|
}
|
|
|
|
// GenerateUserToken signs a scoped JWT bound to the persisted session. The
|
|
// token carries only the subject and session ID: JWT payloads are readable by
|
|
// the holder, so profile data such as email stays behind /auth/me.
|
|
func (s *Service) GenerateUserToken(userID, sessionID string) (string, error) {
|
|
return s.jwt.GenerateToken(userID, map[string]any{"session_id": sessionID})
|
|
}
|
|
|
|
// ValidateToken verifies JWT token and session validity
|
|
func (s *Service) ValidateToken(token string) (string, map[string]any, error) {
|
|
userID, claims, err := s.jwt.ValidateToken(token)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
|
|
if s.store == nil {
|
|
return "", nil, ErrStorageDisabled
|
|
}
|
|
sessionID, ok := claims["session_id"].(string)
|
|
if !ok || sessionID == "" {
|
|
return "", nil, fmt.Errorf("token has no persisted session")
|
|
}
|
|
valid, err := s.store.IsSessionValidForUser(sessionID, userID)
|
|
if err != nil {
|
|
return "", nil, fmt.Errorf("%w: validate token session: %v", ErrStorageUnavailable, err)
|
|
}
|
|
if !valid {
|
|
return "", nil, fmt.Errorf("session invalidated")
|
|
}
|
|
|
|
return userID, claims, nil
|
|
}
|