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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user