@@ -11,50 +11,19 @@ import { z } from 'zod'
11
11
import { GeneralErrorBoundary } from '~/components/error-boundary.tsx'
12
12
import { prisma } from '~/utils/db.server.ts'
13
13
import { sendEmail } from '~/utils/email.server.ts'
14
- import { decrypt , encrypt } from '~/utils/encryption.server.ts'
15
14
import { Button , ErrorList , Field } from '~/utils/forms.tsx'
16
15
import { getDomainUrl } from '~/utils/misc.server.ts'
17
- import { commitSession , getSession } from '~/utils/session .server.ts'
16
+ import { generateTOTP } from '~/utils/totp .server.ts'
18
17
import { emailSchema , usernameSchema } from '~/utils/user-validation.ts'
19
18
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'
23
22
24
23
const forgotPasswordSchema = z . object ( {
25
24
usernameOrEmail : z . union ( [ emailSchema , usernameSchema ] ) ,
26
25
} )
27
26
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
-
58
27
export async function action ( { request } : DataFunctionArgs ) {
59
28
const formData = await request . formData ( )
60
29
const submission = parse ( formData , {
@@ -75,58 +44,84 @@ export async function action({ request }: DataFunctionArgs) {
75
44
}
76
45
const { usernameOrEmail } = submission . value
77
46
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
+
78
56
const user = await prisma . user . findFirst ( {
79
57
where : { OR : [ { email : usernameOrEmail } , { username : usernameOrEmail } ] } ,
80
58
select : { email : true , username : true } ,
81
59
} )
82
60
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
+ } )
104
88
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>
121
113
` ,
122
- } )
114
+ } )
115
+ }
116
+
117
+ return redirect ( redirectTo . pathname + redirectTo . search )
123
118
}
124
119
125
120
export const meta : V2_MetaFunction = ( ) => {
126
121
return [ { title : 'Password Recovery for Epic Notes' } ]
127
122
}
128
123
129
- export default function SignupRoute ( ) {
124
+ export default function ForgotPasswordRoute ( ) {
130
125
const forgotPassword = useFetcher < typeof action > ( )
131
126
132
127
const [ form , fields ] = useForm ( {
@@ -142,58 +137,46 @@ export default function SignupRoute() {
142
137
return (
143
138
< div className = "container mx-auto pb-32 pt-20" >
144
139
< 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
+ />
152
160
</ 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' }
165
175
>
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 >
197
180
< Link to = "/login" className = "mt-11 text-center text-body-sm font-bold" >
198
181
Back to Login
199
182
</ Link >
0 commit comments