Self-serve password change, admin user management, login/signup rate limit

Per-user password change (Settings -> Change password)
- changePasswordAction verifies current password before rotating, blocks
  same-as-current, requires 8+ chars and matching confirm.
- Always revokes every other session for the user via
  deleteOtherSessions(userId, currentToken). If you're rotating because
  you suspect compromise, the worst-case kicks the attacker off
  immediately. UI surfaces how many sessions were revoked.
- ChangePasswordForm sits between SettingsForm and AdminInstanceSettings
  on the existing settings page. Available to every user, no admin
  privileges required.

Admin user management (/main/admin/users — admin only)
- New page lists every account: email, name, joined date, workout count,
  role. Linked from the AdminInstanceSettings panel ("Manage users ->").
- Per-row actions: Promote/Demote (toggles isAdmin), Reset password
  (inline 8+ char input), Delete (cascading delete via Prisma onDelete:
  Cascade — workouts, exercises, sessions, preferences all go).
- Last-admin guard: setUserAdmin and deleteUser refuse if it would
  leave 0 admins. Self-delete is blocked from the admin UI (preserves
  the actor's session and forces them to use a "danger zone" flow they
  set up explicitly elsewhere).
- adminResetPassword force-revokes ALL of the target user's sessions —
  admin reset implies the old credential is no longer trusted.
- Server actions all do their own requireAdmin() gate (defense in depth
  beyond the page-level redirect).

Rate limit on /auth/login + /auth/signup
- New lib/rateLimit.ts: tiny in-process sliding-window limiter, no deps.
  Map<key, timestamps[]> with cutoff filtering on each call. Per Node
  process — fine for the single-replica StartOS deploy shape.
- clientIpFromHeaders prefers x-forwarded-for (leftmost), falls back to
  x-real-ip, then 'unknown' (acts as a global cap in dev).
- signup: 5 attempts per IP per 15min. Cuts off automated account
  spraying without blocking legitimate household-member sign-ups.
- login: 10 attempts per IP per 15min. Slows credential stuffing while
  giving typo-prone users headroom.
This commit is contained in:
Keysat
2026-05-09 09:01:33 -05:00
parent 53d2bade5c
commit a11639cc56
10 changed files with 693 additions and 2 deletions
+14 -1
View File
@@ -1,10 +1,11 @@
'use server';
import { cookies } from 'next/headers';
import { cookies, headers } from 'next/headers';
import { hashPassword, createSession } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { getInstanceSettings } from '@/lib/instanceSettings';
import { ensureLibraryForUser } from '@/lib/library';
import { rateLimit, clientIpFromHeaders } from '@/lib/rateLimit';
const EMAIL_RE = /^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$/;
@@ -15,6 +16,18 @@ export async function signupAction(
name?: string,
) {
try {
// Per-IP cap: 5 sign-ups per 15 minutes. Generous enough for
// legitimate use (testing, household members coming online together)
// but cuts off automated account spraying.
const hdrs = await headers();
const ip = clientIpFromHeaders(hdrs);
const limit = rateLimit(`signup:${ip}`, { limit: 5, windowMs: 15 * 60_000 });
if (!limit.ok) {
return {
error: `Too many sign-up attempts. Try again in ${limit.retryAfterSec}s.`,
};
}
const settings = await getInstanceSettings();
if (!settings.signupsOpen) {
return { error: 'New sign-ups are not enabled on this instance.' };