v0.12.0 migrate sqlite3 to psql18, updated auth, deploy docs, account and replay updates
test / test (push) Canceled after 0s
test / test (push) Canceled after 0s
This commit is contained in:
1 parent
5d2be4abb2
commit
d0326b059e
66 files changed
+4016
-2433
No files matched your search
@@ -127,6 +127,7 @@ func (s *Service) UpdatePlayers(gameID string, whitePlayer, blackPlayer *core.Pl
|
||||
}
|
||||
|
||||
g.UpdatePlayers(whitePlayer, blackPlayer)
|
||||
g.Touch(time.Now().UTC())
|
||||
if oldHasComputer != newHasComputer {
|
||||
if newHasComputer {
|
||||
s.computerGames.Add(1)
|
||||
@@ -302,20 +303,6 @@ func (s *Service) UpdateGameState(gameID string, state core.State) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetLastMoveResult stores metadata about the last move
|
||||
func (s *Service) SetLastMoveResult(gameID string, result *game.MoveResult) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
g, ok := s.games[gameID]
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
|
||||
}
|
||||
|
||||
g.SetLastResult(result)
|
||||
return nil
|
||||
}
|
||||
|
||||
// UndoMoves removes the specified number of moves from game history
|
||||
func (s *Service) UndoMoves(gameID string, count int) error {
|
||||
s.mu.Lock()
|
||||
@@ -334,6 +321,7 @@ func (s *Service) UndoMoves(gameID string, count int) error {
|
||||
if err := g.UndoMoves(count); err != nil {
|
||||
return err
|
||||
}
|
||||
g.Touch(time.Now().UTC())
|
||||
|
||||
// Notify waiting clients about the undo
|
||||
s.waiter.NotifyGame(gameID, len(g.Moves()), g.State())
|
||||
|
||||
@@ -2,21 +2,24 @@ package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"chess/internal/server/core"
|
||||
"chess/internal/server/game"
|
||||
"chess/internal/server/storage"
|
||||
"chess/internal/server/storage/pgtest"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestMoveCommitClaimsSlotPersistsResultAndRejectsStalePosition(t *testing.T) {
|
||||
svc := newPersistentTestService(t)
|
||||
gameID, userID := uuid.NewString(), uuid.NewString()
|
||||
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
|
||||
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
|
||||
if err := svc.CreateGame(
|
||||
"game-1", white, black, "initial", core.ColorWhite, core.StateOngoing,
|
||||
gameID, white, black, "initial", core.ColorWhite, core.StateOngoing,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -24,53 +27,54 @@ func TestMoveCommitClaimsSlotPersistsResultAndRejectsStalePosition(t *testing.T)
|
||||
ended := time.Date(2026, 9, 7, 2, 3, 4, 0, time.UTC)
|
||||
commit := MoveCommit{
|
||||
ExpectedFEN: "initial", ExpectedState: core.StateOngoing, ExpectedTurn: core.ColorWhite,
|
||||
ActorUserID: "user-1", MoveUCI: "e2e4", NewFEN: "after", State: core.StateWhiteWins, At: ended,
|
||||
ActorUserID: userID, MoveUCI: "e2e4", NewFEN: "after", State: core.StateWhiteWins, At: ended,
|
||||
Result: &game.MoveResult{Move: "e2e4", PlayerColor: core.ColorWhite, GameState: core.StateWhiteWins},
|
||||
}
|
||||
if err := svc.ApplyMoveWithState("game-1", commit); err != nil {
|
||||
if err := svc.ApplyMoveWithState(gameID, commit); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
view, err := svc.GetGameView("game-1")
|
||||
view, err := svc.GetGameView(gameID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if view.WhitePlayer.ClaimedBy != "user-1" || view.State != core.StateWhiteWins {
|
||||
if view.WhitePlayer.ClaimedBy != userID || view.State != core.StateWhiteWins {
|
||||
t.Fatalf("in-memory move did not settle atomically: %+v", view)
|
||||
}
|
||||
history, err := svc.GetGameHistory("game-1")
|
||||
history, err := svc.GetGameHistory(gameID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if history.Players.White.ClaimedBy != "user-1" || history.Result != "white_wins" || len(history.Moves) != 1 {
|
||||
if history.Players.White.ClaimedBy != userID || history.Result != "white_wins" || len(history.Moves) != 1 {
|
||||
t.Fatalf("history did not settle atomically: %+v", history)
|
||||
}
|
||||
|
||||
if err := svc.ApplyMoveWithState("game-1", commit); !errors.Is(err, ErrGameChanged) {
|
||||
if err := svc.ApplyMoveWithState(gameID, commit); !errors.Is(err, ErrGameChanged) {
|
||||
t.Fatalf("stale move error = %v, want ErrGameChanged", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUndoClearsDurableTerminalResult(t *testing.T) {
|
||||
svc := newPersistentTestService(t)
|
||||
gameID := uuid.NewString()
|
||||
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
|
||||
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
|
||||
if err := svc.CreateGame(
|
||||
"game-1", white, black, "initial", core.ColorWhite, core.StateOngoing,
|
||||
gameID, white, black, "initial", core.ColorWhite, core.StateOngoing,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.ApplyMoveWithState("game-1", MoveCommit{
|
||||
if err := svc.ApplyMoveWithState(gameID, MoveCommit{
|
||||
ExpectedFEN: "initial", ExpectedState: core.StateOngoing, ExpectedTurn: core.ColorWhite,
|
||||
MoveUCI: "e2e4", NewFEN: "after", State: core.StateStalemate,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.UndoMoves("game-1", 1); err != nil {
|
||||
if err := svc.UndoMoves(gameID, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
history, err := svc.GetGameHistory("game-1")
|
||||
history, err := svc.GetGameHistory(gameID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -81,12 +85,13 @@ func TestUndoClearsDurableTerminalResult(t *testing.T) {
|
||||
|
||||
func TestPlayerReconfigurationPreservesClaimAndPersistsConfiguration(t *testing.T) {
|
||||
svc := newPersistentTestService(t)
|
||||
gameID, userID := uuid.NewString(), uuid.NewString()
|
||||
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
|
||||
white.ID = "user-1"
|
||||
white.ClaimedBy = "user-1"
|
||||
white.ID = userID
|
||||
white.ClaimedBy = userID
|
||||
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
|
||||
if err := svc.CreateGame(
|
||||
"game-1", white, black, "initial", core.ColorWhite, core.StateOngoing,
|
||||
gameID, white, black, "initial", core.ColorWhite, core.StateOngoing,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -95,15 +100,15 @@ func TestPlayerReconfigurationPreservesClaimAndPersistsConfiguration(t *testing.
|
||||
replacementBlack := core.NewPlayer(
|
||||
core.PlayerConfig{Type: core.PlayerComputer, Level: 12, SearchTime: 500}, core.ColorBlack,
|
||||
)
|
||||
if err := svc.UpdatePlayers("game-1", replacementWhite, replacementBlack); err != nil {
|
||||
if err := svc.UpdatePlayers(gameID, replacementWhite, replacementBlack); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
history, err := svc.GetGameHistory("game-1")
|
||||
history, err := svc.GetGameHistory(gameID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if history.Players.White.ID != "user-1" || history.Players.White.ClaimedBy != "user-1" {
|
||||
if history.Players.White.ID != userID || history.Players.White.ClaimedBy != userID {
|
||||
t.Fatalf("white ownership was replaced: %+v", history.Players.White)
|
||||
}
|
||||
if history.Players.Black.Type != core.PlayerComputer || history.Players.Black.Level != 12 {
|
||||
@@ -113,48 +118,101 @@ func TestPlayerReconfigurationPreservesClaimAndPersistsConfiguration(t *testing.
|
||||
computerWhite := core.NewPlayer(
|
||||
core.PlayerConfig{Type: core.PlayerComputer, Level: 8, SearchTime: 300}, core.ColorWhite,
|
||||
)
|
||||
if err := svc.UpdatePlayers("game-1", computerWhite, replacementBlack); err != nil {
|
||||
if err := svc.UpdatePlayers(gameID, computerWhite, replacementBlack); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
history, err = svc.GetGameHistory("game-1")
|
||||
history, err = svc.GetGameHistory(gameID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if history.Players.White.ClaimedBy != "user-1" {
|
||||
if history.Players.White.ClaimedBy != userID {
|
||||
t.Fatalf("player-type change discarded historical ownership: %+v", history.Players.White)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupEvictsOnlyMemoryCopyOfTerminalGame(t *testing.T) {
|
||||
svc := newPersistentTestService(t)
|
||||
gameID := uuid.NewString()
|
||||
svc.SetFinishedGameTTL(time.Nanosecond)
|
||||
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
|
||||
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
|
||||
if err := svc.CreateGame(
|
||||
"game-1", white, black, "terminal", core.ColorWhite, core.StateStalemate,
|
||||
gameID, white, black, "terminal", core.ColorWhite, core.StateStalemate,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
svc.cleanupFinishedGames(time.Now().UTC().Add(time.Second))
|
||||
if _, err := svc.GetGameView("game-1"); !errors.Is(err, ErrGameNotFound) {
|
||||
svc.cleanupGames(time.Now().UTC().Add(time.Second))
|
||||
if _, err := svc.GetGameView(gameID); !errors.Is(err, ErrGameNotFound) {
|
||||
t.Fatalf("terminal game remains in memory: %v", err)
|
||||
}
|
||||
if history, err := svc.GetGameHistory("game-1"); err != nil || history.Result != "stalemate" {
|
||||
if history, err := svc.GetGameHistory(gameID); err != nil || history.Result != "stalemate" {
|
||||
t.Fatalf("durable history was removed: history=%+v err=%v", history, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupRemovesIdleAnonymousGamesButKeepsClaimedOnes(t *testing.T) {
|
||||
svc := newPersistentTestService(t)
|
||||
svc.SetFinishedGameTTL(0)
|
||||
user, _, err := svc.RegisterUser("alice", "", "Password1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
anonymous, claimed := uuid.NewString(), uuid.NewString()
|
||||
for gameID, claimant := range map[string]string{anonymous: "", claimed: user.UserID} {
|
||||
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
|
||||
if claimant != "" {
|
||||
white.ID, white.ClaimedBy = claimant, claimant
|
||||
}
|
||||
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
|
||||
if err := svc.CreateGame(gameID, white, black, "initial", core.ColorWhite, core.StateOngoing); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
history, err := svc.GetGameHistory(claimed)
|
||||
if err != nil || history.Players.White.Name != "alice" {
|
||||
t.Fatalf("claimed history name = %+v, %v", history, err)
|
||||
}
|
||||
|
||||
// Within the retention window both games stay loaded and stored.
|
||||
svc.cleanupGames(time.Now().UTC().Add(time.Hour))
|
||||
for _, gameID := range []string{anonymous, claimed} {
|
||||
if _, err := svc.GetGameView(gameID); err != nil {
|
||||
t.Fatalf("game %s evicted early: %v", gameID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Past it, the anonymous game leaves memory and the database; the
|
||||
// registered user's game is untouched.
|
||||
svc.cleanupGames(time.Now().UTC().Add(AnonymousGameTTL + time.Hour))
|
||||
if _, err := svc.GetGameView(anonymous); !errors.Is(err, ErrGameNotFound) {
|
||||
t.Fatalf("idle anonymous game still loaded: %v", err)
|
||||
}
|
||||
if _, err := svc.GetGameHistory(anonymous); !errors.Is(err, ErrGameNotFound) {
|
||||
t.Fatalf("idle anonymous game still stored: %v", err)
|
||||
}
|
||||
if _, err := svc.GetGameView(claimed); err != nil {
|
||||
t.Fatalf("claimed game evicted: %v", err)
|
||||
}
|
||||
if _, err := svc.GetGameHistory(claimed); err != nil {
|
||||
t.Fatalf("claimed game deleted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func newPersistentTestService(t *testing.T) *Service {
|
||||
t.Helper()
|
||||
store, err := storage.NewStore(filepath.Join(t.TempDir(), "chess.db"), false)
|
||||
store, err := storage.NewStore(pgtest.DSN(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.InitDB(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := New(store, []byte("test-secret-test-secret-test-secret"))
|
||||
svc, err := New(store, testJWTSecret)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := svc.Shutdown(time.Second); err != nil {
|
||||
t.Errorf("shutdown: %v", err)
|
||||
|
||||
@@ -82,6 +82,7 @@ func (s *Service) GetUserGames(userID string, limit, offset int) (*core.GameList
|
||||
StartTimeUTC: record.StartTimeUTC,
|
||||
EndTimeUTC: record.EndTimeUTC,
|
||||
MoveCount: record.MoveCount,
|
||||
FinalFEN: record.FinalFEN,
|
||||
Players: playersResponse(record.GameRecord),
|
||||
})
|
||||
}
|
||||
@@ -100,10 +101,12 @@ func playersResponse(record storage.GameRecord) core.PlayersResponse {
|
||||
White: &core.Player{
|
||||
ID: record.WhitePlayerID, Color: core.ColorWhite, Type: core.PlayerType(record.WhiteType),
|
||||
Level: record.WhiteLevel, SearchTime: record.WhiteSearchTime, ClaimedBy: record.WhiteClaimedBy,
|
||||
Name: record.WhiteName,
|
||||
},
|
||||
Black: &core.Player{
|
||||
ID: record.BlackPlayerID, Color: core.ColorBlack, Type: core.PlayerType(record.BlackType),
|
||||
Level: record.BlackLevel, SearchTime: record.BlackSearchTime, ClaimedBy: record.BlackClaimedBy,
|
||||
Name: record.BlackName,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"chess/internal/server/core"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -10,41 +9,62 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"chess/internal/server/core"
|
||||
"chess/internal/server/game"
|
||||
"chess/internal/server/storage"
|
||||
|
||||
"github.com/lixenwraith/auth"
|
||||
)
|
||||
|
||||
const (
|
||||
MaxComputerGames = 10
|
||||
MaxUsers = 100
|
||||
PermanentSlots = 10
|
||||
TempUserTTL = 24 * time.Hour
|
||||
DefaultMaxUsers = 100
|
||||
SessionTTL = 7 * 24 * time.Hour
|
||||
CleanupJobInterval = 1 * time.Hour
|
||||
FinishedGameTTL = 1 * time.Hour
|
||||
// AnonymousGameTTL is how long a game no registered user claimed survives
|
||||
// after its last activity, in memory and in the database.
|
||||
AnonymousGameTTL = 24 * time.Hour
|
||||
)
|
||||
|
||||
// Service coordinates game state, user management, and storage
|
||||
type Service struct {
|
||||
games map[string]*game.Game
|
||||
mu sync.RWMutex
|
||||
userMu sync.Mutex
|
||||
store *storage.Store
|
||||
jwtSecret []byte
|
||||
jwt *auth.JWT
|
||||
kdf chan struct{} // Argon2id concurrency slots
|
||||
waiter *WaitRegistry
|
||||
computerGames atomic.Int32 // Active games with computer players
|
||||
finishedTTL time.Duration
|
||||
anonymousTTL time.Duration
|
||||
maxUsers atomic.Int64
|
||||
}
|
||||
|
||||
// New creates a new service instance with optional storage
|
||||
func New(store *storage.Store, jwtSecret []byte) *Service {
|
||||
return &Service{
|
||||
games: make(map[string]*game.Game),
|
||||
store: store,
|
||||
jwtSecret: jwtSecret,
|
||||
waiter: NewWaitRegistry(),
|
||||
finishedTTL: FinishedGameTTL,
|
||||
// New creates a service with optional storage. jwtSecret signs session tokens
|
||||
// and must hold at least 32 bytes of key material.
|
||||
func New(store *storage.Store, jwtSecret []byte) (*Service, error) {
|
||||
manager, err := auth.NewJWT(jwtSecret,
|
||||
auth.WithIssuer(JWTIssuer),
|
||||
auth.WithAudience([]string{JWTAudience}),
|
||||
auth.WithTokenLifetime(SessionTTL),
|
||||
// Tokens are minted and verified by this process: no clock skew.
|
||||
auth.WithLeeway(0),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("configure JWT: %w", err)
|
||||
}
|
||||
s := &Service{
|
||||
games: make(map[string]*game.Game),
|
||||
store: store,
|
||||
jwt: manager,
|
||||
kdf: make(chan struct{}, MaxConcurrentKDF),
|
||||
waiter: NewWaitRegistry(),
|
||||
finishedTTL: FinishedGameTTL,
|
||||
anonymousTTL: AnonymousGameTTL,
|
||||
}
|
||||
s.maxUsers.Store(DefaultMaxUsers)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// SetFinishedGameTTL configures how long terminal games remain in memory.
|
||||
@@ -56,6 +76,22 @@ func (s *Service) SetFinishedGameTTL(ttl time.Duration) {
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetAnonymousGameTTL configures how long games without a registered player
|
||||
// survive after their last activity. Such games are unloaded from memory and
|
||||
// deleted from the database once idle this long. A non-positive duration
|
||||
// keeps them indefinitely.
|
||||
func (s *Service) SetAnonymousGameTTL(ttl time.Duration) {
|
||||
s.mu.Lock()
|
||||
s.anonymousTTL = ttl
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetMaxUsers caps the number of accounts public registration may create;
|
||||
// zero removes the cap. Accounts created through the CLI are not limited.
|
||||
func (s *Service) SetMaxUsers(limit int) {
|
||||
s.maxUsers.Store(int64(max(limit, 0)))
|
||||
}
|
||||
|
||||
// GetStorageHealth returns the storage component status
|
||||
func (s *Service) GetStorageHealth() string {
|
||||
if s.store == nil {
|
||||
@@ -84,60 +120,11 @@ func (s *Service) CanCreateComputerGame() bool {
|
||||
return s.computerGames.Load() < MaxComputerGames
|
||||
}
|
||||
|
||||
// IncrementComputerGames increments the computer game counter
|
||||
func (s *Service) IncrementComputerGames() {
|
||||
s.computerGames.Add(1)
|
||||
}
|
||||
|
||||
// DecrementComputerGames decrements the computer game counter
|
||||
func (s *Service) DecrementComputerGames() {
|
||||
s.computerGames.Add(-1)
|
||||
}
|
||||
|
||||
// GetComputerGameCount returns current computer game count
|
||||
func (s *Service) GetComputerGameCount() int32 {
|
||||
return s.computerGames.Load()
|
||||
}
|
||||
|
||||
// ClaimGameSlot claims a player slot for a user
|
||||
func (s *Service) ClaimGameSlot(gameID string, color core.Color, userID string) error {
|
||||
if userID == "" {
|
||||
return errors.New("claimant user ID is required")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
g, ok := s.games[gameID]
|
||||
if !ok {
|
||||
return fmt.Errorf("game not found: %s", gameID)
|
||||
}
|
||||
|
||||
if err := g.ClaimSlot(color, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.store != nil {
|
||||
if err := s.store.RecordSlotClaim(gameID, color.String(), userID); err != nil {
|
||||
slog.Error("failed to queue slot claim persistence",
|
||||
"game_id", gameID, "color", color.String(), "error", err)
|
||||
}
|
||||
}
|
||||
slog.Debug("game slot claimed", "game_id", gameID, "color", color.String(), "user_id", userID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSlotOwner returns the user who claimed a slot
|
||||
func (s *Service) GetSlotOwner(gameID string, color core.Color) (string, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
g, ok := s.games[gameID]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("game not found: %s", gameID)
|
||||
}
|
||||
|
||||
return g.GetSlotOwner(color), nil
|
||||
}
|
||||
|
||||
// Shutdown gracefully shuts down the service
|
||||
func (s *Service) Shutdown(timeout time.Duration) error {
|
||||
var errs []error
|
||||
@@ -159,7 +146,8 @@ func (s *Service) Shutdown(timeout time.Duration) error {
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// RunCleanupJob runs periodic cleanup of expired users and sessions
|
||||
// RunCleanupJob periodically removes expired sessions, unloads idle games, and
|
||||
// deletes anonymous games past their retention.
|
||||
func (s *Service) RunCleanupJob(ctx context.Context, interval time.Duration) {
|
||||
s.cleanupExpired()
|
||||
ticker := time.NewTicker(interval)
|
||||
@@ -176,49 +164,62 @@ func (s *Service) RunCleanupJob(ctx context.Context, interval time.Duration) {
|
||||
}
|
||||
|
||||
func (s *Service) cleanupExpired() {
|
||||
now := time.Now().UTC()
|
||||
if s.store != nil {
|
||||
if deleted, err := s.store.DeleteExpiredTempUsers(); err != nil {
|
||||
slog.Error("cleanup failed to delete expired users", "error", err)
|
||||
} else if deleted > 0 {
|
||||
slog.Info("cleanup deleted expired temporary users", "count", deleted)
|
||||
}
|
||||
|
||||
if deleted, err := s.store.DeleteExpiredSessions(); err != nil {
|
||||
slog.Error("cleanup failed to delete expired sessions", "error", err)
|
||||
} else if deleted > 0 {
|
||||
slog.Info("cleanup deleted expired sessions", "count", deleted)
|
||||
}
|
||||
}
|
||||
|
||||
s.cleanupFinishedGames(time.Now().UTC())
|
||||
s.cleanupGames(now)
|
||||
}
|
||||
|
||||
func (s *Service) cleanupFinishedGames(now time.Time) {
|
||||
// cleanupGames unloads terminal games after the finished-game TTL and
|
||||
// unclaimed games idle past the anonymous TTL, then queues deletion of
|
||||
// anonymous games idle past that TTL. The set of games still loaded is taken
|
||||
// under the same lock as the eviction and excluded from deletion, so no live
|
||||
// game can lose its row while writes for it are still possible.
|
||||
func (s *Service) cleanupGames(now time.Time) {
|
||||
s.mu.Lock()
|
||||
if s.finishedTTL <= 0 {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
cutoff := now.Add(-s.finishedTTL)
|
||||
removed := make([]string, 0)
|
||||
finishedCutoff := now.Add(-s.finishedTTL)
|
||||
anonymousTTL := s.anonymousTTL
|
||||
anonymousCutoff := now.Add(-anonymousTTL)
|
||||
var finished, idle []string
|
||||
live := make([]string, 0, len(s.games))
|
||||
for gameID, g := range s.games {
|
||||
ended := g.EndTimeUTC()
|
||||
if !g.State().IsTerminal() || ended == nil || ended.After(cutoff) {
|
||||
switch {
|
||||
case s.finishedTTL > 0 && g.State().IsTerminal() && ended != nil && ended.Before(finishedCutoff):
|
||||
finished = append(finished, gameID)
|
||||
case anonymousTTL > 0 && !g.IsClaimed() && g.State() != core.StatePending &&
|
||||
g.LastActivity().Before(anonymousCutoff):
|
||||
idle = append(idle, gameID)
|
||||
default:
|
||||
live = append(live, gameID)
|
||||
continue
|
||||
}
|
||||
if g.HasComputerPlayer() {
|
||||
s.computerGames.Add(-1)
|
||||
}
|
||||
delete(s.games, gameID)
|
||||
removed = append(removed, gameID)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
for _, gameID := range removed {
|
||||
for _, gameID := range append(finished, idle...) {
|
||||
s.waiter.RemoveGame(gameID)
|
||||
}
|
||||
if len(removed) > 0 {
|
||||
if len(finished) > 0 {
|
||||
slog.Info("cleanup evicted terminal games from memory",
|
||||
"count", len(removed), "retention", s.finishedTTL)
|
||||
"count", len(finished), "retention", s.finishedTTL)
|
||||
}
|
||||
if len(idle) > 0 {
|
||||
slog.Info("cleanup evicted idle anonymous games from memory",
|
||||
"count", len(idle), "retention", anonymousTTL)
|
||||
}
|
||||
if s.store != nil && anonymousTTL > 0 {
|
||||
if err := s.store.DeleteAnonymousGames(anonymousCutoff, live); err != nil {
|
||||
slog.Error("cleanup failed to queue anonymous game deletion", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
+120
-189
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"chess/internal/server/storage"
|
||||
@@ -18,122 +19,129 @@ var (
|
||||
ErrStorageDisabled = errors.New("storage disabled")
|
||||
ErrStorageUnavailable = errors.New("storage unavailable")
|
||||
ErrAtCapacity = errors.New("at capacity")
|
||||
ErrPermanentSlotsFull = errors.New("permanent slots full")
|
||||
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
|
||||
AccountType string
|
||||
CreatedAt time.Time
|
||||
ExpiresAt *time.Time
|
||||
UserID string
|
||||
Username string
|
||||
Email string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// CreateUser creates new user with registration limits enforcement
|
||||
func (s *Service) CreateUser(username, email, password string, permanent bool) (*User, error) {
|
||||
user, _, err := s.createUser(username, email, password, permanent, false)
|
||||
return user, err
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterUser creates the account and its initial session in one SQLite
|
||||
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.
|
||||
func (s *Service) RegisterUser(username, email, password string, permanent bool) (*User, string, error) {
|
||||
return s.createUser(username, email, password, permanent, true)
|
||||
}
|
||||
|
||||
func (s *Service) createUser(
|
||||
username, email, password string,
|
||||
permanent, withSession bool,
|
||||
) (*User, string, error) {
|
||||
s.userMu.Lock()
|
||||
defer s.userMu.Unlock()
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Determine account type
|
||||
accountType := "temp"
|
||||
var expiresAt *time.Time
|
||||
|
||||
if permanent {
|
||||
accountType = "permanent"
|
||||
} else {
|
||||
expiry := time.Now().UTC().Add(TempUserTTL)
|
||||
expiresAt = &expiry
|
||||
}
|
||||
|
||||
// Hash password
|
||||
passwordHash, err := auth.HashPassword(password)
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Generate unique user ID
|
||||
userID, err := s.generateUniqueUserID()
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to generate unique ID: %w", err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
|
||||
// Create user record
|
||||
// A random UUIDv4 needs no existence probe; the primary key rejects the
|
||||
// negligible collision case.
|
||||
userID := uuid.NewString()
|
||||
user := &User{
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
Email: email,
|
||||
AccountType: accountType,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
ExpiresAt: expiresAt,
|
||||
UserID: userID,
|
||||
Username: strings.ToLower(username),
|
||||
Email: strings.ToLower(email),
|
||||
CreatedAt: now,
|
||||
}
|
||||
|
||||
record := storage.UserRecord{
|
||||
UserID: userID,
|
||||
Username: strings.ToLower(username),
|
||||
Email: strings.ToLower(email),
|
||||
Username: user.Username,
|
||||
Email: user.Email,
|
||||
PasswordHash: passwordHash,
|
||||
AccountType: accountType,
|
||||
CreatedAt: user.CreatedAt,
|
||||
ExpiresAt: expiresAt,
|
||||
CreatedAt: now,
|
||||
}
|
||||
|
||||
var sessionID string
|
||||
var session *storage.SessionRecord
|
||||
if withSession {
|
||||
sessionID = uuid.New().String()
|
||||
session = &storage.SessionRecord{
|
||||
SessionID: sessionID,
|
||||
UserID: userID,
|
||||
CreatedAt: user.CreatedAt,
|
||||
ExpiresAt: user.CreatedAt.Add(SessionTTL),
|
||||
}
|
||||
}
|
||||
limits := storage.UserLimits{
|
||||
MaxUsers: MaxUsers,
|
||||
PermanentSlots: PermanentSlots,
|
||||
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, "", fmt.Errorf("username or email already exists: %w", err)
|
||||
case errors.Is(err, storage.ErrPermanentCapacity):
|
||||
return nil, "", fmt.Errorf("%w (%d maximum)", ErrPermanentSlotsFull, PermanentSlots)
|
||||
return nil, "", ErrUserExists
|
||||
case errors.Is(err, storage.ErrUserCapacity):
|
||||
return nil, "", fmt.Errorf("%w: no temporary account can be replaced", ErrAtCapacity)
|
||||
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,
|
||||
"account_type", accountType,
|
||||
"initial_session", withSession,
|
||||
)
|
||||
slog.Debug("user created", "user_id", userID)
|
||||
|
||||
return user, sessionID, nil
|
||||
}
|
||||
|
||||
// AuthenticateUser verifies credentials and creates a new session
|
||||
// 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
|
||||
@@ -141,75 +149,49 @@ func (s *Service) AuthenticateUser(identifier, password string) (*User, string,
|
||||
|
||||
var userRecord *storage.UserRecord
|
||||
var err error
|
||||
|
||||
// Check if identifier looks like email
|
||||
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)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
auth.HashPassword(password) // Timing attack prevention
|
||||
if !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, "", fmt.Errorf("invalid credentials")
|
||||
return nil, "", ErrInvalidCredentials
|
||||
}
|
||||
|
||||
// Verify password
|
||||
if err := auth.VerifyPassword(password, userRecord.PasswordHash); err != nil {
|
||||
return nil, "", fmt.Errorf("invalid credentials")
|
||||
}
|
||||
|
||||
// Check if temp user expired
|
||||
if userRecord.AccountType == "temp" && userRecord.ExpiresAt != nil {
|
||||
if time.Now().UTC().After(*userRecord.ExpiresAt) {
|
||||
return nil, "", fmt.Errorf("account expired")
|
||||
}
|
||||
}
|
||||
|
||||
// Create new session (invalidates any existing session)
|
||||
sessionID := uuid.New().String()
|
||||
sessionRecord := storage.SessionRecord{
|
||||
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: time.Now().UTC(),
|
||||
ExpiresAt: time.Now().UTC().Add(SessionTTL),
|
||||
}
|
||||
|
||||
if err := s.store.CreateSession(sessionRecord); err != nil {
|
||||
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)
|
||||
|
||||
// Update last login
|
||||
if err := s.store.UpdateUserLastLoginSync(userRecord.UserID, time.Now().UTC()); err != nil {
|
||||
slog.Warn("failed to record user login time", "user_id", userRecord.UserID, "error", err)
|
||||
}
|
||||
|
||||
return &User{
|
||||
UserID: userRecord.UserID,
|
||||
Username: userRecord.Username,
|
||||
Email: userRecord.Email,
|
||||
AccountType: userRecord.AccountType,
|
||||
CreatedAt: userRecord.CreatedAt,
|
||||
ExpiresAt: userRecord.ExpiresAt,
|
||||
UserID: userRecord.UserID,
|
||||
Username: userRecord.Username,
|
||||
Email: userRecord.Email,
|
||||
CreatedAt: userRecord.CreatedAt,
|
||||
}, sessionID, nil
|
||||
}
|
||||
|
||||
// ValidateSession checks if a session is valid
|
||||
func (s *Service) ValidateSession(sessionID string) (bool, error) {
|
||||
if s.store == nil {
|
||||
return false, ErrStorageDisabled
|
||||
}
|
||||
valid, err := s.store.IsSessionValid(sessionID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("%w: validate session: %v", ErrStorageUnavailable, err)
|
||||
}
|
||||
return valid, nil
|
||||
}
|
||||
|
||||
// InvalidateSession removes a session (logout)
|
||||
func (s *Service) InvalidateSession(sessionID string) error {
|
||||
if s.store == nil {
|
||||
@@ -236,34 +218,23 @@ func (s *Service) GetUserByID(userID string) (*User, error) {
|
||||
}
|
||||
|
||||
return &User{
|
||||
UserID: userRecord.UserID,
|
||||
Username: userRecord.Username,
|
||||
Email: userRecord.Email,
|
||||
AccountType: userRecord.AccountType,
|
||||
CreatedAt: userRecord.CreatedAt,
|
||||
ExpiresAt: userRecord.ExpiresAt,
|
||||
UserID: userRecord.UserID,
|
||||
Username: userRecord.Username,
|
||||
Email: userRecord.Email,
|
||||
CreatedAt: userRecord.CreatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GenerateUserToken creates a JWT token for the specified user with session ID
|
||||
// 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) {
|
||||
user, err := s.GetUserByID(userID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
claims := map[string]any{
|
||||
"username": user.Username,
|
||||
"email": user.Email,
|
||||
"session_id": sessionID,
|
||||
}
|
||||
|
||||
return auth.GenerateHS256Token(s.jwtSecret, userID, claims, SessionTTL)
|
||||
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 := auth.ValidateHS256Token(s.jwtSecret, token)
|
||||
userID, claims, err := s.jwt.ValidateToken(token)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
@@ -285,43 +256,3 @@ func (s *Service) ValidateToken(token string) (string, map[string]any, error) {
|
||||
|
||||
return userID, claims, nil
|
||||
}
|
||||
|
||||
// generateUniqueUserID creates a unique user ID with collision detection
|
||||
func (s *Service) generateUniqueUserID() (string, error) {
|
||||
const maxAttempts = 10
|
||||
|
||||
for i := 0; i < maxAttempts; i++ {
|
||||
id := uuid.New().String()
|
||||
if _, err := s.store.GetUserByID(id); errors.Is(err, sql.ErrNoRows) {
|
||||
return id, nil
|
||||
} else if err != nil {
|
||||
return "", fmt.Errorf("%w: check generated user ID: %v", ErrStorageUnavailable, err)
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("failed to generate unique user ID")
|
||||
}
|
||||
|
||||
// CreateUserSession creates a session for a trusted internal caller without
|
||||
// re-authenticating. Public registration uses RegisterUser so account and
|
||||
// initial session creation remain atomic.
|
||||
func (s *Service) CreateUserSession(userID string) (string, error) {
|
||||
if s.store == nil {
|
||||
return "", ErrStorageDisabled
|
||||
}
|
||||
|
||||
sessionID := uuid.New().String()
|
||||
sessionRecord := storage.SessionRecord{
|
||||
SessionID: sessionID,
|
||||
UserID: userID,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
ExpiresAt: time.Now().UTC().Add(SessionTTL),
|
||||
}
|
||||
|
||||
if err := s.store.CreateSession(sessionRecord); err != nil {
|
||||
return "", fmt.Errorf("%w: create session: %v", ErrStorageUnavailable, err)
|
||||
}
|
||||
slog.Debug("user session created", "user_id", userID)
|
||||
|
||||
return sessionID, nil
|
||||
}
|
||||
@@ -1,14 +1,21 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"chess/internal/server/storage"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/lixenwraith/auth"
|
||||
)
|
||||
|
||||
func TestValidateTokenRequiresSessionBoundToSubject(t *testing.T) {
|
||||
var testJWTSecret = []byte("test-secret-test-secret-test-secret")
|
||||
|
||||
func TestValidateTokenRequiresScopedTokenBoundToSession(t *testing.T) {
|
||||
svc := newPersistentTestService(t)
|
||||
user, sessionID, err := svc.RegisterUser("alice", "", "Password1", false)
|
||||
user, sessionID, err := svc.RegisterUser("alice", "", "Password1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -17,26 +24,47 @@ func TestValidateTokenRequiresSessionBoundToSubject(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotUserID, _, err := svc.ValidateToken(validToken); err != nil || gotUserID != user.UserID {
|
||||
t.Fatalf("valid token rejected: user=%q err=%v", gotUserID, err)
|
||||
gotUserID, claims, err := svc.ValidateToken(validToken)
|
||||
if err != nil || gotUserID != user.UserID || claims["session_id"] != sessionID {
|
||||
t.Fatalf("valid token rejected: user=%q claims=%v err=%v", gotUserID, claims, err)
|
||||
}
|
||||
|
||||
missingSession, err := auth.GenerateHS256Token(svc.jwtSecret, user.UserID, nil, SessionTTL)
|
||||
scoped, err := auth.NewJWT(testJWTSecret,
|
||||
auth.WithIssuer(JWTIssuer), auth.WithAudience([]string{JWTAudience}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := svc.ValidateToken(missingSession); err == nil {
|
||||
t.Fatal("token without a persisted session was accepted")
|
||||
}
|
||||
|
||||
wrongSubject, err := auth.GenerateHS256Token(svc.jwtSecret, "other-user", map[string]any{
|
||||
"session_id": sessionID,
|
||||
}, SessionTTL)
|
||||
unscoped, err := auth.NewJWT(testJWTSecret)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := svc.ValidateToken(wrongSubject); err == nil {
|
||||
t.Fatal("session was accepted for a different JWT subject")
|
||||
foreignAudience, err := auth.NewJWT(testJWTSecret,
|
||||
auth.WithIssuer(JWTIssuer), auth.WithAudience([]string{"other-service"}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, mint := range map[string]func() (string, error){
|
||||
"missing session": func() (string, error) { return scoped.GenerateToken(user.UserID, nil) },
|
||||
"wrong subject": func() (string, error) {
|
||||
return scoped.GenerateToken(uuid.NewString(), map[string]any{"session_id": sessionID})
|
||||
},
|
||||
"malformed subject": func() (string, error) {
|
||||
return scoped.GenerateToken("other-user", map[string]any{"session_id": sessionID})
|
||||
},
|
||||
"no issuer or audience": func() (string, error) {
|
||||
return unscoped.GenerateToken(user.UserID, map[string]any{"session_id": sessionID})
|
||||
},
|
||||
"foreign audience": func() (string, error) {
|
||||
return foreignAudience.GenerateToken(user.UserID, map[string]any{"session_id": sessionID})
|
||||
},
|
||||
} {
|
||||
token, err := mint()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := svc.ValidateToken(token); err == nil {
|
||||
t.Errorf("%s: token accepted", name)
|
||||
}
|
||||
}
|
||||
|
||||
if err := svc.InvalidateSession(sessionID); err != nil {
|
||||
@@ -46,3 +74,75 @@ func TestValidateTokenRequiresSessionBoundToSubject(t *testing.T) {
|
||||
t.Fatal("invalidated session was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticateUserFailuresAreIndistinguishable(t *testing.T) {
|
||||
svc := newPersistentTestService(t)
|
||||
if _, _, err := svc.RegisterUser("alice", "alice@example.com", "Password1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := svc.RegisterUser("ALICE", "", "Password1"); !errors.Is(err, ErrUserExists) {
|
||||
t.Fatalf("duplicate registration = %v, want ErrUserExists", err)
|
||||
}
|
||||
|
||||
user, sessionID, err := svc.AuthenticateUser("alice@example.com", "Password1")
|
||||
if err != nil || sessionID == "" || user.Username != "alice" {
|
||||
t.Fatalf("login by email = %+v, %q, %v", user, sessionID, err)
|
||||
}
|
||||
|
||||
for name, attempt := range map[string][2]string{
|
||||
"unknown user": {"nobody", "Password1"},
|
||||
"unknown email": {"nobody@example.com", "Password1"},
|
||||
"wrong password": {"alice", "Password2"},
|
||||
} {
|
||||
if _, _, err := svc.AuthenticateUser(attempt[0], attempt[1]); !errors.Is(err, ErrInvalidCredentials) {
|
||||
t.Errorf("%s: error = %v, want ErrInvalidCredentials", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistrationCapIsConfigurable(t *testing.T) {
|
||||
svc := newPersistentTestService(t)
|
||||
svc.SetMaxUsers(1)
|
||||
if _, _, err := svc.RegisterUser("alice", "", "Password1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := svc.RegisterUser("bob", "", "Password1"); !errors.Is(err, ErrAtCapacity) {
|
||||
t.Fatalf("registration past the cap = %v, want ErrAtCapacity", err)
|
||||
}
|
||||
svc.SetMaxUsers(0)
|
||||
if _, _, err := svc.RegisterUser("bob", "", "Password1"); err != nil {
|
||||
t.Fatalf("registration without a cap: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordHashingIsBounded(t *testing.T) {
|
||||
svc, err := New(nil, testJWTSecret)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var releases []func()
|
||||
for range MaxConcurrentKDF {
|
||||
release, err := svc.acquireKDF()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
releases = append(releases, release)
|
||||
}
|
||||
started := time.Now()
|
||||
if _, err := svc.hashPassword("Password1"); !errors.Is(err, ErrAuthBusy) {
|
||||
t.Fatalf("saturated KDF = %v, want ErrAuthBusy", err)
|
||||
}
|
||||
if waited := time.Since(started); waited < kdfWaitTimeout {
|
||||
t.Fatalf("gave up after %v, want %v", waited, kdfWaitTimeout)
|
||||
}
|
||||
releases[0]()
|
||||
if _, err := svc.hashPassword("Password1"); err != nil {
|
||||
t.Fatalf("hash after release: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRejectsShortJWTSecret(t *testing.T) {
|
||||
if _, err := New((*storage.Store)(nil), make([]byte, 31)); err == nil {
|
||||
t.Fatal("31-byte JWT secret accepted")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user