Fix auth throttle DoS and serialize admin bootstrap.
Evict/cap limiter keys, replace hard username lockouts with IP+user progressive delays cleared on success, and create bootstrap admins under the same advisory/mutex lock as role changes.
This commit is contained in:
@@ -54,7 +54,17 @@ func (m *Memory) CreateUser(_ context.Context, u *User) error {
|
||||
if u.CreatedAt == "" {
|
||||
u.CreatedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
role := u.Role
|
||||
if role == RoleAdmin {
|
||||
for _, existing := range m.users {
|
||||
if existing.Role == RoleAdmin {
|
||||
role = RoleUser
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
cp := *u
|
||||
cp.Role = role
|
||||
cp.db = nil
|
||||
m.users[cp.ID] = &cp
|
||||
m.byName[cp.Username] = cp.ID
|
||||
|
||||
@@ -9,14 +9,20 @@ import (
|
||||
func TestMemoryConcurrentLastAdminDemotion(t *testing.T) {
|
||||
m := NewMemory()
|
||||
ctx := context.Background()
|
||||
a := &User{Username: "admin_a", PasswordHash: "x", Role: RoleAdmin}
|
||||
b := &User{Username: "admin_b", PasswordHash: "x", Role: RoleAdmin}
|
||||
a := &User{Username: "admin_a", PasswordHash: "x", Role: RoleUser}
|
||||
b := &User{Username: "admin_b", PasswordHash: "x", Role: RoleUser}
|
||||
if err := m.CreateUser(ctx, a); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.CreateUser(ctx, b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.SetUserRole(ctx, a.ID, RoleAdmin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.SetUserRole(ctx, b.ID, RoleAdmin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, 2)
|
||||
@@ -54,3 +60,42 @@ func TestMemoryConcurrentLastAdminDemotion(t *testing.T) {
|
||||
t.Fatalf("admins remaining = %d, want 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryConcurrentBootstrapAdmin(t *testing.T) {
|
||||
m := NewMemory()
|
||||
ctx := context.Background()
|
||||
a := &User{Username: "boot_a", PasswordHash: "x", Role: RoleAdmin}
|
||||
b := &User{Username: "boot_b", PasswordHash: "x", Role: RoleAdmin}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, 2)
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
errs <- m.CreateUser(ctx, a)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
errs <- m.CreateUser(ctx, b)
|
||||
}()
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
n, err := m.CountAdmins(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("bootstrap race left %d admins, want 1", n)
|
||||
}
|
||||
if a.Role == RoleAdmin && b.Role == RoleAdmin {
|
||||
t.Fatal("both users kept RoleAdmin")
|
||||
}
|
||||
if a.Role != RoleAdmin && b.Role != RoleAdmin {
|
||||
t.Fatal("neither user is admin")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,12 @@ package store
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"plumber/internal/store/sqlc"
|
||||
)
|
||||
|
||||
// Postgres implements Store against a sqlc-backed database.
|
||||
@@ -16,8 +22,63 @@ func NewPostgres(db *sql.DB) *Postgres {
|
||||
}
|
||||
|
||||
func (p *Postgres) CreateUser(ctx context.Context, u *User) error {
|
||||
if u == nil {
|
||||
return fmt.Errorf("user: nil")
|
||||
}
|
||||
if u.Role != RoleUser && u.Role != RoleAdmin {
|
||||
return fmt.Errorf("invalid role")
|
||||
}
|
||||
u.Username = NormalizeUsername(u.Username)
|
||||
if u.ID == "" {
|
||||
u.ID = uuid.NewString()
|
||||
}
|
||||
if u.Name == "" {
|
||||
u.Name = u.Username
|
||||
}
|
||||
if u.CreatedAt == "" {
|
||||
u.CreatedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
if u.Role != RoleAdmin {
|
||||
u.db = p.db
|
||||
return u.Create(ctx)
|
||||
}
|
||||
|
||||
// Bootstrap admin: serialize count+insert so two setup-secret registers
|
||||
// cannot both observe zero admins.
|
||||
tx, err := p.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`, adminRoleLockKey); err != nil {
|
||||
return err
|
||||
}
|
||||
q := sqlc.New(tx)
|
||||
n, err := q.CountAdmins(ctx, string(RoleAdmin))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
role := RoleAdmin
|
||||
if n > 0 {
|
||||
role = RoleUser
|
||||
}
|
||||
if err := q.CreateUser(ctx, sqlc.CreateUserParams{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
Name: u.Name,
|
||||
PasswordHash: u.PasswordHash,
|
||||
Role: string(role),
|
||||
CreatedAt: u.CreatedAt,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
u.Role = role
|
||||
u.db = p.db
|
||||
return u.Create(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Postgres) UserByID(ctx context.Context, id string) (*User, error) {
|
||||
|
||||
Reference in New Issue
Block a user