Skip to content

feat: forgot password #1468

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Apr 24, 2025
31 changes: 26 additions & 5 deletions api/src/api/emailReset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,23 @@ import {
} from '@faims3/data-model';
import express, {Response} from 'express';
import {processRequest} from 'zod-express-middleware';
import {DEFAULT_REDIRECT_URL} from '../auth/authRoutes';
import {validateRedirect} from '../auth/helpers';
import {
buildCodeIntoUrl,
createNewEmailCode,
markCodeAsUsed,
validateEmailCode,
} from '../couchdb/emailCodes';
} from '../couchdb/emailReset';
import {
getCouchUserFromEmailOrUserId,
updateUserPassword,
} from '../couchdb/users';
import * as Exceptions from '../exceptions';
import {isAllowedToMiddleware, requireAuthenticationAPI} from '../middleware';
import {
buildPasswordResetUrl,
sendPasswordResetEmail,
} from '../utils/emailHelpers';

export const api = express.Router();

Expand Down Expand Up @@ -48,7 +53,12 @@ api.post(
},
}),
async (req, res: Response<PostRequestPasswordResetResponse>) => {
const {email} = req.body;
const {email, redirect} = req.body;

// Don't throw error here if invalid - just make a more sensible one
const {redirect: validatedRedirect} = validateRedirect(
redirect || DEFAULT_REDIRECT_URL
);

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

// Generate reset code
const {code} = await createNewEmailCode(user.user_id);
const url = buildCodeIntoUrl(code);
const {code, record} = await createNewEmailCode({userId: user.user_id});
const url = buildPasswordResetUrl({code, redirect: validatedRedirect});

// NOTE: we intentionally don't await this
// so that it is harder as an attacker to tell if something happened -
// this will complete in the background
sendPasswordResetEmail({
recipientEmail: email,
username: user.name || user.user_id,
resetCode: code,
expiryTimestampMs: record.expiryTimestampMs,
redirect: validatedRedirect,
});

res.json({
code,
Expand Down
111 changes: 110 additions & 1 deletion api/src/auth/authPages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,9 @@ import {
validateRedirect,
} from './helpers';

import patch from '../utils/patchExpressAsync';
import {verifyEmailWithCode} from '../api/verificationChallenges';
import patch from '../utils/patchExpressAsync';
import {validateEmailCode} from '../couchdb/emailReset';

// This must occur before express app is used
patch();
Expand Down Expand Up @@ -172,6 +173,44 @@ export function addAuthPages(app: Router, socialProviders: AuthProvider[]) {
);

/**
* PAGE: Change password form for local users
*/
app.get(
'/change-password',
processRequest({
query: z.object({
// Where should we go once finished?
redirect: z.string().optional(),
// Require username as query param - this lets us know who the user is
username: z.string(),
}),
}),
(req, res) => {
const username = req.query.username;

const {valid, redirect} = validateRedirect(
req.query.redirect || DEFAULT_REDIRECT_URL
);

if (!valid) {
return res.render('redirect-error', {redirect});
}

// Render the change password form
return res.render('change-password', {
// The POST endpoint to handle password change
postUrl: '/auth/changePassword',
changePasswordPostPayload: {
username,
redirect,
},
username,
messages: req.flash(),
});
}
);

/*
* PAGE: Email verification landing page
* This renders a view showing the result of the email verification process
*/
Expand Down Expand Up @@ -229,4 +268,74 @@ export function addAuthPages(app: Router, socialProviders: AuthProvider[]) {
});
}
);

/**
* PAGE: Forgot password form
* Allows the user to enter their email to receive a password reset link
*/
app.get(
'/forgot-password',
processRequest({
query: z.object({
redirect: z.string().optional(),
}),
}),
(req, res) => {
const {valid, redirect} = validateRedirect(
req.query.redirect || DEFAULT_REDIRECT_URL
);

if (!valid) {
return res.render('redirect-error', {redirect});
}

return res.render('forgot-password', {
postUrl: '/auth/forgotPassword',
forgotPasswordPostPayload: {
redirect,
},
messages: req.flash(),
});
}
);

/**
* PAGE: Reset password form
* Allows the user to set a new password using a reset code
*/
app.get(
'/auth/reset-password',
processRequest({
query: z.object({
code: z.string(),
redirect: z.string(),
}),
}),
async (req, res) => {
const code = req.query.code;
const {valid, redirect} = validateRedirect(req.query.redirect);

if (!valid) {
return res.render('redirect-error', {redirect});
}

// Validate the code
const validationResult = await validateEmailCode(code);

if (!validationResult.valid || !validationResult.user) {
return res.render('reset-password-error', {
error: validationResult.validationError || 'Invalid reset code.',
loginUrl: '/login',
forgotPasswordUrl: '/forgot-password',
});
}

return res.render('reset-password', {
postUrl: '/auth/resetPassword',
resetCode: code,
redirect,
messages: req.flash(),
});
}
);
}
Loading