package api import ( "context" "encoding/json" "errors" "log/slog" "net/http" "regexp" "time" "github.com/jackc/pgerrcode" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgxpool" "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"` // SetupToken is required only for the very first account; see // auth.SetupToken. SetupToken string `json:"setup_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) { // Every attempt counts, not only failures: a successful registration is // the thing being sprayed when the mode is open. addr := auth.ClientIP(r, h.netSettings.Hops()) if blocked, wait := h.registerLimit.Blocked(addr); blocked { writeRateLimited(w, wait) return } h.registerLimit.Record(addr) 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 } // The first account becomes admin, so it must prove it can read the // server log. Checked before the invite logic, which the empty-users // state skips. if userCount == 0 && h.requireSetupToken && !h.setupToken.Matches(req.SetupToken) { // Repeat the token in the log at the moment someone needs it: the // boot line may have scrolled away, or users may have been deleted // since boot. h.logger.Warn("register: first-admin registration needs the setup token", "setup_token", h.setupToken.Value()) writeErr(w, apierror.Forbidden("setup_token_invalid", "the setup token from the server log is required to create the first account")) 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), ApiTokenHash: auth.HashAPIToken(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: addr, }); 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: auth.IsHTTPS(r, h.netSettings.Hops()), // plain HTTP and LAN stay functional (rule 94) 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, }, }) } // handleSetupStatus implements GET /api/auth/setup-status. It tells the // register screen whether to ask for the setup token, i.e. whether no // account exists yet. Public, since it is needed before anyone can sign in; // "this server has no users" is not worth hiding from someone who could // simply try to register. func (h *handlers) handleSetupStatus(w http.ResponseWriter, r *http.Request) { n, err := dbq.New(h.pool).CountUsers(r.Context()) if err != nil { writeErrWithLog(w, h.logger, "setup status: count users failed", apierror.Internal(err)) return } writeJSON(w, http.StatusOK, map[string]bool{"setup_required": n == 0}) } // logSetupTokenIfNeeded writes the setup token to the log at boot when the // instance has no accounts yet, with the instruction for using it. func logSetupTokenIfNeeded(pool *pgxpool.Pool, logger *slog.Logger, token *auth.SetupToken) { if pool == nil || logger == nil { return } n, err := dbq.New(pool).CountUsers(context.Background()) if err != nil || n > 0 { return } logger.Warn("no accounts yet: open the web app, choose Create account, and enter this setup token to become the admin", "setup_token", token.Value()) }