Skip to content

Commit

Permalink
#2783 limit number of failed login and reset password requests (#2791)
Browse files Browse the repository at this point in the history
* login limiter done single window and brute forcing one user

* added rate limiters to registration and reset password request

* password strength bar

* added request limiters to 3 methods of changing password, password strength bar, test for login and registration

* changes after review, added limiters to 3 types of reseting password

* CR fixes

* test fix

* correcting spelling

* CR changes

Co-authored-by: Oskar Kocjan <oskarkocjan@Oskar-Kocjan.local>
  • Loading branch information
OskarKocjan and Oskar Kocjan committed Aug 16, 2022
1 parent 7a2832b commit aa0adec
Show file tree
Hide file tree
Showing 15 changed files with 518 additions and 25 deletions.
18 changes: 18 additions & 0 deletions verification/curator-service/api/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions verification/curator-service/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
"envalid": "^7.2.2",
"express": "^4.17.1",
"express-openapi-validator": "^4.9.0",
"express-rate-limit": "^6.5.1",
"express-session": "^1.17.1",
"express-winston": "^4.2.0",
"i18n-iso-countries": "^7.3.0",
Expand Down
165 changes: 155 additions & 10 deletions verification/curator-service/api/src/controllers/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,19 @@ import * as crypto from 'crypto';
import EmailClient from '../clients/email-client';
import { ObjectId } from 'mongodb';
import { baseURL, welcomeEmail } from '../util/instance-details';
import {
setupFailedAttempts,
handleCheckFailedAttempts,
AttemptName,
updateFailedAttempts,
} from '../model/failed_attempts';
import {
loginLimiter,
registerLimiter,
resetPasswordLimiter,
forgotPasswordLimiter,
resetPasswordWithTokenLimiter,
} from '../util/single-window-rate-limiters';

// Global variable for newsletter acceptance
let isNewsletterAccepted: boolean;
Expand Down Expand Up @@ -192,6 +205,7 @@ export class AuthController {

this.router.post(
'/signup',
registerLimiter,
(req: Request, res: Response, next: NextFunction): void => {
passport.authenticate(
'register',
Expand All @@ -214,16 +228,26 @@ export class AuthController {

this.router.post(
'/signin',
loginLimiter,
(req: Request, res: Response, next: NextFunction): void => {
passport.authenticate(
'login',
(error: Error, user: IUser, info: any) => {
(
error: Error,
user: IUser & { timeout: boolean },
info: any,
) => {
if (error) return next(error);
if (!user)
return res
.status(403)
.json({ message: info.message });

if (user.timeout)
return res
.status(429)
.json({ message: info.message });

req.logIn(user, (err) => {
if (err) return next(err);
});
Expand Down Expand Up @@ -371,6 +395,7 @@ export class AuthController {
*/
this.router.post(
'/change-password',
resetPasswordLimiter,
mustBeAuthenticated,
async (req: Request, res: Response) => {
const oldPassword = req.body.oldPassword as string;
Expand All @@ -390,17 +415,41 @@ export class AuthController {
return res.sendStatus(403);
}

const { success, attemptsNumber } =
await handleCheckFailedAttempts(
currentUser._id,
AttemptName.ResetPassword,
);

if (!success)
return res.status(429).json({
message:
'Too many failed login attempts, please try again later',
});

const isValidPassword = await isUserPasswordValid(
currentUser,
oldPassword,
);

if (!isValidPassword) {
updateFailedAttempts(
currentUser._id,
AttemptName.ResetPassword,
attemptsNumber,
);

return res
.status(403)
.json({ message: 'Old password is incorrect' });
}

updateFailedAttempts(
currentUser._id,
AttemptName.ResetPassword,
0,
);

const hashedPassword = await bcrypt.hash(newPassword, 10);
await users().updateOne(userQuery, {
$set: {
Expand All @@ -422,16 +471,41 @@ export class AuthController {
*/
this.router.post(
'/request-password-reset',
forgotPasswordLimiter,
async (req: Request, res: Response): Promise<Response<any>> => {
const email = req.body.email as string;

try {
// Check if user with this email address exists
const user = await users().findOne({ email });
const userPromise = await users()
.find({ email })
.collation({ locale: 'en_US', strength: 2 })
.toArray();

const user = userPromise[0] as IUser;
if (!user) {
return res.sendStatus(200);
}

const { success, attemptsNumber } =
await handleCheckFailedAttempts(
user._id,
AttemptName.ForgotPassword,
);

if (!success) {
return res.status(429).json({
message:
'You sent too many requests. Please wait a while then try again',
});
}

updateFailedAttempts(
user._id,
AttemptName.ForgotPassword,
attemptsNumber,
);

// Check if user is a Gmail user and send appropriate email message in that case
// 42 googleID was set for non Google accounts in the past just to pass mongoose validation
// so this check has to be made
Expand Down Expand Up @@ -500,6 +574,7 @@ export class AuthController {
*/
this.router.post(
'/reset-password',
resetPasswordWithTokenLimiter,
async (req: Request, res: Response): Promise<void> => {
const userId = req.body.userId;
const token = req.body.token as string;
Expand All @@ -512,11 +587,28 @@ export class AuthController {
throw new Error('Invalid user id');
}

const { success, attemptsNumber } =
await handleCheckFailedAttempts(
userId,
AttemptName.ResetPasswordWithToken,
);

if (!success)
res.status(429).json({
message:
'Too many failed login attempts, please try again later',
});

// Check if token exists
const passwordResetToken = await tokens().findOne({
userId,
});
if (!passwordResetToken) {
updateFailedAttempts(
userId,
AttemptName.ResetPasswordWithToken,
attemptsNumber,
);
throw new Error(
'Invalid or expired password reset token',
);
Expand All @@ -528,6 +620,11 @@ export class AuthController {
passwordResetToken.token,
);
if (!isValid) {
updateFailedAttempts(
userId,
AttemptName.ResetPasswordWithToken,
attemptsNumber,
);
throw new Error(
'Invalid or expired password reset token',
);
Expand All @@ -554,6 +651,12 @@ export class AuthController {
// Send confirmation email to the user
const user = result.value as IUser;

updateFailedAttempts(
userId,
AttemptName.ResetPasswordWithToken,
0,
);

await this.emailClient.send(
[user.email],
'Password Change Confirmation',
Expand Down Expand Up @@ -603,6 +706,9 @@ export class AuthController {
const user = (await users().findOne({
_id: result.insertedId,
})) as IUser;

setupFailedAttempts(result.insertedId);

req.login(user, (err: Error) => {
if (!err) {
res.json(user);
Expand Down Expand Up @@ -660,12 +766,13 @@ export class AuthController {
},
async (req, email, password, done) => {
try {
const userPromise = await users().find({ email })
.collation({ locale: 'en_US', strength: 2 })
.toArray();
const userPromise = await users()
.find({ email })
.collation({ locale: 'en_US', strength: 2 })
.toArray();

const user = userPromise[0];

if (user) {
return done(null, false, {
message: 'Email address already exists',
Expand All @@ -688,6 +795,8 @@ export class AuthController {
_id: result.insertedId,
})) as IUser;

setupFailedAttempts(result.insertedId);

// Send welcome email
await this.emailClient.send(
[email],
Expand All @@ -712,9 +821,10 @@ export class AuthController {
},
async (email, password, done) => {
try {
const userPromise = await users().find({ email })
.collation({ locale: 'en_US', strength: 2 })
.toArray();
const userPromise = await users()
.find({ email })
.collation({ locale: 'en_US', strength: 2 })
.toArray();

const user = userPromise[0] as IUser;

Expand All @@ -724,16 +834,40 @@ export class AuthController {
});
}

const { success, attemptsNumber } =
await handleCheckFailedAttempts(
user._id,
AttemptName.Login,
);

if (!success)
return done(
null,
{ timeout: true },
{
message:
'Too many failed login attempts, please try again later',
},
);

const isValidPassword = await isUserPasswordValid(
user,
password,
);

if (!isValidPassword) {
updateFailedAttempts(
user._id,
AttemptName.Login,
attemptsNumber,
);

return done(null, false, {
message: 'Wrong username or password',
});
}

updateFailedAttempts(user._id, AttemptName.Login, 0);
done(null, user);
} catch (error) {
done(error);
Expand Down Expand Up @@ -781,6 +915,8 @@ export class AuthController {
_id: result.insertedId,
})) as IUser;

setupFailedAttempts(result.insertedId);

try {
// Send welcome email
await this.emailClient.send(
Expand Down Expand Up @@ -865,7 +1001,13 @@ export class AuthController {
'Supplied bearer token must be scoped for "email"',
);
}
let user = await users().findOne({ email: email });
const userPromise = await users()
.find({ email })
.collation({ locale: 'en_US', strength: 2 })
.toArray();

let user = userPromise[0] as IUser | null;

if (!user) {
const result = await users().insertOne({
_id: new ObjectId(),
Expand All @@ -878,7 +1020,10 @@ export class AuthController {
user = await users().findOne({
_id: result.insertedId,
});

setupFailedAttempts(result.insertedId);
}

return done(null, user);
} catch (e) {
return done(e);
Expand Down
Loading

0 comments on commit aa0adec

Please sign in to comment.