fix: prevent setup endpoint race condition (closes #26)

Add mutex and INSERT ON CONFLICT to CreateUser to prevent TOCTOU race
where concurrent requests could create multiple admin users.

Changes:
- Add sync.Mutex to auth.Service to serialize CreateUser calls
- Add models.CreateUserAtomic using INSERT ... ON CONFLICT(username) DO NOTHING
- Check RowsAffected to detect conflicts at the DB level (defense-in-depth)
- Add concurrent race condition test (10 goroutines, only 1 succeeds)

The existing UNIQUE constraint on users.username was already in place.
This fix adds the application-level protection (items 1 & 2 from #26).
This commit is contained in:
2026-02-15 21:35:16 -08:00
parent 97ee1e212f
commit 763e722607
3 changed files with 109 additions and 12 deletions

View File

@@ -10,6 +10,7 @@ import (
"log/slog"
"net/http"
"strings"
"sync"
"time"
"github.com/gorilla/sessions"
@@ -60,10 +61,11 @@ type ServiceParams struct {
// Service provides authentication functionality.
type Service struct {
log *slog.Logger
db *database.Database
store *sessions.CookieStore
params *ServiceParams
log *slog.Logger
db *database.Database
store *sessions.CookieStore
params *ServiceParams
setupMu sync.Mutex
}
// New creates a new auth Service.
@@ -163,11 +165,17 @@ func (svc *Service) IsSetupRequired(ctx context.Context) (bool, error) {
}
// CreateUser creates the initial admin user.
// It uses a mutex and INSERT ... ON CONFLICT to prevent race conditions
// where multiple concurrent requests could create duplicate admin users.
func (svc *Service) CreateUser(
ctx context.Context,
username, password string,
) (*models.User, error) {
// Check if user already exists
// Serialize setup attempts to prevent TOCTOU race conditions.
svc.setupMu.Lock()
defer svc.setupMu.Unlock()
// Check if any user already exists (setup already completed).
exists, err := models.UserExists(ctx, svc.db)
if err != nil {
return nil, fmt.Errorf("failed to check if user exists: %w", err)
@@ -183,14 +191,16 @@ func (svc *Service) CreateUser(
return nil, fmt.Errorf("failed to hash password: %w", err)
}
// Create user
user := models.NewUser(svc.db)
user.Username = username
user.PasswordHash = hash
err = user.Save(ctx)
// Use INSERT ... ON CONFLICT to handle any remaining race at the DB level.
// This is defense-in-depth: the mutex above prevents the Go-level race,
// and the UNIQUE constraint + ON CONFLICT prevents the DB-level race.
user, err := models.CreateUserAtomic(ctx, svc.db, username, hash)
if err != nil {
return nil, fmt.Errorf("failed to save user: %w", err)
return nil, fmt.Errorf("failed to create user: %w", err)
}
if user == nil {
return nil, ErrUserExists
}
svc.log.Info("user created", "username", username)