feat: implement forgot password, added security updates
This commit is contained in:
11
.env
11
.env
@@ -30,11 +30,16 @@ EMAIL_FROM=SkyMoney Budget <no-reply@skymoneybudget.com>
|
|||||||
EMAIL_BOUNCE_FROM=bounces@skymoneybudget.com
|
EMAIL_BOUNCE_FROM=bounces@skymoneybudget.com
|
||||||
EMAIL_REPLY_TO=support@skymoneybudget.com
|
EMAIL_REPLY_TO=support@skymoneybudget.com
|
||||||
|
|
||||||
UPDATE_NOTICE_VERSION=2
|
UPDATE_NOTICE_VERSION=3
|
||||||
UPDATE_NOTICE_TITLE=SkyMoney Security Update
|
UPDATE_NOTICE_TITLE=SkyMoney Update
|
||||||
UPDATE_NOTICE_BODY=We strengthened OWASP security controls, auth protections, and deployment security checks.
|
UPDATE_NOTICE_BODY=We shipped account security improvements, including a new password reset flow and stronger session protections.
|
||||||
|
|
||||||
ALLOW_INSECURE_AUTH_FOR_DEV=false
|
ALLOW_INSECURE_AUTH_FOR_DEV=false
|
||||||
JWT_ISSUER=skymoney-api
|
JWT_ISSUER=skymoney-api
|
||||||
JWT_AUDIENCE=skymoney-web
|
JWT_AUDIENCE=skymoney-web
|
||||||
AUTH_MAX_FAILED_ATTEMPTS=5
|
AUTH_MAX_FAILED_ATTEMPTS=5
|
||||||
AUTH_LOCKOUT_WINDOW_MS=900000
|
AUTH_LOCKOUT_WINDOW_MS=900000
|
||||||
|
|
||||||
|
PASSWORD_RESET_TTL_MINUTES=30
|
||||||
|
PASSWORD_RESET_RATE_LIMIT_PER_MINUTE=5
|
||||||
|
PASSWORD_RESET_CONFIRM_RATE_LIMIT_PER_MINUTE=10
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ COOKIE_SECRET=replace-with-32+-chars
|
|||||||
COOKIE_DOMAIN=skymoneybudget.com
|
COOKIE_DOMAIN=skymoneybudget.com
|
||||||
AUTH_MAX_FAILED_ATTEMPTS=5
|
AUTH_MAX_FAILED_ATTEMPTS=5
|
||||||
AUTH_LOCKOUT_WINDOW_MS=900000
|
AUTH_LOCKOUT_WINDOW_MS=900000
|
||||||
|
PASSWORD_RESET_TTL_MINUTES=30
|
||||||
|
PASSWORD_RESET_RATE_LIMIT_PER_MINUTE=5
|
||||||
|
PASSWORD_RESET_CONFIRM_RATE_LIMIT_PER_MINUTE=10
|
||||||
|
UPDATE_NOTICE_VERSION=3
|
||||||
|
UPDATE_NOTICE_TITLE=SkyMoney Update
|
||||||
|
UPDATE_NOTICE_BODY=We shipped account security improvements, including a new password reset flow and stronger session protections.
|
||||||
|
|
||||||
# Email (verification + delete confirmation)
|
# Email (verification + delete confirmation)
|
||||||
SMTP_HOST=smtp.example.com
|
SMTP_HOST=smtp.example.com
|
||||||
|
|||||||
46
SECURITY_FORGOT_PASSWORD.md
Normal file
46
SECURITY_FORGOT_PASSWORD.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# Security Forgot Password Controls
|
||||||
|
|
||||||
|
## Implemented Controls
|
||||||
|
|
||||||
|
- Public entry points:
|
||||||
|
- `POST /auth/forgot-password/request`
|
||||||
|
- `POST /auth/forgot-password/confirm`
|
||||||
|
- Enumeration resistance:
|
||||||
|
- Request endpoint always returns `200` with a generic success message.
|
||||||
|
- No account existence signal for unknown/unverified emails.
|
||||||
|
- Verified-account gate:
|
||||||
|
- Reset tokens are issued only when `emailVerified=true`.
|
||||||
|
- Token security:
|
||||||
|
- Reset links contain `uid` and raw `token` in query params.
|
||||||
|
- Server stores only `SHA-256(token)`.
|
||||||
|
- Token type is `password_reset`.
|
||||||
|
- Token must match `uid`, be unused, and be unexpired.
|
||||||
|
- Token is consumed once (`usedAt`) and cannot be reused.
|
||||||
|
- Session invalidation:
|
||||||
|
- Added `User.passwordChangedAt`.
|
||||||
|
- JWT auth middleware rejects tokens with `iat <= passwordChangedAt`.
|
||||||
|
- Reset and authenticated password-change both set `passwordChangedAt`.
|
||||||
|
- Abuse controls:
|
||||||
|
- Request endpoint: per-IP + email-fingerprint route keying.
|
||||||
|
- Confirm endpoint: per-IP + uid-fingerprint route keying.
|
||||||
|
- Endpoint-specific rate limits via env config.
|
||||||
|
- Logging hygiene:
|
||||||
|
- Structured security events for request/email/confirm outcomes.
|
||||||
|
- No plaintext password or raw token in logs.
|
||||||
|
- Misconfiguration resilience:
|
||||||
|
- Email send failures do not leak through API response shape.
|
||||||
|
- Generic response is preserved if SMTP is unavailable.
|
||||||
|
|
||||||
|
## Environment Settings
|
||||||
|
|
||||||
|
- `PASSWORD_RESET_TTL_MINUTES` (default `30`)
|
||||||
|
- `PASSWORD_RESET_RATE_LIMIT_PER_MINUTE` (default `5`)
|
||||||
|
- `PASSWORD_RESET_CONFIRM_RATE_LIMIT_PER_MINUTE` (default `10`)
|
||||||
|
|
||||||
|
## Operational Verification
|
||||||
|
|
||||||
|
1. Verify `/auth/forgot-password/request` always returns the same JSON for unknown, unverified, and verified addresses.
|
||||||
|
2. Verify only verified users get `EmailToken(type=password_reset)` rows.
|
||||||
|
3. Verify `confirm` succeeds once and fails on replay.
|
||||||
|
4. Verify pre-reset session cookies fail on protected routes after successful reset.
|
||||||
|
5. Verify security logs contain `auth.password_reset.*` events and no raw token values.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "User"
|
||||||
|
ADD COLUMN IF NOT EXISTS "passwordChangedAt" TIMESTAMP(3);
|
||||||
@@ -24,6 +24,7 @@ model User {
|
|||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
email String @unique
|
email String @unique
|
||||||
passwordHash String?
|
passwordHash String?
|
||||||
|
passwordChangedAt DateTime?
|
||||||
displayName String?
|
displayName String?
|
||||||
emailVerified Boolean @default(false)
|
emailVerified Boolean @default(false)
|
||||||
seenUpdateVersion Int @default(0)
|
seenUpdateVersion Int @default(0)
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ const Env = z.object({
|
|||||||
SESSION_TIMEOUT_MINUTES: z.coerce.number().int().positive().default(30),
|
SESSION_TIMEOUT_MINUTES: z.coerce.number().int().positive().default(30),
|
||||||
AUTH_MAX_FAILED_ATTEMPTS: z.coerce.number().int().positive().default(5),
|
AUTH_MAX_FAILED_ATTEMPTS: z.coerce.number().int().positive().default(5),
|
||||||
AUTH_LOCKOUT_WINDOW_MS: z.coerce.number().int().positive().default(15 * 60_000),
|
AUTH_LOCKOUT_WINDOW_MS: z.coerce.number().int().positive().default(15 * 60_000),
|
||||||
|
PASSWORD_RESET_TTL_MINUTES: z.coerce.number().int().positive().default(30),
|
||||||
|
PASSWORD_RESET_RATE_LIMIT_PER_MINUTE: z.coerce.number().int().positive().default(5),
|
||||||
|
PASSWORD_RESET_CONFIRM_RATE_LIMIT_PER_MINUTE: z.coerce.number().int().positive().default(10),
|
||||||
APP_ORIGIN: z.string().min(1).default("http://localhost:5173"),
|
APP_ORIGIN: z.string().min(1).default("http://localhost:5173"),
|
||||||
UPDATE_NOTICE_VERSION: z.coerce.number().int().nonnegative().default(0),
|
UPDATE_NOTICE_VERSION: z.coerce.number().int().nonnegative().default(0),
|
||||||
UPDATE_NOTICE_TITLE: z.string().min(1).default("SkyMoney Updated"),
|
UPDATE_NOTICE_TITLE: z.string().min(1).default("SkyMoney Updated"),
|
||||||
@@ -93,6 +96,9 @@ const rawEnv = {
|
|||||||
SESSION_TIMEOUT_MINUTES: process.env.SESSION_TIMEOUT_MINUTES,
|
SESSION_TIMEOUT_MINUTES: process.env.SESSION_TIMEOUT_MINUTES,
|
||||||
AUTH_MAX_FAILED_ATTEMPTS: process.env.AUTH_MAX_FAILED_ATTEMPTS,
|
AUTH_MAX_FAILED_ATTEMPTS: process.env.AUTH_MAX_FAILED_ATTEMPTS,
|
||||||
AUTH_LOCKOUT_WINDOW_MS: process.env.AUTH_LOCKOUT_WINDOW_MS,
|
AUTH_LOCKOUT_WINDOW_MS: process.env.AUTH_LOCKOUT_WINDOW_MS,
|
||||||
|
PASSWORD_RESET_TTL_MINUTES: process.env.PASSWORD_RESET_TTL_MINUTES,
|
||||||
|
PASSWORD_RESET_RATE_LIMIT_PER_MINUTE: process.env.PASSWORD_RESET_RATE_LIMIT_PER_MINUTE,
|
||||||
|
PASSWORD_RESET_CONFIRM_RATE_LIMIT_PER_MINUTE: process.env.PASSWORD_RESET_CONFIRM_RATE_LIMIT_PER_MINUTE,
|
||||||
APP_ORIGIN: process.env.APP_ORIGIN,
|
APP_ORIGIN: process.env.APP_ORIGIN,
|
||||||
UPDATE_NOTICE_VERSION: process.env.UPDATE_NOTICE_VERSION,
|
UPDATE_NOTICE_VERSION: process.env.UPDATE_NOTICE_VERSION,
|
||||||
UPDATE_NOTICE_TITLE: process.env.UPDATE_NOTICE_TITLE,
|
UPDATE_NOTICE_TITLE: process.env.UPDATE_NOTICE_TITLE,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import rateLimit from "@fastify/rate-limit";
|
|||||||
import fastifyCookie from "@fastify/cookie";
|
import fastifyCookie from "@fastify/cookie";
|
||||||
import fastifyJwt from "@fastify/jwt";
|
import fastifyJwt from "@fastify/jwt";
|
||||||
import argon2 from "argon2";
|
import argon2 from "argon2";
|
||||||
import { randomUUID, createHash, randomInt } from "node:crypto";
|
import { randomUUID, createHash, randomInt, randomBytes } from "node:crypto";
|
||||||
import nodemailer from "nodemailer";
|
import nodemailer from "nodemailer";
|
||||||
import { env } from "./env.js";
|
import { env } from "./env.js";
|
||||||
import { PrismaClient, Prisma } from "@prisma/client";
|
import { PrismaClient, Prisma } from "@prisma/client";
|
||||||
@@ -21,6 +21,8 @@ const openPaths = new Set([
|
|||||||
"/auth/register",
|
"/auth/register",
|
||||||
"/auth/verify",
|
"/auth/verify",
|
||||||
"/auth/verify/resend",
|
"/auth/verify/resend",
|
||||||
|
"/auth/forgot-password/request",
|
||||||
|
"/auth/forgot-password/confirm",
|
||||||
]);
|
]);
|
||||||
const mutationRateLimit = {
|
const mutationRateLimit = {
|
||||||
config: {
|
config: {
|
||||||
@@ -54,6 +56,37 @@ const codeIssueRateLimit = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
const passwordResetRequestRateLimit = (max: number) => ({
|
||||||
|
config: {
|
||||||
|
rateLimit: {
|
||||||
|
max,
|
||||||
|
timeWindow: 60_000,
|
||||||
|
keyGenerator: (req: any) => {
|
||||||
|
const rawEmail =
|
||||||
|
req?.body && typeof req.body === "object" ? (req.body as Record<string, unknown>)?.email : "";
|
||||||
|
const email =
|
||||||
|
typeof rawEmail === "string" && rawEmail.trim().length > 0
|
||||||
|
? rawEmail.trim().toLowerCase()
|
||||||
|
: "unknown";
|
||||||
|
return `${req.ip}|${createHash("sha256").update(email).digest("hex").slice(0, 16)}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const passwordResetConfirmRateLimit = (max: number) => ({
|
||||||
|
config: {
|
||||||
|
rateLimit: {
|
||||||
|
max,
|
||||||
|
timeWindow: 60_000,
|
||||||
|
keyGenerator: (req: any) => {
|
||||||
|
const rawUid =
|
||||||
|
req?.body && typeof req.body === "object" ? (req.body as Record<string, unknown>)?.uid : "";
|
||||||
|
const uid = typeof rawUid === "string" && rawUid.trim().length > 0 ? rawUid.trim() : "unknown";
|
||||||
|
return `${req.ip}|${createHash("sha256").update(uid).digest("hex").slice(0, 16)}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
const pathOf = (url: string) => (url.split("?")[0] || "/");
|
const pathOf = (url: string) => (url.split("?")[0] || "/");
|
||||||
const normalizeClientIp = (ip: string) => ip.replace("::ffff:", "").toLowerCase();
|
const normalizeClientIp = (ip: string) => ip.replace("::ffff:", "").toLowerCase();
|
||||||
const isInternalClientIp = (ip: string) => {
|
const isInternalClientIp = (ip: string) => {
|
||||||
@@ -145,6 +178,7 @@ const ensureCsrfCookie = (reply: any, existing?: string) => {
|
|||||||
const EMAIL_TOKEN_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
const EMAIL_TOKEN_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
||||||
const DELETE_TOKEN_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
const DELETE_TOKEN_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
||||||
const EMAIL_TOKEN_COOLDOWN_MS = 60 * 1000; // 60 seconds
|
const EMAIL_TOKEN_COOLDOWN_MS = 60 * 1000; // 60 seconds
|
||||||
|
const PASSWORD_RESET_MIN_TOKEN_BYTES = 32;
|
||||||
const PASSWORD_MIN_LENGTH = 12;
|
const PASSWORD_MIN_LENGTH = 12;
|
||||||
const passwordSchema = z
|
const passwordSchema = z
|
||||||
.string()
|
.string()
|
||||||
@@ -277,10 +311,11 @@ function hashToken(token: string) {
|
|||||||
|
|
||||||
async function issueEmailToken(
|
async function issueEmailToken(
|
||||||
userId: string,
|
userId: string,
|
||||||
type: "signup" | "delete",
|
type: EmailTokenType,
|
||||||
ttlMs: number
|
ttlMs: number,
|
||||||
|
token?: string
|
||||||
) {
|
) {
|
||||||
const code = generateCode(6);
|
const code = token ?? generateCode(6);
|
||||||
const tokenHash = hashToken(code);
|
const tokenHash = hashToken(code);
|
||||||
const expiresAt = new Date(Date.now() + ttlMs);
|
const expiresAt = new Date(Date.now() + ttlMs);
|
||||||
await app.prisma.emailToken.create({
|
await app.prisma.emailToken.create({
|
||||||
@@ -294,9 +329,15 @@ async function issueEmailToken(
|
|||||||
return { code, expiresAt };
|
return { code, expiresAt };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function generatePasswordResetToken() {
|
||||||
|
return randomBytes(PASSWORD_RESET_MIN_TOKEN_BYTES).toString("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
type EmailTokenType = "signup" | "delete" | "password_reset";
|
||||||
|
|
||||||
async function assertEmailTokenCooldown(
|
async function assertEmailTokenCooldown(
|
||||||
userId: string,
|
userId: string,
|
||||||
type: "signup" | "delete",
|
type: EmailTokenType,
|
||||||
cooldownMs: number
|
cooldownMs: number
|
||||||
) {
|
) {
|
||||||
if (cooldownMs <= 0) return;
|
if (cooldownMs <= 0) return;
|
||||||
@@ -319,7 +360,7 @@ async function assertEmailTokenCooldown(
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function clearEmailTokens(userId: string, type?: "signup" | "delete") {
|
async function clearEmailTokens(userId: string, type?: EmailTokenType) {
|
||||||
await app.prisma.emailToken.deleteMany({
|
await app.prisma.emailToken.deleteMany({
|
||||||
where: type ? { userId, type } : { userId },
|
where: type ? { userId, type } : { userId },
|
||||||
});
|
});
|
||||||
@@ -730,9 +771,49 @@ app.decorate("ensureUser", async (userId: string) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const { sub } = await req.jwtVerify<{ sub: string }>();
|
const { sub, iat } = await req.jwtVerify<{ sub: string; iat?: number }>();
|
||||||
|
const authUser = await app.prisma.user.findUnique({
|
||||||
|
where: { id: sub },
|
||||||
|
select: { id: true, passwordChangedAt: true },
|
||||||
|
});
|
||||||
|
if (!authUser) {
|
||||||
|
logSecurityEvent(req, "auth.unauthenticated_request", "failure", {
|
||||||
|
path,
|
||||||
|
method: req.method,
|
||||||
|
reason: "unknown_user",
|
||||||
|
});
|
||||||
|
return reply
|
||||||
|
.code(401)
|
||||||
|
.send({
|
||||||
|
ok: false,
|
||||||
|
code: "UNAUTHENTICATED",
|
||||||
|
message: "Login required",
|
||||||
|
requestId: String(req.id ?? ""),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const issuedAtMs =
|
||||||
|
typeof iat === "number" && Number.isFinite(iat) ? Math.floor(iat * 1000) : null;
|
||||||
|
const passwordChangedAtMs = authUser.passwordChangedAt?.getTime() ?? null;
|
||||||
|
if (passwordChangedAtMs !== null && (issuedAtMs === null || issuedAtMs <= passwordChangedAtMs)) {
|
||||||
|
logSecurityEvent(req, "auth.unauthenticated_request", "failure", {
|
||||||
|
path,
|
||||||
|
method: req.method,
|
||||||
|
reason: "stale_session",
|
||||||
|
userId: sub,
|
||||||
|
});
|
||||||
|
return reply
|
||||||
|
.code(401)
|
||||||
|
.send({
|
||||||
|
ok: false,
|
||||||
|
code: "UNAUTHENTICATED",
|
||||||
|
message: "Login required",
|
||||||
|
requestId: String(req.id ?? ""),
|
||||||
|
});
|
||||||
|
}
|
||||||
req.userId = sub;
|
req.userId = sub;
|
||||||
await app.ensureUser(req.userId);
|
if (config.SEED_DEFAULT_BUDGET) {
|
||||||
|
await seedDefaultBudget(app.prisma, req.userId);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
logSecurityEvent(req, "auth.unauthenticated_request", "failure", {
|
logSecurityEvent(req, "auth.unauthenticated_request", "failure", {
|
||||||
path,
|
path,
|
||||||
@@ -755,7 +836,14 @@ app.decorate("ensureUser", async (userId: string) => {
|
|||||||
if (method === "GET" || method === "HEAD" || method === "OPTIONS") {
|
if (method === "GET" || method === "HEAD" || method === "OPTIONS") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (path === "/auth/login" || path === "/auth/register" || path === "/auth/verify" || path === "/auth/verify/resend") {
|
if (
|
||||||
|
path === "/auth/login" ||
|
||||||
|
path === "/auth/register" ||
|
||||||
|
path === "/auth/verify" ||
|
||||||
|
path === "/auth/verify/resend" ||
|
||||||
|
path === "/auth/forgot-password/request" ||
|
||||||
|
path === "/auth/forgot-password/confirm"
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const cookieToken = (req.cookies as any)?.[CSRF_COOKIE];
|
const cookieToken = (req.cookies as any)?.[CSRF_COOKIE];
|
||||||
@@ -780,6 +868,14 @@ const VerifyBody = z.object({
|
|||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
code: z.string().min(4),
|
code: z.string().min(4),
|
||||||
});
|
});
|
||||||
|
const ForgotPasswordRequestBody = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
});
|
||||||
|
const ForgotPasswordConfirmBody = z.object({
|
||||||
|
uid: z.string().uuid(),
|
||||||
|
token: z.string().min(16).max(512),
|
||||||
|
newPassword: passwordSchema,
|
||||||
|
});
|
||||||
|
|
||||||
const AllocationOverrideSchema = z.object({
|
const AllocationOverrideSchema = z.object({
|
||||||
type: z.enum(["fixed", "variable"]),
|
type: z.enum(["fixed", "variable"]),
|
||||||
@@ -1058,6 +1154,153 @@ app.post("/auth/verify/resend", codeIssueRateLimit, async (req, reply) => {
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
"/auth/forgot-password/request",
|
||||||
|
passwordResetRequestRateLimit(config.PASSWORD_RESET_RATE_LIMIT_PER_MINUTE),
|
||||||
|
async (req, reply) => {
|
||||||
|
const parsed = ForgotPasswordRequestBody.safeParse(req.body);
|
||||||
|
const genericResponse = {
|
||||||
|
ok: true,
|
||||||
|
message: "If an account exists, reset instructions were sent.",
|
||||||
|
};
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.code(200).send(genericResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedEmail = normalizeEmail(parsed.data.email);
|
||||||
|
const user = await app.prisma.user.findUnique({
|
||||||
|
where: { email: normalizedEmail },
|
||||||
|
select: { id: true, email: true, emailVerified: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
logSecurityEvent(req, "auth.password_reset.request", "success", {
|
||||||
|
emailFingerprint: fingerprintEmail(normalizedEmail),
|
||||||
|
hasAccount: !!user,
|
||||||
|
emailVerified: !!user?.emailVerified,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || !user.emailVerified) {
|
||||||
|
return reply.code(200).send(genericResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await assertEmailTokenCooldown(user.id, "password_reset", EMAIL_TOKEN_COOLDOWN_MS);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.code === "EMAIL_TOKEN_COOLDOWN") {
|
||||||
|
logSecurityEvent(req, "auth.password_reset.request", "blocked", {
|
||||||
|
reason: "email_token_cooldown",
|
||||||
|
userId: user.id,
|
||||||
|
emailFingerprint: fingerprintEmail(normalizedEmail),
|
||||||
|
});
|
||||||
|
return reply.code(200).send(genericResponse);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawToken = generatePasswordResetToken();
|
||||||
|
await clearEmailTokens(user.id, "password_reset");
|
||||||
|
await issueEmailToken(
|
||||||
|
user.id,
|
||||||
|
"password_reset",
|
||||||
|
config.PASSWORD_RESET_TTL_MINUTES * 60_000,
|
||||||
|
rawToken
|
||||||
|
);
|
||||||
|
|
||||||
|
const origin = normalizeOrigin(config.APP_ORIGIN);
|
||||||
|
const resetUrl = `${origin}/reset-password?uid=${encodeURIComponent(user.id)}&token=${encodeURIComponent(rawToken)}`;
|
||||||
|
try {
|
||||||
|
await sendEmail({
|
||||||
|
to: user.email,
|
||||||
|
subject: "Reset your SkyMoney password",
|
||||||
|
text:
|
||||||
|
`Use this link to reset your SkyMoney password: ${resetUrl}\n\n` +
|
||||||
|
"This link expires soon. If you did not request this, you can ignore this email.",
|
||||||
|
html:
|
||||||
|
`<p>Use this link to reset your SkyMoney password:</p><p><a href="${resetUrl}">${resetUrl}</a></p>` +
|
||||||
|
"<p>This link expires soon. If you did not request this, you can ignore this email.</p>",
|
||||||
|
});
|
||||||
|
logSecurityEvent(req, "auth.password_reset.email", "success", {
|
||||||
|
userId: user.id,
|
||||||
|
emailFingerprint: fingerprintEmail(normalizedEmail),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
logSecurityEvent(req, "auth.password_reset.email", "failure", {
|
||||||
|
userId: user.id,
|
||||||
|
emailFingerprint: fingerprintEmail(normalizedEmail),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.code(200).send(genericResponse);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
"/auth/forgot-password/confirm",
|
||||||
|
passwordResetConfirmRateLimit(config.PASSWORD_RESET_CONFIRM_RATE_LIMIT_PER_MINUTE),
|
||||||
|
async (req, reply) => {
|
||||||
|
const parsed = ForgotPasswordConfirmBody.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply
|
||||||
|
.code(400)
|
||||||
|
.send({ ok: false, code: "INVALID_OR_EXPIRED_RESET_LINK", message: "Invalid or expired reset link" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { uid, token, newPassword } = parsed.data;
|
||||||
|
const tokenHash = hashToken(token.trim());
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const consumed = await app.prisma.$transaction(async (tx) => {
|
||||||
|
const candidate = await tx.emailToken.findFirst({
|
||||||
|
where: {
|
||||||
|
userId: uid,
|
||||||
|
type: "password_reset",
|
||||||
|
tokenHash,
|
||||||
|
usedAt: null,
|
||||||
|
expiresAt: { gt: now },
|
||||||
|
},
|
||||||
|
select: { id: true, userId: true },
|
||||||
|
});
|
||||||
|
if (!candidate) return null;
|
||||||
|
|
||||||
|
const updated = await tx.emailToken.updateMany({
|
||||||
|
where: { id: candidate.id, usedAt: null },
|
||||||
|
data: { usedAt: now },
|
||||||
|
});
|
||||||
|
if (updated.count !== 1) return null;
|
||||||
|
|
||||||
|
const nextPasswordHash = await argon2.hash(newPassword, HASH_OPTIONS);
|
||||||
|
await tx.user.update({
|
||||||
|
where: { id: uid },
|
||||||
|
data: {
|
||||||
|
passwordHash: nextPasswordHash,
|
||||||
|
passwordChangedAt: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await tx.emailToken.deleteMany({
|
||||||
|
where: {
|
||||||
|
userId: uid,
|
||||||
|
type: "password_reset",
|
||||||
|
id: { not: candidate.id },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return candidate.userId;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!consumed) {
|
||||||
|
logSecurityEvent(req, "auth.password_reset.confirm", "failure", {
|
||||||
|
reason: "invalid_or_expired_token",
|
||||||
|
userId: uid,
|
||||||
|
});
|
||||||
|
return reply
|
||||||
|
.code(400)
|
||||||
|
.send({ ok: false, code: "INVALID_OR_EXPIRED_RESET_LINK", message: "Invalid or expired reset link" });
|
||||||
|
}
|
||||||
|
|
||||||
|
logSecurityEvent(req, "auth.password_reset.confirm", "success", { userId: consumed });
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
app.post("/account/delete-request", codeIssueRateLimit, async (req, reply) => {
|
app.post("/account/delete-request", codeIssueRateLimit, async (req, reply) => {
|
||||||
const Body = z.object({
|
const Body = z.object({
|
||||||
password: z.string().min(1),
|
password: z.string().min(1),
|
||||||
@@ -1317,7 +1560,7 @@ app.patch("/me/password", async (req, reply) => {
|
|||||||
// Update password
|
// Update password
|
||||||
await app.prisma.user.update({
|
await app.prisma.user.update({
|
||||||
where: { id: req.userId },
|
where: { id: req.userId },
|
||||||
data: { passwordHash: newHash },
|
data: { passwordHash: newHash, passwordChangedAt: new Date() },
|
||||||
});
|
});
|
||||||
|
|
||||||
return { ok: true, message: "Password updated successfully" };
|
return { ok: true, message: "Password updated successfully" };
|
||||||
|
|||||||
176
api/tests/forgot-password.security.test.ts
Normal file
176
api/tests/forgot-password.security.test.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import request from "supertest";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import argon2 from "argon2";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { buildApp } from "../src/server";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
let app: FastifyInstance;
|
||||||
|
|
||||||
|
const hashToken = (token: string) => createHash("sha256").update(token).digest("hex");
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
app = await buildApp({
|
||||||
|
AUTH_DISABLED: false,
|
||||||
|
SEED_DEFAULT_BUDGET: false,
|
||||||
|
PASSWORD_RESET_RATE_LIMIT_PER_MINUTE: 20,
|
||||||
|
PASSWORD_RESET_CONFIRM_RATE_LIMIT_PER_MINUTE: 20,
|
||||||
|
});
|
||||||
|
await app.ready();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (app) await app.close();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Forgot password security", () => {
|
||||||
|
it("returns generic success and only issues reset token for verified users", async () => {
|
||||||
|
const verifiedEmail = `fp-verified-${Date.now()}@test.dev`;
|
||||||
|
const unverifiedEmail = `fp-unverified-${Date.now()}@test.dev`;
|
||||||
|
const passwordHash = await argon2.hash("SupersAFE123!");
|
||||||
|
|
||||||
|
const [verifiedUser, unverifiedUser] = await Promise.all([
|
||||||
|
prisma.user.create({
|
||||||
|
data: { email: verifiedEmail, passwordHash, emailVerified: true },
|
||||||
|
}),
|
||||||
|
prisma.user.create({
|
||||||
|
data: { email: unverifiedEmail, passwordHash, emailVerified: false },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const unknownRes = await request(app.server)
|
||||||
|
.post("/auth/forgot-password/request")
|
||||||
|
.send({ email: `missing-${Date.now()}@test.dev` });
|
||||||
|
const verifiedRes = await request(app.server)
|
||||||
|
.post("/auth/forgot-password/request")
|
||||||
|
.send({ email: verifiedEmail });
|
||||||
|
const unverifiedRes = await request(app.server)
|
||||||
|
.post("/auth/forgot-password/request")
|
||||||
|
.send({ email: unverifiedEmail });
|
||||||
|
|
||||||
|
expect(unknownRes.status).toBe(200);
|
||||||
|
expect(verifiedRes.status).toBe(200);
|
||||||
|
expect(unverifiedRes.status).toBe(200);
|
||||||
|
|
||||||
|
expect(unknownRes.body).toEqual({
|
||||||
|
ok: true,
|
||||||
|
message: "If an account exists, reset instructions were sent.",
|
||||||
|
});
|
||||||
|
expect(verifiedRes.body).toEqual(unknownRes.body);
|
||||||
|
expect(unverifiedRes.body).toEqual(unknownRes.body);
|
||||||
|
|
||||||
|
const [verifiedTokens, unverifiedTokens] = await Promise.all([
|
||||||
|
prisma.emailToken.count({
|
||||||
|
where: { userId: verifiedUser.id, type: "password_reset", usedAt: null },
|
||||||
|
}),
|
||||||
|
prisma.emailToken.count({
|
||||||
|
where: { userId: unverifiedUser.id, type: "password_reset", usedAt: null },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(verifiedTokens).toBe(1);
|
||||||
|
expect(unverifiedTokens).toBe(0);
|
||||||
|
|
||||||
|
await prisma.user.deleteMany({
|
||||||
|
where: { email: { in: [verifiedEmail, unverifiedEmail] } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("consumes reset token once, updates password, and invalidates prior session", async () => {
|
||||||
|
const email = `fp-confirm-${Date.now()}@test.dev`;
|
||||||
|
const oldPassword = "SupersAFE123!";
|
||||||
|
const newPassword = "EvenStrong3rPass!";
|
||||||
|
const rawToken = "reset-token-value-0123456789abcdef";
|
||||||
|
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email,
|
||||||
|
emailVerified: true,
|
||||||
|
passwordHash: await argon2.hash(oldPassword),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const login = await request(app.server).post("/auth/login").send({ email, password: oldPassword });
|
||||||
|
expect(login.status).toBe(200);
|
||||||
|
const sessionCookie = (login.headers["set-cookie"] ?? []).find((c: string) => c.startsWith("session="));
|
||||||
|
expect(sessionCookie).toBeTruthy();
|
||||||
|
|
||||||
|
await prisma.emailToken.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
type: "password_reset",
|
||||||
|
tokenHash: hashToken(rawToken),
|
||||||
|
expiresAt: new Date(Date.now() + 30 * 60_000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const confirm = await request(app.server)
|
||||||
|
.post("/auth/forgot-password/confirm")
|
||||||
|
.send({ uid: user.id, token: rawToken, newPassword });
|
||||||
|
expect(confirm.status).toBe(200);
|
||||||
|
expect(confirm.body.ok).toBe(true);
|
||||||
|
|
||||||
|
const tokenRecord = await prisma.emailToken.findFirst({
|
||||||
|
where: { userId: user.id, type: "password_reset", tokenHash: hashToken(rawToken) },
|
||||||
|
});
|
||||||
|
expect(tokenRecord?.usedAt).toBeTruthy();
|
||||||
|
|
||||||
|
const secondUse = await request(app.server)
|
||||||
|
.post("/auth/forgot-password/confirm")
|
||||||
|
.send({ uid: user.id, token: rawToken, newPassword: "AnotherStrongPass4!" });
|
||||||
|
expect(secondUse.status).toBe(400);
|
||||||
|
expect(secondUse.body.code).toBe("INVALID_OR_EXPIRED_RESET_LINK");
|
||||||
|
|
||||||
|
const oldSessionAccess = await request(app.server)
|
||||||
|
.get("/dashboard")
|
||||||
|
.set("Cookie", [sessionCookie as string]);
|
||||||
|
expect(oldSessionAccess.status).toBe(401);
|
||||||
|
|
||||||
|
const oldPasswordLogin = await request(app.server)
|
||||||
|
.post("/auth/login")
|
||||||
|
.send({ email, password: oldPassword });
|
||||||
|
expect(oldPasswordLogin.status).toBe(401);
|
||||||
|
|
||||||
|
const newPasswordLogin = await request(app.server)
|
||||||
|
.post("/auth/login")
|
||||||
|
.send({ email, password: newPassword });
|
||||||
|
expect(newPasswordLogin.status).toBe(200);
|
||||||
|
|
||||||
|
await prisma.user.deleteMany({ where: { id: user.id } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects uid/token mismatches with generic error", async () => {
|
||||||
|
const emailA = `fp-a-${Date.now()}@test.dev`;
|
||||||
|
const emailB = `fp-b-${Date.now()}@test.dev`;
|
||||||
|
const rawToken = "mismatch-token-value-abcdef0123456789";
|
||||||
|
|
||||||
|
const [userA, userB] = await Promise.all([
|
||||||
|
prisma.user.create({
|
||||||
|
data: { email: emailA, emailVerified: true, passwordHash: await argon2.hash("SupersAFE123!") },
|
||||||
|
}),
|
||||||
|
prisma.user.create({
|
||||||
|
data: { email: emailB, emailVerified: true, passwordHash: await argon2.hash("SupersAFE123!") },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await prisma.emailToken.create({
|
||||||
|
data: {
|
||||||
|
userId: userA.id,
|
||||||
|
type: "password_reset",
|
||||||
|
tokenHash: hashToken(rawToken),
|
||||||
|
expiresAt: new Date(Date.now() + 30 * 60_000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const mismatch = await request(app.server)
|
||||||
|
.post("/auth/forgot-password/confirm")
|
||||||
|
.send({ uid: userB.id, token: rawToken, newPassword: "MismatchPass9!" });
|
||||||
|
|
||||||
|
expect(mismatch.status).toBe(400);
|
||||||
|
expect(mismatch.body.code).toBe("INVALID_OR_EXPIRED_RESET_LINK");
|
||||||
|
|
||||||
|
await prisma.user.deleteMany({ where: { id: { in: [userA.id, userB.id] } } });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -74,4 +74,20 @@ describe("A09 Security Logging and Monitoring Failures", () => {
|
|||||||
expect(typeof event?.requestId).toBe("string");
|
expect(typeof event?.requestId).toBe("string");
|
||||||
expect(typeof event?.ip).toBe("string");
|
expect(typeof event?.ip).toBe("string");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("emits structured security log for forgot-password requests without raw token data", async () => {
|
||||||
|
capturedEvents.length = 0;
|
||||||
|
|
||||||
|
const res = await request(authApp.server)
|
||||||
|
.post("/auth/forgot-password/request")
|
||||||
|
.send({ email: `missing-${Date.now()}@test.dev` });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const event = capturedEvents.find(
|
||||||
|
(payload) => payload.securityEvent === "auth.password_reset.request"
|
||||||
|
);
|
||||||
|
expect(event).toBeTruthy();
|
||||||
|
expect(event?.outcome).toBe("success");
|
||||||
|
expect(event && "token" in event).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ beforeAll(async () => {
|
|||||||
|
|
||||||
// Ensure a clean slate: wipe all tables to avoid cross-file leakage
|
// Ensure a clean slate: wipe all tables to avoid cross-file leakage
|
||||||
await prisma.$transaction([
|
await prisma.$transaction([
|
||||||
|
prisma.emailToken.deleteMany({}),
|
||||||
prisma.allocation.deleteMany({}),
|
prisma.allocation.deleteMany({}),
|
||||||
prisma.transaction.deleteMany({}),
|
prisma.transaction.deleteMany({}),
|
||||||
prisma.incomeEvent.deleteMany({}),
|
prisma.incomeEvent.deleteMany({}),
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const baseSecurityTests = [
|
|||||||
const dbSecurityTests = [
|
const dbSecurityTests = [
|
||||||
"tests/insecure-design.test.ts",
|
"tests/insecure-design.test.ts",
|
||||||
"tests/identification-auth-failures.test.ts",
|
"tests/identification-auth-failures.test.ts",
|
||||||
|
"tests/forgot-password.security.test.ts",
|
||||||
];
|
];
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ Last updated: March 1, 2026
|
|||||||
- `AUTH_MAX_FAILED_ATTEMPTS` (default: `5`)
|
- `AUTH_MAX_FAILED_ATTEMPTS` (default: `5`)
|
||||||
- `AUTH_LOCKOUT_WINDOW_MS` (default: `900000`, 15 minutes)
|
- `AUTH_LOCKOUT_WINDOW_MS` (default: `900000`, 15 minutes)
|
||||||
|
|
||||||
|
4. Added forgot-password hardening:
|
||||||
|
- Public reset request endpoint always returns a generic success response.
|
||||||
|
- Reset token issuance is restricted to verified users.
|
||||||
|
- Reset confirmation enforces strong password policy and one-time expiring token usage.
|
||||||
|
- Successful reset updates `passwordChangedAt` so existing sessions become invalid.
|
||||||
|
|
||||||
## Files changed
|
## Files changed
|
||||||
|
|
||||||
1. `api/src/server.ts`
|
1. `api/src/server.ts`
|
||||||
@@ -33,6 +39,9 @@ Last updated: March 1, 2026
|
|||||||
4. `api/tests/auth.routes.test.ts`
|
4. `api/tests/auth.routes.test.ts`
|
||||||
5. `api/tests/identification-auth-failures.test.ts`
|
5. `api/tests/identification-auth-failures.test.ts`
|
||||||
6. `api/vitest.security.config.ts`
|
6. `api/vitest.security.config.ts`
|
||||||
|
7. `api/tests/forgot-password.security.test.ts`
|
||||||
|
8. `api/prisma/schema.prisma`
|
||||||
|
9. `api/prisma/migrations/20260302000000_add_password_changed_at/migration.sql`
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ Last updated: March 1, 2026
|
|||||||
- `auth.logout` success
|
- `auth.logout` success
|
||||||
- `auth.verify` success/failure
|
- `auth.verify` success/failure
|
||||||
- `auth.verify_resend` success/failure/blocked
|
- `auth.verify_resend` success/failure/blocked
|
||||||
|
- `auth.password_reset.request` success/blocked
|
||||||
|
- `auth.password_reset.email` success/failure
|
||||||
|
- `auth.password_reset.confirm` success/failure
|
||||||
- `account.delete_request` success/failure/blocked
|
- `account.delete_request` success/failure/blocked
|
||||||
- `account.confirm_delete` success/failure/blocked
|
- `account.confirm_delete` success/failure/blocked
|
||||||
|
|
||||||
@@ -32,6 +35,8 @@ Last updated: March 1, 2026
|
|||||||
1. `api/src/server.ts`
|
1. `api/src/server.ts`
|
||||||
2. `api/tests/security-logging-monitoring-failures.test.ts`
|
2. `api/tests/security-logging-monitoring-failures.test.ts`
|
||||||
3. `api/vitest.security.config.ts`
|
3. `api/vitest.security.config.ts`
|
||||||
|
4. `api/tests/forgot-password.security.test.ts`
|
||||||
|
5. `SECURITY_FORGOT_PASSWORD.md`
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
@@ -52,6 +57,7 @@ Dedicated A09 checks in `security-logging-monitoring-failures.test.ts`:
|
|||||||
1. Runtime check emits structured `auth.unauthenticated_request` security event for protected-route access failures.
|
1. Runtime check emits structured `auth.unauthenticated_request` security event for protected-route access failures.
|
||||||
2. Runtime check emits structured `csrf.validation` security event for CSRF failures.
|
2. Runtime check emits structured `csrf.validation` security event for CSRF failures.
|
||||||
3. Validates correlation fields (`requestId`, `ip`, `outcome`) are present in emitted security events.
|
3. Validates correlation fields (`requestId`, `ip`, `outcome`) are present in emitted security events.
|
||||||
|
4. Runtime check emits `auth.password_reset.request` events and confirms raw token fields are absent.
|
||||||
|
|
||||||
## Residual notes
|
## Residual notes
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export default function App() {
|
|||||||
location.pathname.startsWith("/login") ||
|
location.pathname.startsWith("/login") ||
|
||||||
location.pathname.startsWith("/register") ||
|
location.pathname.startsWith("/register") ||
|
||||||
location.pathname.startsWith("/verify") ||
|
location.pathname.startsWith("/verify") ||
|
||||||
|
location.pathname.startsWith("/forgot-password") ||
|
||||||
|
location.pathname.startsWith("/reset-password") ||
|
||||||
location.pathname.startsWith("/beta");
|
location.pathname.startsWith("/beta");
|
||||||
|
|
||||||
const showUpdateModal = !isPublicRoute && !!notice && dismissedVersion !== notice.version;
|
const showUpdateModal = !isPublicRoute && !!notice && dismissedVersion !== notice.version;
|
||||||
|
|||||||
36
web/src/api/auth.ts
Normal file
36
web/src/api/auth.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { http } from "./http";
|
||||||
|
|
||||||
|
export type ForgotPasswordRequestPayload = {
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ForgotPasswordRequestResponse = {
|
||||||
|
ok: true;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ForgotPasswordConfirmPayload = {
|
||||||
|
uid: string;
|
||||||
|
token: string;
|
||||||
|
newPassword: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ForgotPasswordConfirmResponse = {
|
||||||
|
ok: true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function requestForgotPassword(payload: ForgotPasswordRequestPayload) {
|
||||||
|
return http<ForgotPasswordRequestResponse>("/auth/forgot-password/request", {
|
||||||
|
method: "POST",
|
||||||
|
body: payload,
|
||||||
|
skipAuthRedirect: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function confirmForgotPassword(payload: ForgotPasswordConfirmPayload) {
|
||||||
|
return http<ForgotPasswordConfirmResponse>("/auth/forgot-password/confirm", {
|
||||||
|
method: "POST",
|
||||||
|
body: payload,
|
||||||
|
skipAuthRedirect: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -55,6 +55,8 @@ const LoginPage = lazy(() => import("./pages/LoginPage"));
|
|||||||
const RegisterPage = lazy(() => import("./pages/RegisterPage"));
|
const RegisterPage = lazy(() => import("./pages/RegisterPage"));
|
||||||
const BetaAccessPage = lazy(() => import("./pages/BetaAccessPage"));
|
const BetaAccessPage = lazy(() => import("./pages/BetaAccessPage"));
|
||||||
const VerifyPage = lazy(() => import("./pages/VerifyPage"));
|
const VerifyPage = lazy(() => import("./pages/VerifyPage"));
|
||||||
|
const ForgotPasswordPage = lazy(() => import("./pages/ForgotPasswordPage"));
|
||||||
|
const ResetPasswordPage = lazy(() => import("./pages/ResetPasswordPage"));
|
||||||
|
|
||||||
const router = createBrowserRouter(
|
const router = createBrowserRouter(
|
||||||
createRoutesFromElements(
|
createRoutesFromElements(
|
||||||
@@ -72,6 +74,8 @@ const router = createBrowserRouter(
|
|||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/register" element={<RegisterPage />} />
|
<Route path="/register" element={<RegisterPage />} />
|
||||||
<Route path="/verify" element={<VerifyPage />} />
|
<Route path="/verify" element={<VerifyPage />} />
|
||||||
|
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||||
|
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||||
|
|
||||||
{/* Protected onboarding */}
|
{/* Protected onboarding */}
|
||||||
<Route
|
<Route
|
||||||
|
|||||||
67
web/src/pages/ForgotPasswordPage.tsx
Normal file
67
web/src/pages/ForgotPasswordPage.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { type FormEvent, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { requestForgotPassword } from "../api/auth";
|
||||||
|
|
||||||
|
const GENERIC_SUCCESS =
|
||||||
|
"If an account exists, reset instructions were sent.";
|
||||||
|
|
||||||
|
export default function ForgotPasswordPage() {
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [submitted, setSubmitted] = useState(false);
|
||||||
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const emailError = !email.trim() ? "Email is required." : "";
|
||||||
|
|
||||||
|
async function handleSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setSubmitted(true);
|
||||||
|
if (emailError) return;
|
||||||
|
|
||||||
|
setPending(true);
|
||||||
|
try {
|
||||||
|
await requestForgotPassword({ email });
|
||||||
|
setMessage(GENERIC_SUCCESS);
|
||||||
|
} catch {
|
||||||
|
setMessage(GENERIC_SUCCESS);
|
||||||
|
} finally {
|
||||||
|
setPending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center py-16 px-4">
|
||||||
|
<div className="card w-full max-w-md">
|
||||||
|
<h1 className="section-title mb-2">Forgot Password</h1>
|
||||||
|
<p className="muted mb-6">
|
||||||
|
Enter your email and we will send reset instructions if the account is eligible.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{message ? <div className="alert mb-4">{message}</div> : null}
|
||||||
|
|
||||||
|
<form className="stack gap-4" onSubmit={handleSubmit}>
|
||||||
|
<label className="stack gap-1">
|
||||||
|
<span className="text-sm font-medium">Email</span>
|
||||||
|
<input
|
||||||
|
className={`input ${submitted && emailError ? "border-red-500/60 ring-1 ring-red-500/40" : ""}`}
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{submitted && emailError ? <span className="text-xs text-red-400">{emailError}</span> : null}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button className="btn primary" type="submit" disabled={pending}>
|
||||||
|
{pending ? "Sending..." : "Send reset instructions"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="muted text-sm mt-6 text-center">
|
||||||
|
Back to <Link className="link" to="/login">Sign in</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -125,6 +125,11 @@ export default function LoginPage() {
|
|||||||
<span className="text-xs text-red-400">{passwordError}</span>
|
<span className="text-xs text-red-400">{passwordError}</span>
|
||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
|
<div className="text-right">
|
||||||
|
<Link className="link text-sm" to="/forgot-password">
|
||||||
|
Forgot password?
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
<button className="btn primary" type="submit" disabled={pending}>
|
<button className="btn primary" type="submit" disabled={pending}>
|
||||||
{pending ? "Signing in..." : "Sign in"}
|
{pending ? "Signing in..." : "Sign in"}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
115
web/src/pages/ResetPasswordPage.tsx
Normal file
115
web/src/pages/ResetPasswordPage.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { type FormEvent, useMemo, useState } from "react";
|
||||||
|
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import { confirmForgotPassword } from "../api/auth";
|
||||||
|
|
||||||
|
function useQueryParams() {
|
||||||
|
const location = useLocation();
|
||||||
|
return useMemo(() => new URLSearchParams(location.search), [location.search]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const GENERIC_RESET_ERROR = "Invalid or expired reset link.";
|
||||||
|
|
||||||
|
export default function ResetPasswordPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const params = useQueryParams();
|
||||||
|
|
||||||
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const uid = params.get("uid") || "";
|
||||||
|
const token = params.get("token") || "";
|
||||||
|
|
||||||
|
const passwordError =
|
||||||
|
!newPassword
|
||||||
|
? "New password is required."
|
||||||
|
: newPassword.length < 12
|
||||||
|
? "Password must be at least 12 characters."
|
||||||
|
: "";
|
||||||
|
const confirmError =
|
||||||
|
!confirmPassword
|
||||||
|
? "Please confirm your password."
|
||||||
|
: confirmPassword !== newPassword
|
||||||
|
? "Passwords do not match."
|
||||||
|
: "";
|
||||||
|
|
||||||
|
async function handleSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
if (!uid || !token) {
|
||||||
|
setError(GENERIC_RESET_ERROR);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (passwordError || confirmError) {
|
||||||
|
setError(passwordError || confirmError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPending(true);
|
||||||
|
try {
|
||||||
|
await confirmForgotPassword({
|
||||||
|
uid,
|
||||||
|
token,
|
||||||
|
newPassword,
|
||||||
|
});
|
||||||
|
setSuccess("Password reset successful. You can sign in now.");
|
||||||
|
setTimeout(() => navigate("/login", { replace: true }), 1000);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.code === "INVALID_OR_EXPIRED_RESET_LINK" || err?.status === 400) {
|
||||||
|
setError(GENERIC_RESET_ERROR);
|
||||||
|
} else {
|
||||||
|
setError(err?.message || "Unable to reset password.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setPending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center py-16 px-4">
|
||||||
|
<div className="card w-full max-w-md">
|
||||||
|
<h1 className="section-title mb-2">Reset Password</h1>
|
||||||
|
<p className="muted mb-6">Set a new password for your account.</p>
|
||||||
|
|
||||||
|
{!uid || !token ? <div className="alert alert-error mb-4">{GENERIC_RESET_ERROR}</div> : null}
|
||||||
|
{error ? <div className="alert alert-error mb-4">{error}</div> : null}
|
||||||
|
{success ? <div className="alert mb-4">{success}</div> : null}
|
||||||
|
|
||||||
|
<form className="stack gap-4" onSubmit={handleSubmit}>
|
||||||
|
<label className="stack gap-1">
|
||||||
|
<span className="text-sm font-medium">New password</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="stack gap-1">
|
||||||
|
<span className="text-sm font-medium">Confirm password</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button className="btn primary" type="submit" disabled={pending || !uid || !token}>
|
||||||
|
{pending ? "Resetting..." : "Reset password"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="muted text-sm mt-6 text-center">
|
||||||
|
<Link className="link" to="/forgot-password">Request a new reset link</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user