diff --git a/verification/curator-service/api/package-lock.json b/verification/curator-service/api/package-lock.json index 06e5f459f..a05cd9d77 100644 --- a/verification/curator-service/api/package-lock.json +++ b/verification/curator-service/api/package-lock.json @@ -22,6 +22,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", @@ -4188,6 +4189,17 @@ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.0.tgz", "integrity": "sha512-f66KywYG6+43afgE/8j/GoiNyygk/bnoCbps++3ErRKsIYkGGupyv07R2Ok5m9i67Iqc+T2g1eAUGUPzWhYTyg==" }, + "node_modules/express-rate-limit": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-6.5.1.tgz", + "integrity": "sha512-pxO6ioBLd3i8IHL+RmJtL4noYzte5fugoMdaDabtU4hcg53+x0QkTwfPtM7vWD0YUaXQgNj9NRdzmps+CHEHlA==", + "engines": { + "node": ">= 12.9.0" + }, + "peerDependencies": { + "express": "^4 || ^5" + } + }, "node_modules/express-session": { "version": "1.17.2", "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.17.2.tgz", @@ -13369,6 +13381,12 @@ } } }, + "express-rate-limit": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-6.5.1.tgz", + "integrity": "sha512-pxO6ioBLd3i8IHL+RmJtL4noYzte5fugoMdaDabtU4hcg53+x0QkTwfPtM7vWD0YUaXQgNj9NRdzmps+CHEHlA==", + "requires": {} + }, "express-session": { "version": "1.17.2", "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.17.2.tgz", diff --git a/verification/curator-service/api/package.json b/verification/curator-service/api/package.json index dc9efe34b..c3d5a6676 100644 --- a/verification/curator-service/api/package.json +++ b/verification/curator-service/api/package.json @@ -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", diff --git a/verification/curator-service/api/src/controllers/auth.ts b/verification/curator-service/api/src/controllers/auth.ts index e9eb3c201..528222493 100644 --- a/verification/curator-service/api/src/controllers/auth.ts +++ b/verification/curator-service/api/src/controllers/auth.ts @@ -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; @@ -192,6 +205,7 @@ export class AuthController { this.router.post( '/signup', + registerLimiter, (req: Request, res: Response, next: NextFunction): void => { passport.authenticate( 'register', @@ -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); }); @@ -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; @@ -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: { @@ -422,16 +471,41 @@ export class AuthController { */ this.router.post( '/request-password-reset', + forgotPasswordLimiter, async (req: Request, res: Response): Promise> => { 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 @@ -500,6 +574,7 @@ export class AuthController { */ this.router.post( '/reset-password', + resetPasswordWithTokenLimiter, async (req: Request, res: Response): Promise => { const userId = req.body.userId; const token = req.body.token as string; @@ -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', ); @@ -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', ); @@ -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', @@ -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); @@ -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', @@ -688,6 +795,8 @@ export class AuthController { _id: result.insertedId, })) as IUser; + setupFailedAttempts(result.insertedId); + // Send welcome email await this.emailClient.send( [email], @@ -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; @@ -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); @@ -781,6 +915,8 @@ export class AuthController { _id: result.insertedId, })) as IUser; + setupFailedAttempts(result.insertedId); + try { // Send welcome email await this.emailClient.send( @@ -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(), @@ -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); diff --git a/verification/curator-service/api/src/model/failed_attempts.ts b/verification/curator-service/api/src/model/failed_attempts.ts new file mode 100644 index 000000000..eb51851df --- /dev/null +++ b/verification/curator-service/api/src/model/failed_attempts.ts @@ -0,0 +1,130 @@ +import { Collection, ObjectId } from 'mongodb'; +import db from './database'; + +const numberTimeLimiters = { + loginAttempt: { + maxNumberOfFailedLogins: 8, + timeWindowForFailedLoginsMinutes: 60, + }, + resetPasswordAttempt: { + maxNumberOfFailedLogins: 8, + timeWindowForFailedLoginsMinutes: 60, + }, + forgotPasswordAttempt: { + maxNumberOfFailedLogins: 8, + timeWindowForFailedLoginsMinutes: 60, + }, + resetPasswordWithTokenAttempt: { + maxNumberOfFailedLogins: 8, + timeWindowForFailedLoginsMinutes: 60, + }, +}; + +export enum AttemptName { + Login = 'loginAttempt', + ResetPassword = 'resetPasswordAttempt', + ForgotPassword = 'forgotPasswordAttempt', + ResetPasswordWithToken = 'resetPasswordWithTokenAttempt', +} + +export interface IFailedAttempts { + _id: ObjectId; + userId: ObjectId; + loginAttempt: { + count: number; + createdAt: Date; + }; + resetPasswordAttempt: { + count: number; + createdAt: Date; + }; + forgotPasswordAttempt: { + count: number; + createdAt: Date; + }; + resetPasswordWithTokenAttempt: { + count: number; + createdAt: Date; + }; +} + +export const failedAttempts = () => + db().collection('failedAttempts') as Collection; + +export const setupFailedAttempts = async (userId: ObjectId) => { + await failedAttempts().insertOne({ + _id: new ObjectId(), + userId: userId, + loginAttempt: { + count: 0, + createdAt: new Date(), + }, + resetPasswordAttempt: { + count: 0, + createdAt: new Date(), + }, + forgotPasswordAttempt: { + count: 0, + createdAt: new Date(), + }, + resetPasswordWithTokenAttempt: { + count: 0, + createdAt: new Date(), + }, + }); +}; + +export const handleCheckFailedAttempts = async ( + userId: ObjectId, + attemptName: AttemptName, +) => { + let attempts = await failedAttempts().findOne({ + userId, + }); + + if (!attempts) { + setupFailedAttempts(userId); + attempts = (await failedAttempts().findOne({ + userId, + })) as IFailedAttempts; + } + + let attemptsNumber = attempts[attemptName].count + 1; + + const diffTimeMin = + Math.floor( + Math.abs(Date.now() - attempts[attemptName].createdAt.getTime()) / + 1000, + ) / 60; + + if ( + diffTimeMin >= + numberTimeLimiters[attemptName].timeWindowForFailedLoginsMinutes + ) + attemptsNumber = 1; + + return { + success: + attemptsNumber < + numberTimeLimiters[attemptName].maxNumberOfFailedLogins, + attemptsNumber, + }; +}; + +export const updateFailedAttempts = async ( + userId: ObjectId, + attemptName: AttemptName, + attemptsNumber: number, +) => { + await failedAttempts().updateOne( + { userId }, + { + $set: { + [attemptName]: { + count: attemptsNumber, + createdAt: new Date(), + }, + }, + }, + ); +}; diff --git a/verification/curator-service/api/src/util/single-window-rate-limiters.ts b/verification/curator-service/api/src/util/single-window-rate-limiters.ts new file mode 100644 index 000000000..f125a9744 --- /dev/null +++ b/verification/curator-service/api/src/util/single-window-rate-limiters.ts @@ -0,0 +1,65 @@ +import rateLimit from 'express-rate-limit'; + +export const loginLimiter = rateLimit({ + windowMs: 60 * 60 * 1000, // 60 minutes + max: 4, // Limit each IP to 4 requests per `window` (here, per 20 minutes) + standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers + legacyHeaders: false, // Disable the `X-RateLimit-*` headers + handler: function (req, res /*next*/) { + return res.status(429).json({ + message: 'Too many failed login attempts, please try again later', + }); + }, +}); + +export const registerLimiter = rateLimit({ + windowMs: 60 * 60 * 1000, // 60 minutes + max: 4, + standardHeaders: true, + legacyHeaders: false, + handler: function (req, res) { + return res.status(429).json({ + message: + 'You sent too many requests. Please wait a while then try again', + }); + }, +}); + +export const resetPasswordLimiter = rateLimit({ + windowMs: 60 * 60 * 1000, // 60 minutes + max: 4, + standardHeaders: true, + legacyHeaders: false, + handler: function (req, res) { + return res.status(429).json({ + message: + 'You sent too many requests. Please wait a while then try again', + }); + }, +}); + +export const forgotPasswordLimiter = rateLimit({ + windowMs: 60 * 60 * 1000, // 60 minutes + max: 4, + standardHeaders: true, + legacyHeaders: false, + handler: function (req, res) { + return res.status(429).json({ + message: + 'You sent too many requests. Please wait a while then try again', + }); + }, +}); + +export const resetPasswordWithTokenLimiter = rateLimit({ + windowMs: 60 * 60 * 1000, // 60 minutes + max: 4, + standardHeaders: true, + legacyHeaders: false, + handler: function (req, res) { + return res.status(429).json({ + message: + 'You sent too many requests. Please wait a while then try again', + }); + }, +}); diff --git a/verification/curator-service/ui/.gitignore b/verification/curator-service/ui/.gitignore index a7e808add..4997e7efe 100644 --- a/verification/curator-service/ui/.gitignore +++ b/verification/curator-service/ui/.gitignore @@ -1,6 +1,7 @@ # Env files .env .env.staging +.env.development # Don't include CSVs, except for Cypress fixtures. *.csv diff --git a/verification/curator-service/ui/cypress/integration/components/LandingPage.spec.ts b/verification/curator-service/ui/cypress/integration/components/LandingPage.spec.ts index 49563f3c1..266f06689 100644 --- a/verification/curator-service/ui/cypress/integration/components/LandingPage.spec.ts +++ b/verification/curator-service/ui/cypress/integration/components/LandingPage.spec.ts @@ -53,6 +53,18 @@ describe('LandingPage', function () { cy.get('#password').type('tT$5'); cy.get('button[data-testid="sign-up-button"]').click(); cy.contains(/Minimum 8 characters required/i); + + //check score 1 strength of password + cy.get('#password').focus().clear(); + cy.get('#password').type('Tt1ttttt'); + cy.get('button[data-testid="sign-up-button"]').click(); + cy.contains(/Password too weak/i); + + //check score 2 strength of password + cy.get('#password').focus().clear(); + cy.get('#password').type('tT$5aaaaa'); + cy.get('button[data-testid="sign-up-button"]').click(); + cy.contains(/Password too weak/i); }); it('Validates emails', function () { @@ -123,6 +135,18 @@ describe('LandingPage', function () { cy.get('#password').type('tT$5'); cy.get('button[data-testid="change-password-button"]').click(); cy.contains('Minimum 8 characters required!'); + + //check score 1 strength of password + cy.get('#password').focus().clear(); + cy.get('#password').type('Tt1ttttt'); + cy.get('button[data-testid="change-password-button"]').click(); + cy.contains('Password too weak'); + + //check score 2 strength of password + cy.get('#password').focus().clear(); + cy.get('#password').type('tT$5aaaaa'); + cy.get('button[data-testid="change-password-button"]').click(); + cy.contains('Password too weak'); }); it('Homepage with logged out user', function () { @@ -186,4 +210,27 @@ describe('LandingPage', function () { cy.contains('Manage users').should('not.exist'); cy.contains('Terms of use'); }); + + it('Limit number of requests to register and login ', function () { + cy.visit('/'); + cy.get('#email').type('test@example.com'); + cy.get('#confirmEmail').type('test@example.com'); + cy.get('#password').type('tT$5aaaaak'); + cy.get('#passwordConfirmation').type('tT$5aaaaak'); + cy.get('#isAgreementChecked').check(); + for (let i = 0; i < 5; i++) { + cy.get('button[data-testid="sign-up-button"]').click(); + } + cy.contains( + /You sent too many requests. Please wait a while then try again/i, + ); + + cy.contains('Sign in!').click(); + cy.get('#email').type('test@example.com'); + cy.get('#password').type('test'); + for (let i = 0; i < 5; i++) { + cy.get('button[data-testid="sign-in-button"]').click(); + } + cy.contains(/Too many failed login attempts, please try again later/i); + }); }); diff --git a/verification/curator-service/ui/cypress/integration/components/ProfileTest.spec.ts b/verification/curator-service/ui/cypress/integration/components/ProfileTest.spec.ts index 54e3f93ba..ae163958a 100644 --- a/verification/curator-service/ui/cypress/integration/components/ProfileTest.spec.ts +++ b/verification/curator-service/ui/cypress/integration/components/ProfileTest.spec.ts @@ -20,10 +20,12 @@ describe('Profile', function () { email: 'alice@test.com', roles: ['curator'], }); - cy.visit('/') + cy.visit('/'); cy.visit('/profile'); - cy.get('[data-testid="change-your-password-title"]').should('not.exist'); + cy.get('[data-testid="change-your-password-title"]').should( + 'not.exist', + ); }); it('Checks if the change pass form validation works well', function () { @@ -52,6 +54,18 @@ describe('Profile', function () { cy.get('#password').type('tT$5'); cy.get('button[data-testid="change-password-button"]').click(); cy.contains('Minimum 8 characters required!'); + + //check score 1 strength of password + cy.get('#password').focus().clear(); + cy.get('#password').type('Tt1ttttt'); + cy.get('button[data-testid="change-password-button"]').click(); + cy.contains('Password too weak'); + + //check score 2 strength of password + cy.get('#password').focus().clear(); + cy.get('#password').type('tT$5aaaaa'); + cy.get('button[data-testid="change-password-button"]').click(); + cy.contains('Password too weak'); }); it('Checks if the validates the repeated password', function () { diff --git a/verification/curator-service/ui/cypress/integration/components/UsersTest.spec.ts b/verification/curator-service/ui/cypress/integration/components/UsersTest.spec.ts index 15fe4cbe5..30c67b697 100644 --- a/verification/curator-service/ui/cypress/integration/components/UsersTest.spec.ts +++ b/verification/curator-service/ui/cypress/integration/components/UsersTest.spec.ts @@ -11,7 +11,7 @@ describe('Manage users page', function () { roles: ['curator'], }); cy.login({ name: 'Alice', email: 'alice@test.com', roles: ['admin'] }); - cy.visit('/') + cy.visit('/'); cy.visit('/users'); cy.contains('Alice'); @@ -33,8 +33,8 @@ describe('Manage users page', function () { roles: ['curator'], }); cy.login({ name: 'Alice', email: 'alice@test.com', roles: ['admin'] }); - cy.visit('/') - cy.visit('/users') + cy.visit('/'); + cy.visit('/users'); cy.contains('Bob'); cy.get('div[data-testid="Bob-select-roles"]').contains('curator'); cy.get('div[data-testid="Bob-select-roles"]') @@ -60,7 +60,7 @@ describe('Manage users page', function () { .should('not.exist'); // Roles are maintained on refresh - cy.visit('/') + cy.visit('/'); cy.visit('/users'); cy.get('div[data-testid="Bob-select-roles"]').contains('admin'); cy.get('div[data-testid="Bob-select-roles"]') @@ -70,7 +70,7 @@ describe('Manage users page', function () { it('Updated roles propagate to other pages', function () { cy.login({ name: 'Alice', email: 'alice@test.com', roles: ['admin'] }); - cy.visit('/') + cy.visit('/'); cy.visit('/users'); // Select new role @@ -85,7 +85,7 @@ describe('Manage users page', function () { cy.contains('Line list'); // Profile page is updated - cy.visit('/') + cy.visit('/'); cy.visit('/profile'); cy.contains('admin'); cy.contains('curator'); diff --git a/verification/curator-service/ui/package-lock.json b/verification/curator-service/ui/package-lock.json index 1fe517893..b9f235c81 100644 --- a/verification/curator-service/ui/package-lock.json +++ b/verification/curator-service/ui/package-lock.json @@ -36,6 +36,7 @@ "react-gtm-module": "^2.0.11", "react-helmet": "^6.1.0", "react-highlight-words": "^0.17.0", + "react-password-strength-bar": "^0.4.1", "react-redux": "^7.2.5", "react-router-dom": "^5.3.0", "react-router-last-location": "^2.0.1", @@ -23245,6 +23246,18 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" }, + "node_modules/react-password-strength-bar": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/react-password-strength-bar/-/react-password-strength-bar-0.4.1.tgz", + "integrity": "sha512-2NvYz4IUU8k7KDZgsXKoJWreKCZLKGaqF5QhIVhc09OsPBFXFMh0BeghNkBIRkaxLeI7/xjivknDCYfluBCXKA==", + "dependencies": { + "zxcvbn": "4.4.2" + }, + "peerDependencies": { + "react": ">=16.8.6", + "react-dom": ">=16.8.6" + } + }, "node_modules/react-redux": { "version": "7.2.6", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.6.tgz", @@ -29433,6 +29446,11 @@ "engines": { "node": ">=10" } + }, + "node_modules/zxcvbn": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/zxcvbn/-/zxcvbn-4.4.2.tgz", + "integrity": "sha512-Bq0B+ixT/DMyG8kgX2xWcI5jUvCwqrMxSFam7m0lAf78nf04hv6lNCsyLYdyYTrCVMqNDY/206K7eExYCeSyUQ==" } }, "dependencies": { @@ -47414,6 +47432,14 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" }, + "react-password-strength-bar": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/react-password-strength-bar/-/react-password-strength-bar-0.4.1.tgz", + "integrity": "sha512-2NvYz4IUU8k7KDZgsXKoJWreKCZLKGaqF5QhIVhc09OsPBFXFMh0BeghNkBIRkaxLeI7/xjivknDCYfluBCXKA==", + "requires": { + "zxcvbn": "4.4.2" + } + }, "react-redux": { "version": "7.2.6", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.6.tgz", @@ -52288,6 +52314,11 @@ "property-expr": "^2.0.4", "toposort": "^2.0.2" } + }, + "zxcvbn": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/zxcvbn/-/zxcvbn-4.4.2.tgz", + "integrity": "sha512-Bq0B+ixT/DMyG8kgX2xWcI5jUvCwqrMxSFam7m0lAf78nf04hv6lNCsyLYdyYTrCVMqNDY/206K7eExYCeSyUQ==" } } } diff --git a/verification/curator-service/ui/package.json b/verification/curator-service/ui/package.json index b5925371b..aab00f574 100644 --- a/verification/curator-service/ui/package.json +++ b/verification/curator-service/ui/package.json @@ -31,6 +31,7 @@ "react-gtm-module": "^2.0.11", "react-helmet": "^6.1.0", "react-highlight-words": "^0.17.0", + "react-password-strength-bar": "^0.4.1", "react-redux": "^7.2.5", "react-router-dom": "^5.3.0", "react-router-last-location": "^2.0.1", diff --git a/verification/curator-service/ui/src/components/Profile.test.tsx b/verification/curator-service/ui/src/components/Profile.test.tsx index 4df2f312e..d052e3b04 100644 --- a/verification/curator-service/ui/src/components/Profile.test.tsx +++ b/verification/curator-service/ui/src/components/Profile.test.tsx @@ -193,10 +193,10 @@ describe('', () => { render(, { initialState: noUserInfoState }); userEvent.type(screen.getByLabelText('Old Password'), '1234567'); - userEvent.type(screen.getByLabelText('New password'), 'asdD?234'); + userEvent.type(screen.getByLabelText('New password'), 'asdD?234df'); userEvent.type( screen.getByLabelText('Repeat new password'), - 'asdD?234', + 'asdD?234df', ); userEvent.click( @@ -225,10 +225,10 @@ describe('', () => { render(, { initialState: noUserInfoState }); userEvent.type(screen.getByLabelText('Old Password'), '1234567'); - userEvent.type(screen.getByLabelText('New password'), 'asdD?234'); + userEvent.type(screen.getByLabelText('New password'), 'asdD?234df'); userEvent.type( screen.getByLabelText('Repeat new password'), - 'asdD?234', + 'asdD?234df', ); userEvent.click( diff --git a/verification/curator-service/ui/src/components/Profile.tsx b/verification/curator-service/ui/src/components/Profile.tsx index 9000c5700..4d72a1736 100644 --- a/verification/curator-service/ui/src/components/Profile.tsx +++ b/verification/curator-service/ui/src/components/Profile.tsx @@ -33,6 +33,7 @@ import Alert from '@material-ui/lab/Alert'; import LinearProgress from '@material-ui/core/LinearProgress'; import { SnackbarAlert } from './SnackbarAlert'; import Helmet from 'react-helmet'; +import PasswordStrengthBar from 'react-password-strength-bar'; const styles = makeStyles((theme: Theme) => ({ root: { @@ -119,6 +120,7 @@ export function ChangePasswordFormInProfile(): JSX.Element { const classes = useStyles(); const dispatch = useAppDispatch(); + const [passwordStrength, setPasswordStrength] = useState(0); const [oldPasswordVisible, setOldPasswordVisible] = useState(false); const [passwordVisible, setPasswordVisible] = useState(false); const [passwordConfirmationVisible, setPasswordConfirmationVisible] = @@ -138,6 +140,7 @@ export function ChangePasswordFormInProfile(): JSX.Element { .matches(uppercaseRegex, 'one uppercase required!') .matches(numericRegex, 'one number required!') .min(8, 'Minimum 8 characters required!') + .required('Required!') .test( 'passwords-different', "New password can't be the same as old password", @@ -145,7 +148,9 @@ export function ChangePasswordFormInProfile(): JSX.Element { return this.parent.oldPassword !== value; }, ) - .required('Required!'), + .test('password-strong-enough', 'Password too weak', () => { + return passwordStrength > 2; + }), passwordConfirmation: Yup.string().test( 'passwords-match', 'Passwords must match', @@ -280,6 +285,14 @@ export function ChangePasswordFormInProfile(): JSX.Element { } label="New password" /> + { + setPasswordStrength(score); + }} + /> {formik.touched.password && formik.errors.password} diff --git a/verification/curator-service/ui/src/components/landing-page/ChangePasswordForm.tsx b/verification/curator-service/ui/src/components/landing-page/ChangePasswordForm.tsx index a3a5fbc35..3fe555844 100644 --- a/verification/curator-service/ui/src/components/landing-page/ChangePasswordForm.tsx +++ b/verification/curator-service/ui/src/components/landing-page/ChangePasswordForm.tsx @@ -18,6 +18,7 @@ import Visibility from '@material-ui/icons/Visibility'; import VisibilityOff from '@material-ui/icons/VisibilityOff'; import Button from '@material-ui/core/Button'; import Typography from '@material-ui/core/Typography'; +import PasswordStrengthBar from 'react-password-strength-bar'; const useStyles = makeStyles((theme: Theme) => ({ checkboxRoot: { @@ -88,6 +89,7 @@ export default function ChangePasswordForm({ const dispatch = useAppDispatch(); const history = useHistory(); + const [passwordStrength, setPasswordStrength] = useState(0); const passwordReset = useAppSelector(selectPasswordReset); const [passwordVisible, setPasswordVisible] = useState(false); const [passwordConfirmationVisible, setPasswordConfirmationVisible] = @@ -122,7 +124,10 @@ export default function ChangePasswordForm({ .matches(uppercaseRegex, 'one uppercase required!') .matches(numericRegex, 'one number required!') .min(8, 'Minimum 8 characters required!') - .required('Required!'), + .required('Required!') + .test('password-strong-enough', 'Password too weak', () => { + return passwordStrength > 2; + }), passwordConfirmation: Yup.string().test( 'passwords-match', 'Passwords must match', @@ -203,6 +208,14 @@ export default function ChangePasswordForm({ } label="Password" /> + { + setPasswordStrength(score); + }} + /> {formik.touched.password && formik.errors.password} diff --git a/verification/curator-service/ui/src/components/landing-page/SignUpForm.tsx b/verification/curator-service/ui/src/components/landing-page/SignUpForm.tsx index 2aa161b39..2a9d269f5 100644 --- a/verification/curator-service/ui/src/components/landing-page/SignUpForm.tsx +++ b/verification/curator-service/ui/src/components/landing-page/SignUpForm.tsx @@ -21,6 +21,7 @@ import Checkbox from '@material-ui/core/Checkbox'; import Typography from '@material-ui/core/Typography'; import GoogleButton from 'react-google-button'; import { sendCustomGtmEvent } from '../util/helperFunctions'; +import PasswordStrengthBar from 'react-password-strength-bar'; const useStyles = makeStyles((theme: Theme) => ({ checkboxRoot: { @@ -99,6 +100,7 @@ export default function SignUpForm({ const dispatch = useAppDispatch(); const [passwordVisible, setPasswordVisible] = useState(false); + const [passwordStrength, setPasswordStrength] = useState(0); const [passwordConfirmationVisible, setPasswordConfirmationVisible] = useState(false); @@ -128,7 +130,10 @@ export default function SignUpForm({ .matches(uppercaseRegex, 'One uppercase required') .matches(numericRegex, 'One number required') .min(8, 'Minimum 8 characters required') - .required('This field is required'), + .required('This field is required') + .test('password-strong-enough', 'Password too weak', () => { + return passwordStrength > 2; + }), passwordConfirmation: Yup.string().test( 'passwords-match', 'Passwords must match', @@ -191,6 +196,7 @@ export default function SignUpForm({ helperText={ formik.touched.email && formik.errors.email } + style={{ marginBottom: 17 }} /> + { + setPasswordStrength(score); + }} + /> {formik.touched.password && formik.errors.password}