mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-19 05:10:01 -05:00
feat: add captcha options for changing password and email
This commit is contained in:
1 parent
398614a4f7
commit
21f91d6275
13 files changed
+116
-15
No files matched your search
@@ -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))) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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ų.",
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user