@@ -11,101 +11,93 @@ import { z } from 'zod'
11
11
import { GeneralErrorBoundary } from '~/components/error-boundary.tsx'
12
12
import { ErrorList , Field } from '~/components/forms.tsx'
13
13
import { StatusButton } from '~/components/ui/status-button.tsx'
14
+ import {
15
+ getRedirectToUrl ,
16
+ prepareVerification ,
17
+ } from '~/routes/resources+/verify.tsx'
14
18
import { prisma } from '~/utils/db.server.ts'
15
19
import { sendEmail } from '~/utils/email.server.ts'
16
- import { getDomainUrl } from '~/utils/misc.ts'
17
- import { generateTOTP } from '~/utils/totp.server.ts'
18
20
import { emailSchema , usernameSchema } from '~/utils/user-validation.ts'
19
21
import { ForgotPasswordEmail } from './email.server.tsx'
20
22
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 ( {
26
24
usernameOrEmail : z . union ( [ emailSchema , usernameSchema ] ) ,
27
25
} )
28
26
29
27
export async function action ( { request } : DataFunctionArgs ) {
30
28
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 ,
33
50
acceptMultipleErrors : ( ) => true ,
34
51
} )
35
52
if ( submission . intent !== 'submit' ) {
36
53
return json ( { status : 'idle' , submission } as const )
37
54
}
38
55
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 } )
46
57
}
47
58
const { usernameOrEmail } = submission . value
59
+ const redirectTo = getRedirectToUrl ( {
60
+ request,
61
+ type : 'forgot-password' ,
62
+ target : usernameOrEmail ,
63
+ } )
48
64
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
+ }
57
70
71
+ async function sendVerifyEmail ( {
72
+ request,
73
+ target,
74
+ } : {
75
+ request : Request
76
+ target : string
77
+ } ) {
58
78
const user = await prisma . user . findFirst ( {
59
- where : { OR : [ { email : usernameOrEmail } , { username : usernameOrEmail } ] } ,
79
+ where : { OR : [ { email : target } , { username : target } ] } ,
60
80
select : { email : true , username : true } ,
61
81
} )
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
106
85
}
107
86
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
+ } )
109
101
}
110
102
111
103
export const meta : V2_MetaFunction = ( ) => {
@@ -117,10 +109,10 @@ export default function ForgotPasswordRoute() {
117
109
118
110
const [ form , fields ] = useForm ( {
119
111
id : 'forgot-password-form' ,
120
- constraint : getFieldsetConstraint ( forgotPasswordSchema ) ,
112
+ constraint : getFieldsetConstraint ( ForgotPasswordSchema ) ,
121
113
lastSubmission : forgotPassword . data ?. submission ,
122
114
onValidate ( { formData } ) {
123
- return parse ( formData , { schema : forgotPasswordSchema } )
115
+ return parse ( formData , { schema : ForgotPasswordSchema } )
124
116
} ,
125
117
shouldRevalidate : 'onBlur' ,
126
118
} )
0 commit comments