Files
minstrel/internal/api/auth_register.go
T
bvandeusen a07fb3867a
test-web / test (push) Successful in 42s
test-go / test (push) Failing after 43s
test-go / integration (push) Failing after 4m22s
fix(net): thread hops into session creation; disambiguate card tests — #2453
Two CI failures from 381e9ced, both mine.

**Go (vet, which cascaded into the integration job).** Widening
auth.ClientIP to take a hop count, I updated the middleware that TOUCHES a
session but missed the two places that CREATE one — handleLogin and
handleRegister. So `created_ip`, the frozen origin address that the whole
"address changed" comparison rests on, was the one value still being
computed the old way. Both now read h.netSettings.Hops(), which is nil-safe
so test handlers constructed without the service still work.

Worth noting the shape of this miss: I checked call sites by searching for
the middleware's own usage and stopped there, rather than for every caller of
the function whose signature I changed. vet found it in seconds; a grep for
`auth.ClientIP(` would have too.

**Web (vitest).** Three tests waited on `findByText('198.51.100.7')`, which
matches TWO elements in the fixture — the detected client address and the
forwarded chain, identical strings for a single-proxy setup — and findByText
throws on multiple matches. Now they wait on the unique "Your address right
now" label and assert the address with getAllByText where duplication is
legitimate. The duplication is correct behaviour, so the test moved rather
than the component.
2026-08-05 10:14:38 -04:00

226 lines
7.5 KiB
Go

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/apierror"
"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, apierror.BadRequest("invalid_body", "invalid JSON body"))
return
}
if !usernameRe.MatchString(req.Username) {
writeErr(w, apierror.BadRequest("username_invalid", "username must be 3-32 chars, alphanumeric + underscore + hyphen"))
return
}
if len(req.Password) < minPasswordLength {
writeErr(w, apierror.BadRequest("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, apierror.Internal(err))
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, apierror.Internal(err))
return
}
if mode == "invite_only" {
if req.InviteToken == "" {
writeErr(w, apierror.BadRequest("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, apierror.BadRequest("invite_invalid", "invite token not found"))
return
}
h.logger.Error("register: get invite failed", "err", ierr)
writeErr(w, apierror.Internal(ierr))
return
}
if invite.RedeemedAt.Valid || invite.ExpiresAt.Time.Before(time.Now()) {
writeErr(w, apierror.BadRequest("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, apierror.Internal(err))
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, apierror.Internal(err))
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, apierror.Conflict("username_taken", "username is already taken"))
return
}
h.logger.Error("register: create user failed", "err", err)
writeErr(w, apierror.Internal(err))
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, apierror.Internal(err))
return
}
if _, err := q.InsertSession(r.Context(), dbq.InsertSessionParams{
UserID: user.ID,
TokenHash: auth.HashSessionToken(sessionToken),
UserAgent: r.UserAgent(),
Ip: auth.ClientIP(r, h.netSettings.Hops()),
}); err != nil {
h.logger.Error("register: insert session failed", "err", err)
writeErr(w, apierror.Internal(err))
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
}
audit.WriteOrLog(r.Context(), h.pool, h.logger, user.ID, user.ID, audit.ActionRegister, auditMeta)
if usedInviteToken != "" {
audit.WriteOrLog(r.Context(), h.pool, h.logger, user.ID, user.ID, audit.ActionInviteRedeem,
map[string]any{"token": usedInviteToken})
}
// Register the new user with the playlist scheduler so their daily
// builds are scheduled without waiting for the hourly reconciliation
// pass. Failure here is logged but doesn't fail registration — the
// next hourly pass will pick them up.
if h.playlistScheduler != nil {
if err := h.playlistScheduler.Refresh(r.Context(), user.ID); err != nil {
h.logger.Warn("api: scheduler refresh after registration",
"user_id", uuidToString(user.ID), "err", err)
}
}
writeJSON(w, http.StatusOK, LoginResponse{
Token: sessionToken,
User: UserView{
ID: user.ID,
Username: user.Username,
IsAdmin: user.IsAdmin,
},
})
}