a11639cc56
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.
63 lines
2.0 KiB
TypeScript
63 lines
2.0 KiB
TypeScript
/**
|
|
* Tiny in-process sliding-window rate limiter.
|
|
*
|
|
* Per Node process — fine for a single Next.js standalone server, which is
|
|
* the only deployment shape we ship. If the app ever runs behind multiple
|
|
* replicas, swap the Map for a shared Redis-backed bucket; the public API
|
|
* here stays the same.
|
|
*
|
|
* No deps: a Map of `key -> sorted timestamps[]`. Each call drops
|
|
* timestamps older than the window, then either records the new request
|
|
* and returns ok, or returns blocked with retry-after seconds.
|
|
*
|
|
* Memory: O(active-keys * limit). At a 5-per-15min ceiling and even
|
|
* thousands of distinct IPs, this is trivially small.
|
|
*/
|
|
|
|
interface Window {
|
|
/** Max events allowed per `windowMs` per `key`. */
|
|
limit: number;
|
|
/** Window length in ms. */
|
|
windowMs: number;
|
|
}
|
|
|
|
const buckets = new Map<string, number[]>();
|
|
|
|
export function rateLimit(
|
|
key: string,
|
|
{ limit, windowMs }: Window,
|
|
): { ok: true } | { ok: false; retryAfterSec: number } {
|
|
const now = Date.now();
|
|
const cutoff = now - windowMs;
|
|
const hits = (buckets.get(key) ?? []).filter((t) => t > cutoff);
|
|
|
|
if (hits.length >= limit) {
|
|
const oldest = hits[0];
|
|
const retryAfterSec = Math.max(1, Math.ceil((oldest + windowMs - now) / 1000));
|
|
buckets.set(key, hits);
|
|
return { ok: false, retryAfterSec };
|
|
}
|
|
|
|
hits.push(now);
|
|
buckets.set(key, hits);
|
|
return { ok: true };
|
|
}
|
|
|
|
/**
|
|
* Best-effort client IP extraction. In a StartOS deployment the Next.js
|
|
* server sits behind a single proxy hop, so the leftmost
|
|
* `x-forwarded-for` entry is the originating client. If headers are
|
|
* absent (direct access in dev), fall back to the literal "unknown" key
|
|
* so the limiter still applies as a global rate cap.
|
|
*/
|
|
export function clientIpFromHeaders(headers: Headers): string {
|
|
const xff = headers.get('x-forwarded-for');
|
|
if (xff) {
|
|
const first = xff.split(',')[0]?.trim();
|
|
if (first) return first;
|
|
}
|
|
const real = headers.get('x-real-ip');
|
|
if (real) return real;
|
|
return 'unknown';
|
|
}
|