test-web / test (push) Successful in 2m4s
test-go / test (push) Successful in 2m23s
test-go / integration (push) Successful in 5m28s
release / Build signed APK (releases and dev) (push) Successful in 6m29s
release / Build + push container image (push) Successful in 29s
release / Verify release artifacts (tag releases only) (push) Skipped
users.api_token held each user's apiKey in plaintext and was looked up by equality, so a leaked row or backup handed out working keys. Migration 0063 replaces it with api_token_hash (sha256, hex), computed in place from the existing keys so every Subsonic client keeps working. The key can no longer be read back: GET /api/me/api-token is gone, and POST returns the new key once. Settings shows it right after Regenerate with a copy button and a "won't be shown again" note. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
282 lines
9.9 KiB
Go
282 lines
9.9 KiB
Go
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())
|
|
}
|