Skip to content

Commit 796f4ea

Browse files
committed
🚀 switch to TOTP
1 parent ec5975e commit 796f4ea

24 files changed

+1258
-561
lines changed

.env.example

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ DATABASE_PATH="./prisma/data.db"
33
DATABASE_URL="file:./data.db?connection_limit=1"
44
CACHE_DATABASE_PATH="./other/cache.db"
55
SESSION_SECRET="super-duper-s3cret"
6-
ENCRYPTION_SECRET="some-made-up-secret"
76
INTERNAL_COMMAND_TOKEN="some-made-up-token"
87
MAILGUN_DOMAIN="mg.example.com"
98
MAILGUN_SENDING_KEY="some-api-token-with-dashes"

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

Lines changed: 107 additions & 124 deletions
Original file line numberDiff line numberDiff line change
@@ -11,50 +11,19 @@ import { z } from 'zod'
1111
import { GeneralErrorBoundary } from '~/components/error-boundary.tsx'
1212
import { prisma } from '~/utils/db.server.ts'
1313
import { sendEmail } from '~/utils/email.server.ts'
14-
import { decrypt, encrypt } from '~/utils/encryption.server.ts'
1514
import { Button, ErrorList, Field } from '~/utils/forms.tsx'
1615
import { getDomainUrl } from '~/utils/misc.server.ts'
17-
import { commitSession, getSession } from '~/utils/session.server.ts'
16+
import { generateTOTP } from '~/utils/totp.server.ts'
1817
import { emailSchema, usernameSchema } from '~/utils/user-validation.ts'
1918

20-
export const resetPasswordSessionKey = 'resetPasswordToken'
21-
const resetPasswordTokenQueryParam = 'token'
22-
const tokenType = 'forgot-password'
19+
export const forgotPasswordOTPQueryParam = 'code'
20+
export const forgotPasswordVerificationTargetQueryParam = 'usernameOrEmail'
21+
export const verificationType = 'forgot-password'
2322

2423
const forgotPasswordSchema = z.object({
2524
usernameOrEmail: z.union([emailSchema, usernameSchema]),
2625
})
2726

28-
const tokenSchema = z.object({
29-
type: z.literal(tokenType),
30-
payload: z.object({
31-
username: usernameSchema,
32-
}),
33-
})
34-
35-
export async function loader({ request }: DataFunctionArgs) {
36-
const resetPasswordTokenString = new URL(request.url).searchParams.get(
37-
resetPasswordTokenQueryParam,
38-
)
39-
if (resetPasswordTokenString) {
40-
const submission = tokenSchema.safeParse(
41-
JSON.parse(decrypt(resetPasswordTokenString)),
42-
)
43-
if (!submission.success) return redirect('/signup')
44-
const token = submission.data
45-
46-
const session = await getSession(request.headers.get('cookie'))
47-
session.set(resetPasswordSessionKey, token.payload.username)
48-
return redirect('/reset-password', {
49-
headers: {
50-
'Set-Cookie': await commitSession(session),
51-
},
52-
})
53-
}
54-
55-
return json({})
56-
}
57-
5827
export async function action({ request }: DataFunctionArgs) {
5928
const formData = await request.formData()
6029
const submission = parse(formData, {
@@ -75,58 +44,84 @@ export async function action({ request }: DataFunctionArgs) {
7544
}
7645
const { usernameOrEmail } = submission.value
7746

47+
const resetPasswordUrl = new URL(
48+
`${getDomainUrl(request)}/forgot-password/verify`,
49+
)
50+
resetPasswordUrl.searchParams.set(
51+
forgotPasswordVerificationTargetQueryParam,
52+
usernameOrEmail,
53+
)
54+
const redirectTo = new URL(resetPasswordUrl.toString())
55+
7856
const user = await prisma.user.findFirst({
7957
where: { OR: [{ email: usernameOrEmail }, { username: usernameOrEmail }] },
8058
select: { email: true, username: true },
8159
})
8260
if (user) {
83-
void sendPasswordResetEmail({ request, user })
84-
}
85-
86-
return json({ status: 'success', submission } as const)
87-
}
88-
89-
async function sendPasswordResetEmail({
90-
request,
91-
user,
92-
}: {
93-
request: Request
94-
user: { email: string; username: string }
95-
}) {
96-
const resetPasswordToken = encrypt(
97-
JSON.stringify({ type: tokenType, payload: { username: user.username } }),
98-
)
99-
const resetPasswordUrl = new URL(`${getDomainUrl(request)}/forgot-password`)
100-
resetPasswordUrl.searchParams.set(
101-
resetPasswordTokenQueryParam,
102-
resetPasswordToken,
103-
)
61+
// fire and forget to avoid timing attacks
62+
63+
const tenMinutesInSeconds = 10 * 60
64+
// using username or email as the verification target allows us to
65+
// avoid leaking whether a username or email is registered. It also
66+
// allows a user who forgot one to use the other to reset their password.
67+
// And displaying what the user provided rather than the other ensures we
68+
// don't leak the association between the two.
69+
const verificationTarget = usernameOrEmail
70+
const { otp, key, algorithm, validSeconds } = generateTOTP({
71+
validSeconds: tenMinutesInSeconds,
72+
})
73+
// delete old verifications. Users should not have more than one verification
74+
// of a specific type for a specific target at a time.
75+
await prisma.verification.deleteMany({
76+
where: { type: verificationType, verificationTarget },
77+
})
78+
await prisma.verification.create({
79+
data: {
80+
type: verificationType,
81+
verificationTarget,
82+
algorithm,
83+
secretKey: key,
84+
validSeconds,
85+
otp,
86+
},
87+
})
10488

105-
await sendEmail({
106-
to: user.email,
107-
subject: `Epic Notes Password Reset`,
108-
text: `Please open this URL: ${resetPasswordUrl}`,
109-
html: `
110-
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
111-
<html>
112-
<head>
113-
<meta http-equiv="Content-Type" content="text/html charset=UTF-8" />
114-
</head>
115-
<body>
116-
<h1>Reset your Epic Notes password.</h1>
117-
<p>Click the link below to reset the Epic Notes password for ${user.username}.</p>
118-
<a href="${resetPasswordUrl}">${resetPasswordUrl}</a>
119-
</body>
120-
</html>
89+
// add the otp to the url we'll email the user.
90+
resetPasswordUrl.searchParams.set(forgotPasswordOTPQueryParam, otp)
91+
92+
await sendEmail({
93+
to: user.email,
94+
subject: `Epic Notes Password Reset`,
95+
text: `
96+
Welcome to Epic Notes!
97+
Here's your verification code: ${otp}
98+
Or you can open this URL: ${resetPasswordUrl}
99+
`.trim(),
100+
html: `
101+
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
102+
<html>
103+
<head>
104+
<meta http-equiv="Content-Type" content="text/html charset=UTF-8" />
105+
</head>
106+
<body>
107+
<h1>Reset your Epic Notes password for ${user.username}.</h1>
108+
<p>Here's your verification code: <strong>${otp}</strong></p>
109+
<p>Or click this link:</p>
110+
<a href="${resetPasswordUrl}">${resetPasswordUrl}</a>
111+
</body>
112+
</html>
121113
`,
122-
})
114+
})
115+
}
116+
117+
return redirect(redirectTo.pathname + redirectTo.search)
123118
}
124119

125120
export const meta: V2_MetaFunction = () => {
126121
return [{ title: 'Password Recovery for Epic Notes' }]
127122
}
128123

129-
export default function SignupRoute() {
124+
export default function ForgotPasswordRoute() {
130125
const forgotPassword = useFetcher<typeof action>()
131126

132127
const [form, fields] = useForm({
@@ -142,58 +137,46 @@ export default function SignupRoute() {
142137
return (
143138
<div className="container mx-auto pb-32 pt-20">
144139
<div className="flex flex-col justify-center">
145-
{forgotPassword.data?.status === 'success' ? (
146-
<div className="text-center">
147-
<img src="" alt="" />
148-
<h1 className="mt-44 text-h1">Check your email</h1>
149-
<p className="mt-3 text-body-md text-night-200">
150-
Instructions have been sent to the email address on file.
151-
</p>
140+
<div className="text-center">
141+
<h1 className="text-h1">Forgot Password</h1>
142+
<p className="mt-3 text-body-md text-night-200">
143+
No worries, we'll send you reset instructions.
144+
</p>
145+
</div>
146+
<forgotPassword.Form
147+
method="POST"
148+
{...form.props}
149+
className="mx-auto mt-16 min-w-[368px] max-w-sm"
150+
>
151+
<div>
152+
<Field
153+
labelProps={{
154+
htmlFor: fields.usernameOrEmail.id,
155+
children: 'Username or Email',
156+
}}
157+
inputProps={conform.input(fields.usernameOrEmail)}
158+
errors={fields.usernameOrEmail.errors}
159+
/>
152160
</div>
153-
) : (
154-
<>
155-
<div className="text-center">
156-
<h1 className="text-h1">Forgot Password</h1>
157-
<p className="mt-3 text-body-md text-night-200">
158-
No worries, we'll send you reset instructions.
159-
</p>
160-
</div>
161-
<forgotPassword.Form
162-
method="POST"
163-
{...form.props}
164-
className="mx-auto mt-16 min-w-[368px] max-w-sm"
161+
<ErrorList errors={form.errors} id={form.errorId} />
162+
163+
<div className="mt-6">
164+
<Button
165+
className="w-full"
166+
size="md"
167+
variant="primary"
168+
status={
169+
forgotPassword.state === 'submitting'
170+
? 'pending'
171+
: forgotPassword.data?.status ?? 'idle'
172+
}
173+
type="submit"
174+
disabled={forgotPassword.state !== 'idle'}
165175
>
166-
<div>
167-
<Field
168-
labelProps={{
169-
htmlFor: fields.usernameOrEmail.id,
170-
children: 'Username or Email',
171-
}}
172-
inputProps={conform.input(fields.usernameOrEmail)}
173-
errors={fields.usernameOrEmail.errors}
174-
/>
175-
</div>
176-
<ErrorList errors={form.errors} id={form.errorId} />
177-
178-
<div className="mt-6">
179-
<Button
180-
className="w-full"
181-
size="md"
182-
variant="primary"
183-
status={
184-
forgotPassword.state === 'submitting'
185-
? 'pending'
186-
: forgotPassword.data?.status ?? 'idle'
187-
}
188-
type="submit"
189-
disabled={forgotPassword.state !== 'idle'}
190-
>
191-
Recover password
192-
</Button>
193-
</div>
194-
</forgotPassword.Form>
195-
</>
196-
)}
176+
Recover password
177+
</Button>
178+
</div>
179+
</forgotPassword.Form>
197180
<Link to="/login" className="mt-11 text-center text-body-sm font-bold">
198181
Back to Login
199182
</Link>

0 commit comments

Comments
 (0)