Skip to content

Commit e288629

Browse files
authored
Fix verifyEmail to not delete guest contributors (#11308)
* fix: verifyEmail to not delete guest contributors Also, enforce profile completion for guests * refact: remove unused sendGuestConfirmationEmail mutation * fix: force legacy guests to complete profile * chore: type controllers/users * test: add signup legacy test compatibility test
1 parent 4593ae8 commit e288629

File tree

7 files changed

+122
-242
lines changed

7 files changed

+122
-242
lines changed

server/controllers/users.ts

Lines changed: 76 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,11 @@ const { User } = models;
3636
/**
3737
* Check existence of a user based on email
3838
*/
39-
export const exists = async (req, res) => {
40-
const email = req.query.email.toLowerCase();
39+
export const exists = async (req: express.Request, res: express.Response) => {
40+
const email = (req.query.email as string).toLowerCase();
4141
if (!isValidEmail(email)) {
42-
return res.send({ exists: false });
42+
res.send({ exists: false });
43+
return;
4344
} else {
4445
const rateLimit = new RateLimit(
4546
`user_email_search_ip_${req.ip}`,
@@ -55,7 +56,8 @@ export const exists = async (req, res) => {
5556
attributes: ['id'],
5657
where: { email },
5758
});
58-
return res.send({ exists: Boolean(user) });
59+
res.send({ exists: Boolean(user) });
60+
return;
5961
}
6062
};
6163

@@ -66,7 +68,7 @@ export const exists = async (req, res) => {
6668
* create a new account. In the future once signin.js is fully deprecated (replaced by signinV2.js)
6769
* this function should be refactored to remove createProfile.
6870
*/
69-
export const signin = async (req, res, next) => {
71+
export const signin = async (req: express.Request, res: express.Response, next: express.NextFunction) => {
7072
const { redirect, websiteUrl, sendLink, resetPassword, createProfile = true } = req.body;
7173
try {
7274
const rateLimit = new RateLimit(
@@ -76,40 +78,45 @@ export const signin = async (req, res, next) => {
7678
true,
7779
);
7880
if (!(await rateLimit.registerCall())) {
79-
return res.status(403).send({
81+
res.status(403).send({
8082
error: { message: 'Rate limit exceeded' },
8183
});
84+
return;
8285
}
8386
let user = await models.User.findOne({ where: { email: req.body.user.email.toLowerCase() } });
8487
if (!user && !createProfile) {
85-
return res.status(400).send({
88+
res.status(400).send({
8689
errorCode: 'EMAIL_DOES_NOT_EXIST',
8790
message: 'Email does not exist',
8891
});
92+
return;
8993
} else if (!user && createProfile) {
9094
user = await models.User.createUserWithCollective(req.body.user);
9195
} else if (!user.CollectiveId || user.data?.requiresVerification === true) {
92-
return res.status(403).send({
96+
res.status(403).send({
9397
errorCode: 'EMAIL_AWAITING_VERIFICATION',
9498
message: 'Email awaiting verification',
9599
});
100+
return;
96101
}
97102

98103
// If password set and not passed, challenge user with password
99104
if (user.passwordHash && !sendLink && !resetPassword) {
100105
if (!req.body.user.password) {
101-
return res.status(403).send({
106+
res.status(403).send({
102107
errorCode: 'PASSWORD_REQUIRED',
103108
message: 'Password requested to complete sign in.',
104109
});
110+
return;
105111
}
106112
const validPassword = await bcrypt.compare(req.body.user.password, user.passwordHash);
107113
if (!validPassword) {
108114
// Would be great to be consistent in the way we send errors
109115
// This is what works best with Frontend today
110-
return res.status(401).send({
116+
res.status(401).send({
111117
error: { errorCode: 'PASSWORD_INVALID', message: 'Invalid password' },
112118
});
119+
return;
113120
}
114121

115122
const twoFactorAuthenticationEnabled = parseToBoolean(config.twoFactorAuthentication.enabled);
@@ -131,12 +138,14 @@ export const signin = async (req, res, next) => {
131138
},
132139
auth.TOKEN_EXPIRATION_2FA,
133140
);
134-
return res.send({ token });
141+
res.send({ token });
142+
return;
135143
} else {
136144
// Context: this is token generation when using a password and no 2FA
137145
const token = await user.generateSessionToken({ createActivity: true, updateLastLoginAt: true, req });
138146
auth.setAuthCookie(res, token);
139-
return res.send({ token });
147+
res.send({ token });
148+
return;
140149
}
141150
}
142151

@@ -154,9 +163,10 @@ export const signin = async (req, res, next) => {
154163
);
155164
} catch (e) {
156165
reportErrorToSentry(e, { user });
157-
return res.status(500).send({
166+
res.status(500).send({
158167
error: { message: 'Error sending reset password email' },
159168
});
169+
return;
160170
}
161171
} else {
162172
const collective = await user.getCollective();
@@ -175,17 +185,18 @@ export const signin = async (req, res, next) => {
175185
);
176186
} catch (e) {
177187
reportErrorToSentry(e, { user });
178-
return res.status(500).send({
188+
res.status(500).send({
179189
error: { message: 'Error sending login email' },
180190
});
191+
return;
181192
}
182193

183194
// For e2e testing, we enable testuser+(admin|member)@opencollective.com to automatically receive the login link
184195
if (config.env !== 'production' && user.email.match(/.*test.*@opencollective.com$/)) {
185-
return res.send({ success: true, redirect: loginLink });
196+
res.send({ success: true, redirect: loginLink });
197+
return;
186198
}
187199
}
188-
189200
res.send({ success: true });
190201
} catch (e) {
191202
next(e);
@@ -263,14 +274,21 @@ export async function signup(req: express.Request, res: express.Response) {
263274
}
264275

265276
// Check if Users exists
266-
let user = await models.User.findOne({ where: { email: sanitizedEmail } });
277+
let user = await models.User.findOne({
278+
where: { email: sanitizedEmail },
279+
include: [{ model: models.Collective, as: 'collective' }],
280+
});
267281
if (user) {
268282
if (user.confirmedAt) {
269283
res.status(403).send({
270284
error: { message: 'User already exists', type: 'USER_ALREADY_EXISTS' },
271285
});
272286
return;
273287
}
288+
if (user.collective) {
289+
const newData = Object.assign({}, user.collective.data, { requiresProfileCompletion: true });
290+
await user.collective.update({ data: newData });
291+
}
274292
} else {
275293
user = await sequelize.transaction(async transaction => {
276294
const user = await models.User.create(
@@ -462,7 +480,13 @@ export async function verifyEmail(req: express.Request, res: express.Response) {
462480
{ confirmedAt: new Date(), data: omit(user.data, ['requiresVerification']) },
463481
{ transaction },
464482
);
465-
await user.collective.update({ data: omit(user.collective.data, ['isSuspended']) }, { transaction });
483+
if (user.collective) {
484+
let newData = omit(user.collective.data, ['isSuspended']);
485+
if (user.collective.data?.isGuest) {
486+
newData = { ...newData, isGuest: false, wasGuest: true, requiresProfileCompletion: true };
487+
}
488+
await user.collective.update({ data: newData }, { transaction });
489+
}
466490
});
467491
await sessionCache.delete(otpSessionKey);
468492
const token = await user.generateSessionToken({ createActivity: true, updateLastLoginAt: true, req });
@@ -473,7 +497,13 @@ export async function verifyEmail(req: express.Request, res: express.Response) {
473497
const tries = otpSession.tries + 1;
474498
if (tries >= 3) {
475499
await sessionCache.delete(otpSessionKey);
476-
await user.safeDestroy();
500+
if (user.collective) {
501+
const userHasTransactions = await user.collective.getTransactions({ limit: 1 });
502+
// Covers edge-case where the user has previously contributed as guest
503+
if (userHasTransactions.length === 0) {
504+
await user.safeDestroy();
505+
}
506+
}
477507
} else {
478508
await sessionCache.set(otpSessionKey, { ...otpSession, tries }, 15 * 60);
479509
}
@@ -510,25 +540,27 @@ export async function verifyEmail(req: express.Request, res: express.Response) {
510540
*
511541
* B) If no 2FA, we send back a "session" token
512542
*/
513-
export const exchangeLoginToken = async (req, res, next) => {
543+
export const exchangeLoginToken = async (req: express.Request, res: express.Response, next: express.NextFunction) => {
514544
const rateLimit = new RateLimit(
515545
`user_exchange_login_token_ip_${req.ip}`,
516546
config.limits.userExchangeLoginTokenPerHourPerIp,
517547
ONE_HOUR_IN_SECONDS,
518548
true,
519549
);
520550
if (!(await rateLimit.registerCall())) {
521-
return res.status(403).send({
551+
res.status(403).send({
522552
error: { message: 'Rate limit exceeded' },
523553
});
554+
return;
524555
}
525556

526557
// This is already checked in checkJwtScope but lets' make it clear
527558
if (req.jwtPayload?.scope !== 'login') {
528559
const errorMessage = `Cannot use this token on this route (scope: ${
529560
req.jwtPayload?.scope || 'session'
530561
}, expected: login)`;
531-
return next(new BadRequest(errorMessage));
562+
next(new BadRequest(errorMessage));
563+
return;
532564
}
533565

534566
// If a guest signs in, it's safe to directly confirm its account
@@ -572,33 +604,37 @@ export const exchangeLoginToken = async (req, res, next) => {
572604
/**
573605
* Exchange a session JWT against a fresh one with extended expiration
574606
*/
575-
export const refreshToken = async (req, res, next) => {
607+
export const refreshToken = async (req: express.Request, res: express.Response, next: express.NextFunction) => {
576608
const rateLimit = new RateLimit(
577609
`user_refresh_token_ip_${req.ip}`,
578610
config.limits.userRefreshTokenPerHourPerIp,
579611
ONE_HOUR_IN_SECONDS,
580612
true,
581613
);
582614
if (!(await rateLimit.registerCall())) {
583-
return res.status(403).send({
615+
res.status(403).send({
584616
error: { message: 'Rate limit exceeded' },
585617
});
618+
return;
586619
}
587620

588621
if (req.personalToken) {
589622
const errorMessage = `Cannot use this token on this route (personal token)`;
590-
return next(new BadRequest(errorMessage));
623+
next(new BadRequest(errorMessage));
624+
return;
591625
}
592626

593627
if (req.jwtPayload?.scope && req.jwtPayload?.scope !== 'session') {
594628
const errorMessage = `Cannot use this token on this route (scope: ${req.jwtPayload?.scope}, expected: session)`;
595-
return next(new BadRequest(errorMessage));
629+
next(new BadRequest(errorMessage));
630+
return;
596631
}
597632

598633
// TODO: not necessary once all oAuth tokens have the scope "oauth"
599634
if (req.jwtPayload?.access_token) {
600635
const errorMessage = `Cannot use this token on this route (oAuth access_token)`;
601-
return next(new BadRequest(errorMessage));
636+
next(new BadRequest(errorMessage));
637+
return;
602638
}
603639

604640
// Context: this is token generation when extending a session
@@ -615,12 +651,17 @@ export const refreshToken = async (req, res, next) => {
615651
/**
616652
* Verify the 2FA code or recovery code the user has entered when logging in and send back another JWT.
617653
*/
618-
export const twoFactorAuthAndUpdateToken = async (req, res, next) => {
654+
export const twoFactorAuthAndUpdateToken = async (
655+
req: express.Request,
656+
res: express.Response,
657+
next: express.NextFunction,
658+
) => {
619659
if (req.jwtPayload?.scope !== 'twofactorauth') {
620660
const errorMessage = `Cannot use this token on this route (scope: ${
621661
req.jwtPayload?.scope || 'session'
622662
}, expected: twofactorauth)`;
623-
return next(new BadRequest(errorMessage));
663+
next(new BadRequest(errorMessage));
664+
return;
624665
}
625666

626667
const { twoFactorAuthenticatorCode, twoFactorAuthenticationRecoveryCode, twoFactorAuthenticationType } = req.body;
@@ -635,7 +676,8 @@ export const twoFactorAuthAndUpdateToken = async (req, res, next) => {
635676
};
636677

637678
if (await rateLimit.hasReachedLimit()) {
638-
return next(new TooManyRequests('Too many attempts. Please try again in an hour'));
679+
next(new TooManyRequests('Too many attempts. Please try again in an hour'));
680+
return;
639681
}
640682

641683
const user = await User.findByPk(userId);
@@ -650,7 +692,8 @@ export const twoFactorAuthAndUpdateToken = async (req, res, next) => {
650692
twoFactorAuthenticationType ?? (twoFactorAuthenticatorCode ? TwoFactorMethod.TOTP : TwoFactorMethod.RECOVERY_CODE);
651693

652694
if (!code) {
653-
return fail(new BadRequest('This endpoint requires you to provide a 2FA code or a recovery code'));
695+
fail(new BadRequest('This endpoint requires you to provide a 2FA code or a recovery code'));
696+
return;
654697
}
655698

656699
try {
@@ -663,7 +706,8 @@ export const twoFactorAuthAndUpdateToken = async (req, res, next) => {
663706
req,
664707
);
665708
} catch {
666-
return fail(new Unauthorized('Two-factor authentication code failed. Please try again'));
709+
fail(new Unauthorized('Two-factor authentication code failed. Please try again'));
710+
return;
667711
}
668712

669713
// Context: this is token generation after signin and valid 2FA authentication

0 commit comments

Comments
 (0)