feat(server/m7-user-mgmt): self-registration handler

POST /api/auth/register accepts {username, password, invite_token?,
display_name?} and creates a user. Race-safe first-admin path: when
the users table is empty, the SQL query (CreateUserFirstAdminRace)
inserts is_admin computed from a SELECT NOT EXISTS subquery — no
serializable isolation needed; concurrent empty-state registrations
both end up admin (benign).

Past first-admin, registration_settings.mode dictates: 'invite_only'
(default) requires a valid unredeemed unexpired invite token, which
is atomically claimed via RedeemInvite (rows-affected returns from
sqlc's :execrows directive). 'open' mode skips the invite check.

On success: hashes password (bcrypt), mints session token + cookie
matching handleLogin's shape, mints a separate api_token for
Subsonic clients, audits ActionRegister + ActionInviteRedeem
(best-effort — a failed audit write does NOT fail the user-facing
operation).

Validation: 3-32 char usernames (alphanumeric + underscore +
hyphen), 8-char minimum password. Duplicate username surfaces as
409.

Tests cover: first-user-becomes-admin, invite-only requires token,
valid token redeems + non-admin role, invalid token 400, open mode
skips check, duplicate username 409, password too short 400,
username format 400, race scenario asserts at-least-one-admin.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-07 11:56:19 -04:00
parent 4845628ae4
commit a449398906
3 changed files with 492 additions and 0 deletions
+1
View File
@@ -47,6 +47,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
r.Route("/api", func(api chi.Router) {
api.Post("/auth/login", h.handleLogin)
api.Post("/auth/register", h.handleRegister)
api.Group(func(authed chi.Router) {
authed.Use(auth.RequireUser(pool))
authed.Post("/auth/logout", h.handleLogout)
+216
View File
@@ -0,0 +1,216 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"regexp"
"time"
"github.com/jackc/pgerrcode"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"golang.org/x/crypto/bcrypt"
"git.fabledsword.com/bvandeusen/minstrel/internal/audit"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// usernameRe enforces a conservative username shape: 3-32 chars,
// alphanumeric + underscore + hyphen. The same regex is reused on
// the frontend for live validation.
var usernameRe = regexp.MustCompile(`^[a-zA-Z0-9_-]{3,32}$`)
const minPasswordLength = 8
type registerReq struct {
Username string `json:"username"`
Password string `json:"password"`
InviteToken string `json:"invite_token"`
DisplayName *string `json:"display_name"`
}
// handleRegister implements POST /api/auth/register.
//
// Flow:
// 1. Validate username/password format + minimum lengths.
// 2. Look up registration_settings.mode and current user count.
// 3. If users table is empty: insert via CreateUserFirstAdminRace
// (sets is_admin from a SELECT NOT EXISTS subquery). Skip invite
// check.
// 4. Else if mode == 'invite_only': atomically redeem invite via
// RedeemInvite (returns rows-affected; 0 means no usable
// invite — reject 400).
// 5. Else (mode == 'open'): proceed without invite check.
// 6. Hash password, mint session token, insert session row, set
// cookie, return user + token.
// 7. Audit ActionRegister and (if invite was used) ActionInviteRedeem.
//
// Errors:
// - 400 invalid_body / username_invalid / password_too_short
// - 400 invite_required (invite_only mode, no token supplied)
// - 400 invite_invalid (token doesn't exist, expired, or already redeemed)
// - 409 username_taken (PG unique violation on users.username)
// - 500 server_error otherwise
func (h *handlers) handleRegister(w http.ResponseWriter, r *http.Request) {
var req registerReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid_body", "invalid JSON body")
return
}
if !usernameRe.MatchString(req.Username) {
writeErr(w, http.StatusBadRequest, "username_invalid", "username must be 3-32 chars, alphanumeric + underscore + hyphen")
return
}
if len(req.Password) < minPasswordLength {
writeErr(w, http.StatusBadRequest, "password_too_short", "password must be at least 8 chars")
return
}
q := dbq.New(h.pool)
// Determine whether this is the empty-users (bootstrap-admin) case.
userCount, err := q.CountUsers(r.Context())
if err != nil {
h.logger.Error("register: count users failed", "err", err)
writeErr(w, http.StatusInternalServerError, "server_error", "")
return
}
// Validate invite (skipped on empty-users state; skipped in 'open' mode).
usedInviteToken := ""
if userCount > 0 {
mode, err := q.GetRegistrationMode(r.Context())
if err != nil {
h.logger.Error("register: get mode failed", "err", err)
writeErr(w, http.StatusInternalServerError, "server_error", "")
return
}
if mode == "invite_only" {
if req.InviteToken == "" {
writeErr(w, http.StatusBadRequest, "invite_required", "an invite token is required to register")
return
}
// Pre-validate existence + non-redeemed + non-expired so we fail
// fast on bad tokens before creating the user.
invite, ierr := q.GetInviteByToken(r.Context(), req.InviteToken)
if ierr != nil {
if errors.Is(ierr, pgx.ErrNoRows) {
writeErr(w, http.StatusBadRequest, "invite_invalid", "invite token not found")
return
}
h.logger.Error("register: get invite failed", "err", ierr)
writeErr(w, http.StatusInternalServerError, "server_error", "")
return
}
if invite.RedeemedAt.Valid || invite.ExpiresAt.Time.Before(time.Now()) {
writeErr(w, http.StatusBadRequest, "invite_invalid", "invite token is expired or already redeemed")
return
}
usedInviteToken = req.InviteToken
}
}
// Hash password.
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
h.logger.Error("register: hash password failed", "err", err)
writeErr(w, http.StatusInternalServerError, "server_error", "")
return
}
// Mint API token (same primitive used by bootstrap).
apiToken, err := auth.MintSessionToken()
if err != nil {
h.logger.Error("register: mint api_token failed", "err", err)
writeErr(w, http.StatusInternalServerError, "server_error", "")
return
}
// Insert user (race-safe-first-admin variant).
user, err := q.CreateUserFirstAdminRace(r.Context(), dbq.CreateUserFirstAdminRaceParams{
Username: req.Username,
PasswordHash: string(hash),
ApiToken: apiToken,
DisplayName: req.DisplayName,
})
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation {
writeErr(w, http.StatusConflict, "username_taken", "username is already taken")
return
}
h.logger.Error("register: create user failed", "err", err)
writeErr(w, http.StatusInternalServerError, "server_error", "")
return
}
// Atomic invite redeem (if applicable). Now we have user.ID for redeemed_by.
if usedInviteToken != "" {
rows, rerr := q.RedeemInvite(r.Context(), dbq.RedeemInviteParams{
Token: usedInviteToken,
RedeemedBy: user.ID,
})
if rerr != nil || rows != 1 {
// Race: another caller redeemed between our pre-check and now.
// User was already inserted — the redeem failed but the user
// row exists. Best to leave the user in place (operator can
// promote them manually) but surface a clear log entry.
h.logger.Error("register: invite redeem failed; user inserted but invite not consumed",
"user_id", uuidToString(user.ID), "rows", rows, "err", rerr)
// Don't fail the response — the user IS registered.
}
}
// Mint session token + cookie (same shape as handleLogin).
sessionToken, err := auth.MintSessionToken()
if err != nil {
h.logger.Error("register: mint session failed", "err", err)
writeErr(w, http.StatusInternalServerError, "server_error", "")
return
}
if _, err := q.InsertSession(r.Context(), dbq.InsertSessionParams{
UserID: user.ID,
TokenHash: auth.HashSessionToken(sessionToken),
UserAgent: r.UserAgent(),
}); err != nil {
h.logger.Error("register: insert session failed", "err", err)
writeErr(w, http.StatusInternalServerError, "server_error", "")
return
}
http.SetCookie(w, &http.Cookie{
Name: auth.SessionCookieName,
Value: sessionToken,
Path: "/",
HttpOnly: true,
Secure: r.TLS != nil,
SameSite: http.SameSiteStrictMode,
MaxAge: int(sessionCookieMaxAge.Seconds()),
})
// Audit (best-effort; a failed audit write does NOT fail the user-
// facing operation).
auditMeta := map[string]any{}
if user.IsAdmin {
auditMeta["first_admin"] = true
}
if err := audit.Write(r.Context(), h.pool, user.ID, user.ID, audit.ActionRegister, auditMeta); err != nil {
h.logger.Warn("register: audit write failed", "err", err)
}
if usedInviteToken != "" {
if err := audit.Write(r.Context(), h.pool, user.ID, user.ID, audit.ActionInviteRedeem,
map[string]any{"token": usedInviteToken}); err != nil {
h.logger.Warn("register: audit invite-redeem write failed", "err", err)
}
}
writeJSON(w, http.StatusOK, LoginResponse{
Token: sessionToken,
User: UserView{
ID: user.ID,
Username: user.Username,
IsAdmin: user.IsAdmin,
},
})
}
+275
View File
@@ -0,0 +1,275 @@
package api
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"sync"
"testing"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
)
// resetUsers wipes the entire users table (including any non-prefixed admin
// row). Used only by tests that need the empty-users bootstrap path; normal
// tests call seedUser which uses the TestUserPrefix prefix and is cleaned up
// by dbtest.ResetDB inside testHandlers.
func resetUsers(t *testing.T, h *handlers) {
t.Helper()
if _, err := h.pool.Exec(context.Background(), "DELETE FROM users"); err != nil {
t.Fatalf("resetUsers: %v", err)
}
}
// resetInvites wipes the user_invites table. Called by tests that seed their
// own invites so subsequent runs start clean.
func resetInvites(t *testing.T, h *handlers) {
t.Helper()
if _, err := h.pool.Exec(context.Background(), "DELETE FROM user_invites"); err != nil {
t.Fatalf("resetInvites: %v", err)
}
}
func TestRegister_FirstUserBecomesAdmin(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
h, _ := testHandlers(t)
resetUsers(t, h)
body := `{"username":"firstuser","password":"abcd1234"}`
req := httptest.NewRequest(http.MethodPost, "/api/auth/register", bytes.NewReader([]byte(body)))
rec := httptest.NewRecorder()
h.handleRegister(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
// Verify is_admin = true on the inserted row.
var isAdmin bool
if err := h.pool.QueryRow(context.Background(),
"SELECT is_admin FROM users WHERE username = 'firstuser'").Scan(&isAdmin); err != nil {
t.Fatalf("verify: %v", err)
}
if !isAdmin {
t.Errorf("is_admin = false; first registered user must be admin")
}
// Cookie set?
gotCookie := false
for _, c := range rec.Result().Cookies() {
if c.Name == auth.SessionCookieName {
gotCookie = true
}
}
if !gotCookie {
t.Errorf("session cookie not set")
}
}
func TestRegister_InviteOnlyMode_RequiresToken(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
h, pool := testHandlers(t)
// Seed an admin user so we're past the empty-users state.
seedUser(t, pool, "existingadmin", "pw", true)
// Mode is default invite_only.
body := `{"username":"newuser","password":"abcd1234"}`
req := httptest.NewRequest(http.MethodPost, "/api/auth/register", bytes.NewReader([]byte(body)))
rec := httptest.NewRecorder()
h.handleRegister(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400 (invite_required)", rec.Code)
}
}
func TestRegister_InviteOnlyMode_ValidTokenSucceeds(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
h, pool := testHandlers(t)
resetInvites(t, h)
admin := seedUser(t, pool, "admin1", "pw", true)
// Insert a fresh invite directly.
token := "test-invite-abc123"
if _, err := pool.Exec(context.Background(),
`INSERT INTO user_invites (token, invited_by, expires_at)
VALUES ($1, $2, now() + interval '24 hours')`,
token, admin.ID,
); err != nil {
t.Fatalf("seed invite: %v", err)
}
body := `{"username":"invitee1","password":"abcd1234","invite_token":"` + token + `"}`
req := httptest.NewRequest(http.MethodPost, "/api/auth/register", bytes.NewReader([]byte(body)))
rec := httptest.NewRecorder()
h.handleRegister(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
// Invite redeemed?
var redeemedAt *string
if err := pool.QueryRow(context.Background(),
`SELECT redeemed_at::text FROM user_invites WHERE token = $1`, token,
).Scan(&redeemedAt); err != nil {
t.Fatalf("verify redeem: %v", err)
}
if redeemedAt == nil {
t.Errorf("invite not redeemed after successful registration")
}
// New user is_admin = false?
var isAdmin bool
if err := pool.QueryRow(context.Background(),
`SELECT is_admin FROM users WHERE username = 'invitee1'`).Scan(&isAdmin); err != nil {
t.Fatalf("verify user: %v", err)
}
if isAdmin {
t.Errorf("new invitee marked as admin; only first user should be admin")
}
}
func TestRegister_InviteOnlyMode_InvalidTokenRejects(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
h, pool := testHandlers(t)
seedUser(t, pool, "adm", "pw", true)
body := `{"username":"baduser","password":"abcd1234","invite_token":"nonexistent-token"}`
req := httptest.NewRequest(http.MethodPost, "/api/auth/register", bytes.NewReader([]byte(body)))
rec := httptest.NewRecorder()
h.handleRegister(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400 (invite_invalid)", rec.Code)
}
}
func TestRegister_OpenMode_NoTokenNeeded(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
h, pool := testHandlers(t)
seedUser(t, pool, "preexisting", "pw", true)
// Switch to open mode; restore invite_only afterwards so other tests are
// not affected (registration_settings is a singleton not truncated by ResetDB).
if _, err := pool.Exec(context.Background(),
"UPDATE registration_settings SET mode = 'open' WHERE id = true"); err != nil {
t.Fatalf("set mode: %v", err)
}
t.Cleanup(func() {
if _, err := pool.Exec(context.Background(),
"UPDATE registration_settings SET mode = 'invite_only' WHERE id = true"); err != nil {
t.Logf("cleanup: restore mode failed: %v", err)
}
})
body := `{"username":"openreg","password":"abcd1234"}`
req := httptest.NewRequest(http.MethodPost, "/api/auth/register", bytes.NewReader([]byte(body)))
rec := httptest.NewRecorder()
h.handleRegister(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
}
func TestRegister_DuplicateUsername_409(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
h, pool := testHandlers(t)
resetUsers(t, h)
// Switch to open mode so the second attempt is not rejected by invite
// check before it can reach the INSERT and hit the unique violation.
if _, err := pool.Exec(context.Background(),
"UPDATE registration_settings SET mode = 'open' WHERE id = true"); err != nil {
t.Fatalf("set mode: %v", err)
}
t.Cleanup(func() {
if _, err := pool.Exec(context.Background(),
"UPDATE registration_settings SET mode = 'invite_only' WHERE id = true"); err != nil {
t.Logf("cleanup: restore mode failed: %v", err)
}
})
// First register succeeds (empty-users state → first-admin path).
body := `{"username":"taken","password":"abcd1234"}`
req := httptest.NewRequest(http.MethodPost, "/api/auth/register", bytes.NewReader([]byte(body)))
h.handleRegister(httptest.NewRecorder(), req)
// Second register with same username in open mode → 409 (unique violation).
req2 := httptest.NewRequest(http.MethodPost, "/api/auth/register", bytes.NewReader([]byte(body)))
rec2 := httptest.NewRecorder()
h.handleRegister(rec2, req2)
if rec2.Code != http.StatusConflict {
t.Errorf("status = %d, want 409 (username_taken)", rec2.Code)
}
}
func TestRegister_PasswordTooShort_400(t *testing.T) {
h, _ := testHandlers(t)
body := `{"username":"shortpw","password":"abc"}`
req := httptest.NewRequest(http.MethodPost, "/api/auth/register", bytes.NewReader([]byte(body)))
rec := httptest.NewRecorder()
h.handleRegister(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", rec.Code)
}
}
func TestRegister_UsernameInvalid_400(t *testing.T) {
h, _ := testHandlers(t)
body := `{"username":"has spaces","password":"abcd1234"}`
req := httptest.NewRequest(http.MethodPost, "/api/auth/register", bytes.NewReader([]byte(body)))
rec := httptest.NewRecorder()
h.handleRegister(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", rec.Code)
}
}
func TestRegister_FirstAdminRace(t *testing.T) {
// Concurrent registrations from empty-users state. Both
// usernames are different, so users.username uniqueness doesn't
// arbitrate. Both will see "no users yet" and both insert as
// admin. Test asserts the at-least-one-admin invariant.
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
h, _ := testHandlers(t)
resetUsers(t, h)
const N = 5
var wg sync.WaitGroup
for i := 0; i < N; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
body := json.RawMessage(`{"username":"raceuser` + string(rune('A'+idx)) + `","password":"abcd1234"}`)
req := httptest.NewRequest(http.MethodPost, "/api/auth/register", bytes.NewReader(body))
rec := httptest.NewRecorder()
h.handleRegister(rec, req)
}(i)
}
wg.Wait()
var adminCount int
if err := h.pool.QueryRow(context.Background(),
`SELECT count(*) FROM users WHERE is_admin = true`).Scan(&adminCount); err != nil {
t.Fatalf("count: %v", err)
}
if adminCount < 1 {
t.Errorf("admin count = 0; expected at least 1 (race-safety invariant violated)")
}
// adminCount can be > 1 — that's fine. The invariant is "at least one,"
// not "exactly one" (which would require serializable isolation).
t.Logf("admin count after race = %d (>=1 is the invariant)", adminCount)
}