Skip to content

Commit 0e8888d

Browse files
authored
feat: forgot password (#1468)
2 parents 14b681b + 0f55fb5 commit 0e8888d

21 files changed

+1279
-911
lines changed

api/src/api/emailReset.ts

+26-5
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,23 @@ import {
77
} from '@faims3/data-model';
88
import express, {Response} from 'express';
99
import {processRequest} from 'zod-express-middleware';
10+
import {DEFAULT_REDIRECT_URL} from '../auth/authRoutes';
11+
import {validateRedirect} from '../auth/helpers';
1012
import {
11-
buildCodeIntoUrl,
1213
createNewEmailCode,
1314
markCodeAsUsed,
1415
validateEmailCode,
15-
} from '../couchdb/emailCodes';
16+
} from '../couchdb/emailReset';
1617
import {
1718
getCouchUserFromEmailOrUserId,
1819
updateUserPassword,
1920
} from '../couchdb/users';
2021
import * as Exceptions from '../exceptions';
2122
import {isAllowedToMiddleware, requireAuthenticationAPI} from '../middleware';
23+
import {
24+
buildPasswordResetUrl,
25+
sendPasswordResetEmail,
26+
} from '../utils/emailHelpers';
2227

2328
export const api = express.Router();
2429

@@ -48,7 +53,12 @@ api.post(
4853
},
4954
}),
5055
async (req, res: Response<PostRequestPasswordResetResponse>) => {
51-
const {email} = req.body;
56+
const {email, redirect} = req.body;
57+
58+
// Don't throw error here if invalid - just make a more sensible one
59+
const {redirect: validatedRedirect} = validateRedirect(
60+
redirect || DEFAULT_REDIRECT_URL
61+
);
5262

5363
// Get the user by email
5464
const user = await getCouchUserFromEmailOrUserId(email);
@@ -59,8 +69,19 @@ api.post(
5969
}
6070

6171
// Generate reset code
62-
const {code} = await createNewEmailCode(user.user_id);
63-
const url = buildCodeIntoUrl(code);
72+
const {code, record} = await createNewEmailCode({userId: user.user_id});
73+
const url = buildPasswordResetUrl({code, redirect: validatedRedirect});
74+
75+
// NOTE: we intentionally don't await this
76+
// so that it is harder as an attacker to tell if something happened -
77+
// this will complete in the background
78+
sendPasswordResetEmail({
79+
recipientEmail: email,
80+
username: user.name || user.user_id,
81+
resetCode: code,
82+
expiryTimestampMs: record.expiryTimestampMs,
83+
redirect: validatedRedirect,
84+
});
6485

6586
res.json({
6687
code,

api/src/auth/authPages.ts

+72-1
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535

3636
import {verifyEmailWithCode} from '../api/verificationChallenges';
3737
import patch from '../utils/patchExpressAsync';
38+
import {validateEmailCode} from '../couchdb/emailReset';
3839

3940
// This must occur before express app is used
4041
patch();
@@ -198,7 +199,7 @@ export function addAuthPages(app: Router, socialProviders: AuthProvider[]) {
198199
// Render the change password form
199200
return res.render('change-password', {
200201
// The POST endpoint to handle password change
201-
postUrl: '/auth/change-password',
202+
postUrl: '/auth/changePassword',
202203
changePasswordPostPayload: {
203204
username,
204205
redirect,
@@ -267,4 +268,74 @@ export function addAuthPages(app: Router, socialProviders: AuthProvider[]) {
267268
});
268269
}
269270
);
271+
272+
/**
273+
* PAGE: Forgot password form
274+
* Allows the user to enter their email to receive a password reset link
275+
*/
276+
app.get(
277+
'/forgot-password',
278+
processRequest({
279+
query: z.object({
280+
redirect: z.string().optional(),
281+
}),
282+
}),
283+
(req, res) => {
284+
const {valid, redirect} = validateRedirect(
285+
req.query.redirect || DEFAULT_REDIRECT_URL
286+
);
287+
288+
if (!valid) {
289+
return res.render('redirect-error', {redirect});
290+
}
291+
292+
return res.render('forgot-password', {
293+
postUrl: '/auth/forgotPassword',
294+
forgotPasswordPostPayload: {
295+
redirect,
296+
},
297+
messages: req.flash(),
298+
});
299+
}
300+
);
301+
302+
/**
303+
* PAGE: Reset password form
304+
* Allows the user to set a new password using a reset code
305+
*/
306+
app.get(
307+
'/auth/reset-password',
308+
processRequest({
309+
query: z.object({
310+
code: z.string(),
311+
redirect: z.string(),
312+
}),
313+
}),
314+
async (req, res) => {
315+
const code = req.query.code;
316+
const {valid, redirect} = validateRedirect(req.query.redirect);
317+
318+
if (!valid) {
319+
return res.render('redirect-error', {redirect});
320+
}
321+
322+
// Validate the code
323+
const validationResult = await validateEmailCode(code);
324+
325+
if (!validationResult.valid || !validationResult.user) {
326+
return res.render('reset-password-error', {
327+
error: validationResult.validationError || 'Invalid reset code.',
328+
loginUrl: '/login',
329+
forgotPasswordUrl: '/forgot-password',
330+
});
331+
}
332+
333+
return res.render('reset-password', {
334+
postUrl: '/auth/resetPassword',
335+
resetCode: code,
336+
redirect,
337+
messages: req.flash(),
338+
});
339+
}
340+
);
270341
}

0 commit comments

Comments
 (0)