Files
minstrel/internal/api/auth_register.go
T
bvandeusenandClaude Opus 5.5 3bfddd0862
test-go / test (push) Successful in 1m54s
test-web / test (push) Successful in 1m34s
test-go / integration (push) Successful in 4m56s
android / Build + lint + test (push) Successful in 5m41s
release / Build signed APK (releases and dev) (push) Successful in 5m52s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
feat(auth): throttle login, register, password reset and Subsonic auth failures (M462 #4976)
Every password-shaped check was mounted bare, so guessing was limited only
by bcrypt cost. A shared in-memory AttemptLimiter now sits in front of them:

- login: 10 failures per account and 50 per address per 15 min, checked
  before the user lookup and bcrypt; 429 with Retry-After. A success clears
  the account's count but not the address's.
- unknown usernames run a dummy bcrypt compare, so timing no longer says
  which accounts exist.
- register: 10 per address per hour; forgot-password: 5 per address and 3
  per email per hour (applied whether or not the email matches); reset: 20
  failed tokens per address per 15 min.
- Subsonic /rest: same limits as login, counting only wrong credentials,
  since clients authenticate on every request.

Web login, register, reset and forgot-password screens say how long to
wait; web and Android carry copy for the rate_limited code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 08:28:29 -04:00

235 lines
7.8 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) {
// 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
}
// 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: 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: 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,
},
})
}