Skip to content

Commit c44b1e5

Browse files
committed
major improvement to verification
This drastically reduces the amount of duplicate code and simplifies the process of verification. This also makes it much easier to add verification before critical actions (like an email change). At a high level, this makes a route for verifications in general that anything can send the user to. It also provides handy reusable utilities for the purpose. This also adds a resource route for doing inline verifications as well.
1 parent 5a1535f commit c44b1e5

17 files changed

+555
-923
lines changed

app/routes/_auth+/forgot-password/index.tsx

Lines changed: 64 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -11,101 +11,93 @@ import { z } from 'zod'
1111
import { GeneralErrorBoundary } from '~/components/error-boundary.tsx'
1212
import { ErrorList, Field } from '~/components/forms.tsx'
1313
import { StatusButton } from '~/components/ui/status-button.tsx'
14+
import {
15+
getRedirectToUrl,
16+
prepareVerification,
17+
} from '~/routes/resources+/verify.tsx'
1418
import { prisma } from '~/utils/db.server.ts'
1519
import { sendEmail } from '~/utils/email.server.ts'
16-
import { getDomainUrl } from '~/utils/misc.ts'
17-
import { generateTOTP } from '~/utils/totp.server.ts'
1820
import { emailSchema, usernameSchema } from '~/utils/user-validation.ts'
1921
import { ForgotPasswordEmail } from './email.server.tsx'
2022

21-
export const forgotPasswordOTPQueryParam = 'code'
22-
export const forgotPasswordTargetQueryParam = 'usernameOrEmail'
23-
export const verificationType = 'forgot-password'
24-
25-
const forgotPasswordSchema = z.object({
23+
const ForgotPasswordSchema = z.object({
2624
usernameOrEmail: z.union([emailSchema, usernameSchema]),
2725
})
2826

2927
export async function action({ request }: DataFunctionArgs) {
3028
const formData = await request.formData()
31-
const submission = parse(formData, {
32-
schema: forgotPasswordSchema,
29+
const submission = await parse(formData, {
30+
schema: ForgotPasswordSchema.superRefine(async (data, ctx) => {
31+
if (data.usernameOrEmail.includes('@')) return
32+
33+
// check the username exists. Usernames have to be unique anyway so anyone
34+
// signing up can check whether a username exists by trying to sign up
35+
// with it.
36+
const user = await prisma.user.findUnique({
37+
where: { username: data.usernameOrEmail },
38+
select: { id: true },
39+
})
40+
if (!user) {
41+
ctx.addIssue({
42+
path: ['usernameOrEmail'],
43+
code: z.ZodIssueCode.custom,
44+
message: 'No user exists with this username',
45+
})
46+
return
47+
}
48+
}),
49+
async: true,
3350
acceptMultipleErrors: () => true,
3451
})
3552
if (submission.intent !== 'submit') {
3653
return json({ status: 'idle', submission } as const)
3754
}
3855
if (!submission.value) {
39-
return json(
40-
{
41-
status: 'error',
42-
submission,
43-
} as const,
44-
{ status: 400 },
45-
)
56+
return json({ status: 'error', submission } as const, { status: 400 })
4657
}
4758
const { usernameOrEmail } = submission.value
59+
const redirectTo = getRedirectToUrl({
60+
request,
61+
type: 'forgot-password',
62+
target: usernameOrEmail,
63+
})
4864

49-
const resetPasswordUrl = new URL(
50-
`${getDomainUrl(request)}/forgot-password/verify`,
51-
)
52-
resetPasswordUrl.searchParams.set(
53-
forgotPasswordTargetQueryParam,
54-
usernameOrEmail,
55-
)
56-
const redirectTo = new URL(resetPasswordUrl.toString())
65+
// fire, forget, and don't wait to combat timing attacks
66+
void sendVerifyEmail({ request, target: usernameOrEmail })
67+
68+
return redirect(redirectTo.toString())
69+
}
5770

71+
async function sendVerifyEmail({
72+
request,
73+
target,
74+
}: {
75+
request: Request
76+
target: string
77+
}) {
5878
const user = await prisma.user.findFirst({
59-
where: { OR: [{ email: usernameOrEmail }, { username: usernameOrEmail }] },
79+
where: { OR: [{ email: target }, { username: target }] },
6080
select: { email: true, username: true },
6181
})
62-
if (user) {
63-
// fire and forget to avoid timing attacks
64-
65-
const tenMinutesInSeconds = 10 * 60
66-
// using username or email as the verification target allows us to
67-
// avoid leaking whether a username or email is registered. It also
68-
// allows a user who forgot one to use the other to reset their password.
69-
// And displaying what the user provided rather than the other ensures we
70-
// don't leak the association between the two.
71-
const target = usernameOrEmail
72-
const { otp, secret, algorithm, period, digits } = generateTOTP({
73-
algorithm: 'SHA256',
74-
period: tenMinutesInSeconds,
75-
})
76-
// delete old verifications. Users should not have more than one verification
77-
// of a specific type for a specific target at a time.
78-
await prisma.verification.deleteMany({
79-
where: { type: verificationType, target },
80-
})
81-
await prisma.verification.create({
82-
data: {
83-
type: verificationType,
84-
target,
85-
algorithm,
86-
secret,
87-
period,
88-
digits,
89-
expiresAt: new Date(Date.now() + period * 1000),
90-
},
91-
})
92-
93-
// add the otp to the url we'll email the user.
94-
resetPasswordUrl.searchParams.set(forgotPasswordOTPQueryParam, otp)
95-
96-
await sendEmail({
97-
to: user.email,
98-
subject: `Epic Notes Password Reset`,
99-
react: (
100-
<ForgotPasswordEmail
101-
onboardingUrl={resetPasswordUrl.toString()}
102-
otp={otp}
103-
/>
104-
),
105-
})
82+
if (!user) {
83+
// maybe they're trying to see whether a user exists? We're not gonna tell them...
84+
return
10685
}
10786

108-
return redirect(redirectTo.pathname + redirectTo.search)
87+
const { verifyUrl, otp } = await prepareVerification({
88+
period: 10 * 60,
89+
request,
90+
type: 'forgot-password',
91+
target,
92+
})
93+
94+
await sendEmail({
95+
to: user.email,
96+
subject: `Epic Notes Password Reset`,
97+
react: (
98+
<ForgotPasswordEmail onboardingUrl={verifyUrl.toString()} otp={otp} />
99+
),
100+
})
109101
}
110102

111103
export const meta: V2_MetaFunction = () => {
@@ -117,10 +109,10 @@ export default function ForgotPasswordRoute() {
117109

118110
const [form, fields] = useForm({
119111
id: 'forgot-password-form',
120-
constraint: getFieldsetConstraint(forgotPasswordSchema),
112+
constraint: getFieldsetConstraint(ForgotPasswordSchema),
121113
lastSubmission: forgotPassword.data?.submission,
122114
onValidate({ formData }) {
123-
return parse(formData, { schema: forgotPasswordSchema })
115+
return parse(formData, { schema: ForgotPasswordSchema })
124116
},
125117
shouldRevalidate: 'onBlur',
126118
})

app/routes/_auth+/forgot-password_.verify.tsx

Lines changed: 0 additions & 203 deletions
This file was deleted.

0 commit comments

Comments
 (0)