feat: Add password checks for password-reset (#10042)
Co-authored-by: zomars <zomars@me.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
|
||||
import { validPassword } from "@calcom/features/auth/lib/validPassword";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
@@ -36,6 +37,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
return res.status(400).json({ message: "Couldn't find an account for this email" });
|
||||
}
|
||||
|
||||
if (!validPassword(rawPassword)) {
|
||||
return res.status(400).json({ message: "Password does not meet the requirements" });
|
||||
}
|
||||
|
||||
const hashedPassword = await hashPassword(rawPassword);
|
||||
|
||||
await prisma.user.update({
|
||||
@@ -47,9 +52,23 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
},
|
||||
});
|
||||
|
||||
await expireResetPasswordRequest(rawRequestId);
|
||||
|
||||
return res.status(201).json({ message: "Password reset." });
|
||||
} catch (reason) {
|
||||
console.error(reason);
|
||||
return res.status(500).json({ message: "Unable to create password reset request" });
|
||||
}
|
||||
}
|
||||
|
||||
async function expireResetPasswordRequest(rawRequestId: string) {
|
||||
await prisma.resetPasswordRequest.update({
|
||||
where: {
|
||||
id: rawRequestId,
|
||||
},
|
||||
data: {
|
||||
// We set the expiry to now to invalidate the request
|
||||
expires: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import type { ResetPasswordRequest } from "@prisma/client";
|
||||
import { debounce } from "lodash";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { getCsrfToken } from "next-auth/react";
|
||||
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
|
||||
import Link from "next/link";
|
||||
import type { CSSProperties } from "react";
|
||||
import React, { useMemo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { Button, TextField } from "@calcom/ui";
|
||||
import { Button, PasswordField, Form } from "@calcom/ui";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
import AuthContainer from "@components/ui/AuthContainer";
|
||||
@@ -23,40 +23,22 @@ type Props = {
|
||||
|
||||
export default function Page({ resetPasswordRequest, csrfToken }: Props) {
|
||||
const { t } = useLocale();
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [, setError] = React.useState<{ message: string } | null>(null);
|
||||
const [success, setSuccess] = React.useState(false);
|
||||
|
||||
const [password, setPassword] = React.useState("");
|
||||
const formMethods = useForm<{ new_password: string }>();
|
||||
const success = formMethods.formState.isSubmitSuccessful;
|
||||
const loading = formMethods.formState.isSubmitting;
|
||||
|
||||
const submitChangePassword = async ({ password, requestId }: { password: string; requestId: string }) => {
|
||||
try {
|
||||
const res = await fetch("/api/auth/reset-password", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ requestId: requestId, password: password }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
setError(json);
|
||||
} else {
|
||||
setSuccess(true);
|
||||
}
|
||||
|
||||
return json;
|
||||
} catch (reason) {
|
||||
setError({ message: t("unexpected_error_try_again") });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
const res = await fetch("/api/auth/reset-password", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ requestId, password }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok) return formMethods.setError("new_password", { type: "server", message: json.message });
|
||||
};
|
||||
|
||||
const debouncedChangePassword = debounce(submitChangePassword, 250);
|
||||
|
||||
const Success = () => {
|
||||
return (
|
||||
<>
|
||||
@@ -109,8 +91,9 @@ export default function Page({ resetPasswordRequest, csrfToken }: Props) {
|
||||
{isRequestExpired && <Expired />}
|
||||
{!isRequestExpired && !success && (
|
||||
<>
|
||||
<form
|
||||
<Form
|
||||
className="space-y-6"
|
||||
form={formMethods}
|
||||
style={
|
||||
{
|
||||
"--cal-brand": "#111827",
|
||||
@@ -119,36 +102,26 @@ export default function Page({ resetPasswordRequest, csrfToken }: Props) {
|
||||
"--cal-brand-subtle": "#9CA3AF",
|
||||
} as CSSProperties
|
||||
}
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!password) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
|
||||
await debouncedChangePassword({ password, requestId: resetPasswordRequest.id });
|
||||
}}
|
||||
action="#">
|
||||
handleSubmit={async (values) => {
|
||||
await submitChangePassword({
|
||||
password: values.new_password,
|
||||
requestId: resetPasswordRequest.id,
|
||||
});
|
||||
}}>
|
||||
<input name="csrfToken" type="hidden" defaultValue={csrfToken} hidden />
|
||||
<div className="mt-1">
|
||||
<TextField
|
||||
<PasswordField
|
||||
{...formMethods.register("new_password", {
|
||||
minLength: {
|
||||
message: t("password_hint_min"),
|
||||
value: 7, // We don't have user here so we can't check if they are admin or not
|
||||
},
|
||||
pattern: {
|
||||
message: "Should contain a number, uppercase and lowercase letters",
|
||||
value: /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[a-zA-Z]).*$/gm,
|
||||
},
|
||||
})}
|
||||
label={t("new_password")}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
}}
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -162,7 +135,7 @@ export default function Page({ resetPasswordRequest, csrfToken }: Props) {
|
||||
{t("reset_password")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
{!isRequestExpired && success && (
|
||||
|
||||
@@ -8,7 +8,7 @@ test("Can reset forgotten password", async ({ page, users }) => {
|
||||
// eslint-disable-next-line playwright/no-skipped-test
|
||||
test.skip(process.env.NEXT_PUBLIC_IS_E2E !== "1", "It shouldn't if we can't skip email");
|
||||
const user = await users.create();
|
||||
const newPassword = `${user.username}-123`;
|
||||
const newPassword = `${user.username}-123CAL`; // To match the password policy
|
||||
// Got to reset password flow
|
||||
await page.goto("/auth/forgot-password");
|
||||
|
||||
@@ -24,8 +24,8 @@ test("Can reset forgotten password", async ({ page, users }) => {
|
||||
|
||||
// Wait for page to fully load
|
||||
await page.waitForSelector("text=Reset Password");
|
||||
// Fill input[name="password"]
|
||||
await page.fill('input[name="password"]', newPassword);
|
||||
// Fill input[name="new_password"]
|
||||
await page.fill('input[name="new_password"]', newPassword);
|
||||
|
||||
// Click text=Submit
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
Reference in New Issue
Block a user