diff --git a/internal/api/api.go b/internal/api/api.go index c1844f53..25d13675 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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) diff --git a/internal/api/auth_register.go b/internal/api/auth_register.go new file mode 100644 index 00000000..ca881e7c --- /dev/null +++ b/internal/api/auth_register.go @@ -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, + }, + }) +} diff --git a/internal/api/auth_register_test.go b/internal/api/auth_register_test.go new file mode 100644 index 00000000..698caf5e --- /dev/null +++ b/internal/api/auth_register_test.go @@ -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) +}