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