feat: add captcha options for changing password and email

This commit is contained in:
Tautvydas Derzinskas committed 2026-10-01 21:37:38 +02:00
1 parent 398614a4f7
commit 21f91d6275
13 files changed
+116 -15

No files matched your search

+6 -1
View File
@@ -7,7 +7,7 @@ import { prisma } from "../db";
import { HttpError } from "../utils/fileUtils";
import { parseBody } from "../utils/validate";
import { asyncHandler } from "../utils/asyncHandler";
import { getAllowRegistrations, isSmtpConfigured } from "../services/settingsService";
import { getAllowRegistrations, isSmtpConfigured, type CaptchaPlace } from "../services/settingsService";
import { sendVerificationEmail } from "../services/mailer";
import { findValidInvitation } from "../services/invitationService";
import { checkCaptcha } from "../services/captchaService";
@@ -219,6 +219,11 @@ router.patch(
requireAuth,
asyncHandler(async (req, res) => {
const body = parseBody(updateProfileSchema, req.body);
// Before the password check, so each guess at the current password costs a solved captcha.
const captchaPlaces: CaptchaPlace[] = [];
if (body.email !== undefined) captchaPlaces.push("change_email");
if (body.new_password !== undefined) captchaPlaces.push("change_password");
await checkCaptcha(req, captchaPlaces);
const user = await prisma.user.findUnique({ where: { id: req.userId! } });
if (!user) throw new HttpError(401, "Invalid or expired token");
if (!(await bcrypt.compare(body.current_password, user.passwordHash))) {
+9 -1
View File
@@ -94,7 +94,15 @@ router.get(
);
// Reading these is public (routes/captcha.ts): sign-in and register forms need them.
const captchaSettingsSchema = z.object({ login: z.boolean(), register: z.boolean(), import: z.boolean() }).partial();
const captchaSettingsSchema = z
.object({
login: z.boolean(),
register: z.boolean(),
import: z.boolean(),
change_password: z.boolean(),
change_email: z.boolean(),
})
.partial();
router.patch(
"/settings/captcha",
requireAdmin,
+6 -3
View File
@@ -1,4 +1,4 @@
// Image captchas for login, registration and import. svg-captcha draws characters as outlines,
// Image captchas for login, registration, import and profile changes. svg-captcha draws characters as outlines,
// not <text>, so the answer isn't in the markup.
//
// Answers live in memory, are single-use, and expire after CAPTCHA_TTL_MS.
@@ -63,8 +63,11 @@ export function isExtensionRequest(req: Request): boolean {
return req.get("x-thingport-client") === "grab" || EXTENSION_ORIGIN.test(req.get("origin") ?? "");
}
export async function checkCaptcha(req: Request, place: CaptchaPlace): Promise<void> {
if (isExtensionRequest(req) || !(await isCaptchaEnabled(place))) return;
/** With several places (one request changing several things), one captcha covers whichever are on. */
export async function checkCaptcha(req: Request, place: CaptchaPlace | CaptchaPlace[]): Promise<void> {
if (isExtensionRequest(req)) return;
const enabled = await Promise.all((Array.isArray(place) ? place : [place]).map(isCaptchaEnabled));
if (!enabled.includes(true)) return;
const body = (req.body ?? {}) as { captcha_id?: unknown; captcha_answer?: unknown };
if (typeof body.captcha_answer !== "string" || !body.captcha_answer.trim()) {
throw new HttpError(400, "Enter the characters shown in the image.", "CAPTCHA_REQUIRED");
+8 -2
View File
@@ -15,9 +15,15 @@ async function setBoolSetting(key: string, value: boolean): Promise<void> {
});
}
export type CaptchaPlace = "login" | "register" | "import";
export type CaptchaPlace = "login" | "register" | "import" | "change_password" | "change_email";
export type CaptchaSettings = Record<CaptchaPlace, boolean>;
export const CAPTCHA_PLACES: readonly CaptchaPlace[] = ["login", "register", "import"];
export const CAPTCHA_PLACES: readonly CaptchaPlace[] = [
"login",
"register",
"import",
"change_password",
"change_email",
];
const captchaKey = (place: CaptchaPlace) => `captcha_${place}`;
export async function getCaptchaSettings(): Promise<CaptchaSettings> {
+39 -2
View File
@@ -9,7 +9,7 @@ const app = createApp();
const stamp = Date.now();
const email = (name: string) => `${name}-${stamp}@example.com`;
const auth = (token: string) => ({ Authorization: `Bearer ${token}` });
const ALL_OFF = { login: false, register: false, import: false };
const ALL_OFF = { login: false, register: false, import: false, change_password: false, change_email: false };
let adminToken: string;
let memberToken: string;
@@ -64,7 +64,7 @@ describe("captcha settings", () => {
).toBe(403);
const res = await request(app).patch("/api/settings/captcha").set(auth(adminToken)).send({ login: true });
expect(res.status).toBe(200);
expect(res.body).toEqual({ login: true, register: false, import: false });
expect(res.body).toEqual({ ...ALL_OFF, login: true });
});
});
@@ -178,6 +178,43 @@ describe("captcha on registration", () => {
});
});
describe("captcha on profile changes", () => {
// Unchanged values keep the member's credentials (used by other tests) and skip the verification email.
const changePassword = (extra: object = {}) =>
request(app)
.patch("/api/profile")
.set(auth(memberToken))
.send({ current_password: "password123", new_password: "password123", ...extra });
const changeEmail = (extra: object = {}) =>
request(app)
.patch("/api/profile")
.set(auth(memberToken))
.send({ current_password: "password123", email: email("captcha-member"), ...extra });
it("isn't asked for while turned off", async () => {
expect((await changePassword()).status).toBe(200);
expect((await changeEmail()).status).toBe(200);
});
it("guards each change only when its own place is on", async () => {
await setCaptchaSettings({ change_password: true });
expect((await changePassword()).body.code).toBe("CAPTCHA_REQUIRED");
expect((await changePassword(solved())).status).toBe(200);
expect((await changeEmail()).status).toBe(200);
await setCaptchaSettings({ change_password: false, change_email: true });
expect((await changeEmail()).body.code).toBe("CAPTCHA_REQUIRED");
expect((await changeEmail(solved())).status).toBe(200);
expect((await changePassword()).status).toBe(200);
});
it("is checked before the current password", async () => {
await setCaptchaSettings({ change_password: true });
const res = await changePassword({ current_password: "wrong-password" });
expect(res.body.code).toBe("CAPTCHA_REQUIRED");
});
});
describe("captcha on import", () => {
it("guards every endpoint that starts an import, but not the previews", async () => {
await setCaptchaSettings({ import: true });
+1 -1
View File
@@ -19,7 +19,7 @@ export type UpdateProfileInput = {
current_password: string;
email?: string;
new_password?: string;
};
} & Partial<CaptchaAnswer>;
async function readAuthError(res: Response): Promise<never> {
let message = "Request failed";
+1 -1
View File
@@ -2,7 +2,7 @@ import { authHeaders } from "../utils/auth";
import { apiBase, assertOk, readErrorMessage, UnauthorizedError } from "./client";
/** Where the web app asks for a captcha (Administration > Captcha). */
export type CaptchaPlace = "login" | "register" | "import";
export type CaptchaPlace = "login" | "register" | "import" | "change_password" | "change_email";
export type CaptchaSettings = Record<CaptchaPlace, boolean>;
/** A solved captcha, sent along with the request it guards. */
+1 -1
View File
@@ -11,7 +11,7 @@ export function useCaptchaSettings(): CaptchaSettings | null {
let active = true;
cached ??= captchaApi.getSettings().catch(() => {
cached = null;
return { login: false, register: false, import: false };
return { login: false, register: false, import: false, change_password: false, change_email: false };
});
void cached.then((value) => {
if (active) setSettings(value);
+8
View File
@@ -442,6 +442,14 @@
"import": {
"label": "Import",
"help": "Asked in the Import dialog before an import starts. Uploading your own files isn't affected."
},
"change_password": {
"label": "Change password",
"help": "Asked when changing your password on the profile page, before the current password is checked."
},
"change_email": {
"label": "Change email",
"help": "Asked when changing your email on the profile page, before the current password is checked."
}
},
"loadFailed": "Unable to load the captcha settings.",
+8
View File
@@ -444,6 +444,14 @@
"import": {
"label": "Importuojant",
"help": "Prašoma importavimo lange prieš pradedant importą. Savų failų įkėlimui netaikoma."
},
"change_password": {
"label": "Keičiant slaptažodį",
"help": "Prašoma keičiant slaptažodį profilio puslapyje, prieš tikrinant dabartinį slaptažodį."
},
"change_email": {
"label": "Keičiant el. paštą",
"help": "Prašoma keičiant el. pašto adresą profilio puslapyje, prieš tikrinant dabartinį slaptažodį."
}
},
"loadFailed": "Nepavyko įkelti captcha nustatymų.",
+1 -1
View File
@@ -21,7 +21,7 @@ type Props = {
onUnauthorized?: () => void;
};
const PLACES: CaptchaPlace[] = ["login", "register", "import"];
const PLACES: CaptchaPlace[] = ["login", "register", "import", "change_password", "change_email"];
/** Each box saves as soon as it's ticked. The extension is never asked for a captcha. */
export default function CaptchaPage({ onUnauthorized }: Props) {
@@ -9,6 +9,9 @@ import Alert from "@mui/material/Alert";
import SectionHeader from "../../components/SectionHeader";
import { authApi, type AuthUser } from "../../api/auth";
import { UnauthorizedError } from "../../api/client";
import type { CaptchaAnswer } from "../../api/captcha";
import CaptchaField from "../../components/CaptchaField";
import { useCaptchaSettings } from "../../hooks/useCaptchaSettings";
type Props = {
user: AuthUser | null;
@@ -24,6 +27,9 @@ export default function ChangeEmailPage({ user, onUserUpdated, onUnauthorized }:
const [loading, setLoading] = React.useState(false);
const [status, setStatus] = React.useState<string | null>(null);
const [error, setError] = React.useState<string | null>(null);
const captchaEnabled = useCaptchaSettings()?.change_email ?? false;
const [captcha, setCaptcha] = React.useState<CaptchaAnswer | null>(null);
const [captchaKey, setCaptchaKey] = React.useState(0);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -31,7 +37,11 @@ export default function ChangeEmailPage({ user, onUserUpdated, onUnauthorized }:
setStatus(null);
setLoading(true);
try {
const result = await authApi.updateProfile({ current_password: password, email: email.trim() });
const result = await authApi.updateProfile({
current_password: password,
email: email.trim(),
...(captchaEnabled && captcha),
});
onUserUpdated(result.user);
setPassword("");
setStatus(
@@ -46,6 +56,8 @@ export default function ChangeEmailPage({ user, onUserUpdated, onUnauthorized }:
}
setError(err instanceof Error ? err.message : t("profile.genericError"));
} finally {
// Every attempt, successful or not, uses the captcha up.
if (captchaEnabled) setCaptchaKey((k) => k + 1);
setLoading(false);
}
};
@@ -95,6 +107,7 @@ export default function ChangeEmailPage({ user, onUserUpdated, onUnauthorized }:
fullWidth
size="small"
/>
{captchaEnabled && <CaptchaField key={captchaKey} onChange={setCaptcha} disabled={loading} />}
<Button type="submit" variant="contained" disabled={loading}>
{loading ? t("profile.saving") : t("profile.saveEmail")}
</Button>
@@ -9,6 +9,9 @@ import Alert from "@mui/material/Alert";
import SectionHeader from "../../components/SectionHeader";
import { authApi } from "../../api/auth";
import { UnauthorizedError } from "../../api/client";
import type { CaptchaAnswer } from "../../api/captcha";
import CaptchaField from "../../components/CaptchaField";
import { useCaptchaSettings } from "../../hooks/useCaptchaSettings";
const MIN_PASSWORD_LENGTH = 8;
@@ -25,6 +28,9 @@ export default function ChangePasswordPage({ onUnauthorized }: Props) {
const [loading, setLoading] = React.useState(false);
const [status, setStatus] = React.useState<string | null>(null);
const [error, setError] = React.useState<string | null>(null);
const captchaEnabled = useCaptchaSettings()?.change_password ?? false;
const [captcha, setCaptcha] = React.useState<CaptchaAnswer | null>(null);
const [captchaKey, setCaptchaKey] = React.useState(0);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -40,7 +46,11 @@ export default function ChangePasswordPage({ onUnauthorized }: Props) {
}
setLoading(true);
try {
await authApi.updateProfile({ current_password: currentPassword, new_password: newPassword });
await authApi.updateProfile({
current_password: currentPassword,
new_password: newPassword,
...(captchaEnabled && captcha),
});
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
@@ -52,6 +62,8 @@ export default function ChangePasswordPage({ onUnauthorized }: Props) {
}
setError(err instanceof Error ? err.message : t("profile.genericError"));
} finally {
// Every attempt, successful or not, uses the captcha up.
if (captchaEnabled) setCaptchaKey((k) => k + 1);
setLoading(false);
}
};
@@ -109,6 +121,7 @@ export default function ChangePasswordPage({ onUnauthorized }: Props) {
fullWidth
size="small"
/>
{captchaEnabled && <CaptchaField key={captchaKey} onChange={setCaptcha} disabled={loading} />}
<Button type="submit" variant="contained" disabled={loading}>
{loading ? t("profile.saving") : t("profile.savePassword")}
</Button>