Skip to content
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

Feature branch/forgotten username #6466

Merged
merged 100 commits into from
Jan 7, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
100 commits
Select commit Hold shift + click to select a range
b7a5c85
add forgot-your-username-or-password component
kapppa-joe Dec 3, 2024
0c4156b
start adding validation
kapppa-joe Dec 3, 2024
36fee5f
add unit tests for navigation
kapppa-joe Dec 4, 2024
f889982
adjust css
kapppa-joe Dec 4, 2024
f92cbb0
amend forgot-your-password component
kapppa-joe Dec 5, 2024
83a9d76
amend error message retrieval
kapppa-joe Dec 5, 2024
d97fd6a
adjust css
kapppa-joe Dec 5, 2024
127aefd
adjust css
kapppa-joe Dec 5, 2024
fb4f238
fix an issue about unit test config
kapppa-joe Dec 5, 2024
0b53bcb
sort import
kapppa-joe Dec 5, 2024
dc1c277
update test content and css
kapppa-joe Dec 5, 2024
65be38a
update confirmation screen message
kapppa-joe Dec 6, 2024
f0c4baa
add nowrap class to tel number
kapppa-joe Dec 6, 2024
c5fc166
Add validation check for at signs in username
ssrome Dec 6, 2024
53c8e24
Add password toggle and showadditional error message
ssrome Dec 6, 2024
d38ad7f
Merge pull request #6443 from NMDSdevopsServiceAdm/feat/1569-new-page…
kapppa-joe Dec 6, 2024
effa545
Merge branch 'feature-branch/forgotten-username' into feat/1567-forgo…
ssrome Dec 6, 2024
d546629
Create new page to display username
ssrome Dec 9, 2024
fed95ed
Remove unused regex
ssrome Dec 9, 2024
545e7c9
Update test names
ssrome Dec 9, 2024
cfc3af2
Merge pull request #6446 from NMDSdevopsServiceAdm/feat/1567-forgotte…
ssrome Dec 9, 2024
9e946b6
Add panel to display
ssrome Dec 10, 2024
e219b66
amend folder structure, add forgot-your-username component
kapppa-joe Dec 6, 2024
d5372fb
add find-account child component
kapppa-joe Dec 6, 2024
aea6c7b
implement submit and validation logic for findAccount form
kapppa-joe Dec 9, 2024
c5fa77d
amend the handling of findUserAccount response
kapppa-joe Dec 10, 2024
dde9a60
implement POST: findUserAccount endpoint
kapppa-joe Dec 10, 2024
1e32365
fix .padEnd call at backend
kapppa-joe Dec 10, 2024
d61921c
emit account uid & question to parent component, add scrolling
kapppa-joe Dec 10, 2024
870438c
add aria-live to result div for accessibility
kapppa-joe Dec 10, 2024
5a07605
add email format validation to form, remove unused scss files
kapppa-joe Dec 11, 2024
0ea5a79
cleanup, minor fix
kapppa-joe Dec 11, 2024
592f4ef
amend where clause for finding user
kapppa-joe Dec 11, 2024
2ae6451
minor fix at backend
kapppa-joe Dec 12, 2024
786cc01
display security question in find username component
kapppa-joe Dec 12, 2024
5981584
style and content adjustment
kapppa-joe Dec 12, 2024
65f2d77
add test for FindUsernameService, remove fdescribe
kapppa-joe Dec 12, 2024
b3b9245
minor refactor at backend, add a unit test
kapppa-joe Dec 12, 2024
c7a1898
minor fix, remove describe.only
kapppa-joe Dec 12, 2024
4fcfbd0
address PR comment (rename method validateRequest --> requestIsInvali…
kapppa-joe Dec 12, 2024
be346e1
Merge pull request #6453 from NMDSdevopsServiceAdm/feat/1572-forgot-u…
kapppa-joe Dec 12, 2024
8a033b8
add green tick icon beside "Account found" text
kapppa-joe Dec 12, 2024
e485d9c
implement Find username form
kapppa-joe Dec 12, 2024
e42aa4e
adding validation to find username
kapppa-joe Dec 12, 2024
ba19e76
css fix
kapppa-joe Dec 12, 2024
227bac4
add call to findUsername endpoint
kapppa-joe Dec 13, 2024
e72152e
display error message on wrong answer
kapppa-joe Dec 13, 2024
64387cb
implement finduser endpoint
kapppa-joe Dec 13, 2024
8fecd3e
css adjustment
kapppa-joe Dec 13, 2024
4f1c584
remove subscription on destroy
kapppa-joe Dec 13, 2024
ea67481
fix css style for the case of very long question
kapppa-joe Dec 13, 2024
5de7a2d
adjust page scrolling behaviour on account found
kapppa-joe Dec 16, 2024
fa2257b
add a fullstop, remove fdescribe
kapppa-joe Dec 16, 2024
6f77467
Merge pull request #6456 from NMDSdevopsServiceAdm/feat/1568-forgot-u…
kapppa-joe Dec 16, 2024
bf0b324
Merge branch 'feature-branch/forgotten-username' into feat/1571-forgo…
ssrome Dec 18, 2024
a2d2d6d
Rename and move component
ssrome Dec 18, 2024
3efe1cd
Remove commented code
ssrome Dec 18, 2024
45be738
Create component for page no longer available
ssrome Dec 19, 2024
6020fab
Addressing pr comments
ssrome Dec 19, 2024
8b247d3
Merge pull request #6450 from NMDSdevopsServiceAdm/feat/1571-forgotte…
ssrome Dec 19, 2024
1bff231
Merge branch 'main' into feature-branch/forgotten-username
ssrome Dec 20, 2024
2c75dee
add a ForgotUsernameAttempts column, update finduser endpoint to limi…
kapppa-joe Dec 16, 2024
c3614dc
start implementing failure page
kapppa-joe Dec 16, 2024
e997412
finish security-question-answer-not-match page
kapppa-joe Dec 17, 2024
546cad0
add a user-account-not-found page
kapppa-joe Dec 17, 2024
66ce117
add logic to limit find user account attempts
kapppa-joe Dec 17, 2024
98ebd5e
rename file
kapppa-joe Dec 17, 2024
40f0556
amend unit test description
kapppa-joe Dec 17, 2024
2bed8e5
rename variable for clarity
kapppa-joe Dec 17, 2024
9c052a9
adjust css
kapppa-joe Dec 17, 2024
fee4dce
implement limit for findUserAccount
kapppa-joe Dec 18, 2024
ae66d97
lock user account when forgot username reach maximum attempts
kapppa-joe Dec 18, 2024
6c065ae
share the same lock status across findUsername & failed login, move n…
kapppa-joe Dec 18, 2024
7a2071d
minor fix, replace hardcoded string "Locked" with shared constant
kapppa-joe Dec 18, 2024
6eeca73
refactor findUsername
kapppa-joe Dec 19, 2024
bdcb067
fix incorrect field name
kapppa-joe Dec 19, 2024
325b030
minor amend
kapppa-joe Dec 19, 2024
4ec1210
use 429 Too many request instead of 423 Locked when findAccount reach…
kapppa-joe Dec 20, 2024
c6cc5cc
modifying findUsername service to handle the case when account is locked
kapppa-joe Dec 20, 2024
afcf9d9
amend findUserAccount endpoint to represent different cases by status…
kapppa-joe Dec 20, 2024
17a8db2
handle the case of AccountLocked at findAccount
kapppa-joe Dec 20, 2024
2613401
remove f from describe block
kapppa-joe Dec 20, 2024
ec67ad7
adjust css
kapppa-joe Dec 23, 2024
938d315
update heading texts
kapppa-joe Dec 23, 2024
d98ddf9
update error message text
kapppa-joe Dec 23, 2024
3017bfd
text updates
kapppa-joe Dec 23, 2024
c451a29
change the error message for account locked case as non-clickable text
kapppa-joe Dec 23, 2024
1444159
css adjustment (no underline for account locked error message)
kapppa-joe Dec 23, 2024
4b7ee0c
replace hardcoded value with constant
kapppa-joe Dec 24, 2024
eb32a34
fix unit test
kapppa-joe Dec 24, 2024
1b420bc
Merge pull request #6459 from NMDSdevopsServiceAdm/feat/1570-forgot-u…
kapppa-joe Jan 2, 2025
3b42e8b
try fix redis client config for find user account attempts
kapppa-joe Jan 2, 2025
550e46c
Merge pull request #6460 from NMDSdevopsServiceAdm/fix/redis-config-f…
kapppa-joe Jan 2, 2025
36a8c77
on user account not found, give a 200 response with status: AccountNo…
kapppa-joe Jan 2, 2025
e0fbf6a
Merge pull request #6461 from NMDSdevopsServiceAdm/fix/use-200-instea…
kapppa-joe Jan 2, 2025
8990db2
handle the case for multiple accounts found in FindUserAccount
kapppa-joe Jan 3, 2025
46b9315
minor amend
kapppa-joe Jan 3, 2025
b9dc866
Merge pull request #6463 from NMDSdevopsServiceAdm/fix/1588-multiple-…
kapppa-joe Jan 3, 2025
a5794c8
when error message of invalid username/password is clicked, set focus…
kapppa-joe Jan 7, 2025
9fc243f
Merge pull request #6465 from NMDSdevopsServiceAdm/fix/login-page-set…
kapppa-joe Jan 7, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
'use strict';

const table = {
tableName: 'Login',
schema: 'cqc',
};

/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
return queryInterface.addColumn(table, 'InvalidFindUsernameAttempts', { type: Sequelize.DataTypes.INTEGER });
},

async down(queryInterface) {
return queryInterface.removeColumn(table, 'InvalidFindUsernameAttempts');
},
};
9 changes: 9 additions & 0 deletions backend/server/data/constants.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
const UserAccountStatus = {
Locked: 'Locked',
Pending: 'PENDING',
};

const MaxLoginAttempts = 10;
const MaxFindUsernameAttempts = 5;

module.exports = { UserAccountStatus, MaxLoginAttempts, MaxFindUsernameAttempts };
48 changes: 48 additions & 0 deletions backend/server/models/login.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/* jshint indent: 2 */
var bcrypt = require('bcrypt-nodejs');
const { MaxLoginAttempts, MaxFindUsernameAttempts, UserAccountStatus } = require('../data/constants');

module.exports = function (sequelize, DataTypes) {
const Login = sequelize.define(
Expand Down Expand Up @@ -39,6 +40,11 @@ module.exports = function (sequelize, DataTypes) {
allowNull: false,
field: '"InvalidAttempt"',
},
invalidFindUsernameAttempts: {
type: DataTypes.INTEGER,
allowNull: true,
field: '"InvalidFindUsernameAttempts"',
},
firstLogin: {
type: DataTypes.DATE,
allowNull: true,
Expand Down Expand Up @@ -132,5 +138,47 @@ module.exports = function (sequelize, DataTypes) {
});
};

Login.prototype.recordInvalidFindUsernameAttempts = async function (transaction) {
const loginAccount = await Login.findByPk(this.id);

const previousAttempts = loginAccount.invalidFindUsernameAttempts ?? 0;
const updatedFields = {
invalidFindUsernameAttempts: previousAttempts + 1,
};
const options = transaction ? { transaction } : {};

return this.update(updatedFields, options);
};

Login.prototype.lockAccount = async function (transaction) {
const updatedFields = {
isActive: false,
status: UserAccountStatus.Locked,
};

const options = transaction ? { transaction } : {};

return this.update(updatedFields, options);
};

Login.prototype.unlockAccount = async function (transaction) {
const loginAccount = await Login.findByPk(this.id);
const updatedFields = {
isActive: true,
status: null,
};
const options = transaction ? { transaction } : {};

if (loginAccount.invalidAttempt >= MaxLoginAttempts) {
updatedFields.invalidAttempt = MaxLoginAttempts - 1;
}

if (loginAccount.invalidFindUsernameAttempts >= MaxFindUsernameAttempts) {
updatedFields.invalidFindUsernameAttempts = MaxFindUsernameAttempts - 1;
}

return this.update(updatedFields, options);
};

return Login;
};
56 changes: 56 additions & 0 deletions backend/server/models/user.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
/* jshint indent: 2 */
const { Op } = require('sequelize');
const { padEnd } = require('lodash');
const { sanitise } = require('../utils/db');
const { UserAccountStatus } = require('../data/constants');

module.exports = function (sequelize, DataTypes) {
const User = sequelize.define(
Expand Down Expand Up @@ -486,5 +488,59 @@ module.exports = function (sequelize, DataTypes) {
});
};

User.findByRelevantInfo = async function ({ name, workplaceId, postcode, email }) {
if (!workplaceId && !postcode) {
return null;
}

const workplaceIdWithTrailingSpace = padEnd(workplaceId ?? '', 8, ' ');
const establishmentWhereClause = workplaceId
? { NmdsID: [workplaceId, workplaceIdWithTrailingSpace] }
: { postcode: postcode };

const query = {
attributes: ['uid', 'SecurityQuestionValue'],
where: {
Archived: false,
FullNameValue: name,
EmailValue: email,
SecurityQuestionValue: { [Op.ne]: null },
SecurityQuestionAnswerValue: { [Op.ne]: null },
},
include: [
{
model: sequelize.models.establishment,
where: establishmentWhereClause,
required: true,
attributes: [],
},
{
model: sequelize.models.login,
required: true,
attributes: ['status'],
},
],
raw: true,
};

const allFoundUsers = await this.findAll(query);

if (!allFoundUsers?.length || allFoundUsers.length === 0) {
return null;
}

if (allFoundUsers.length > 1) {
return { multipleAccountsFound: true };
}

const userFound = allFoundUsers[0];

if (userFound && userFound['login.status'] === UserAccountStatus.Locked) {
return { accountLocked: true };
}

return userFound;
};

return User;
};
6 changes: 1 addition & 5 deletions backend/server/routes/admin/unlock-account/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,7 @@ const unlockAccount = async (req, res) => {
// Make sure we have the matching user
if (login && login.id && username === login.username) {
try {
const updateduser = await login.update({
isActive: true,
invalidAttempt: 9,
status: null,
});
const updateduser = await login.unlockAccount();
if (updateduser) {
res.status(200);
return res.json({ status: '0', message: 'User has been set as active' });
Expand Down
15 changes: 7 additions & 8 deletions backend/server/routes/login.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const { adminRoles } = require('../utils/adminUtils');
const sendMail = require('../utils/email/notify-email').sendPasswordReset;

const { authLimiter } = require('../utils/middleware/rateLimiting');
const { MaxLoginAttempts, UserAccountStatus } = require('../data/constants');

const tribalHashCompare = (password, salt, expectedHash) => {
const hash = crypto.createHash('sha256');
Expand Down Expand Up @@ -197,13 +198,13 @@ router.post('/', async (req, res) => {

if (establishmentUser) {
//check weather posted user is locked or pending
if (!establishmentUser.isActive && establishmentUser.status === 'Locked') {
if (!establishmentUser.isActive && establishmentUser.status === UserAccountStatus.Locked) {
//check for locked status, if locked then return with 409 error
console.error('POST .../login failed: User status is locked');
return res.status(409).send({
message: 'Authentication failed.',
});
} else if (!establishmentUser.isActive && establishmentUser.status === 'PENDING') {
} else if (!establishmentUser.isActive && establishmentUser.status === UserAccountStatus.Pending) {
//check for Pending status, if Pending then return with 403 error
console.error('POST .../login failed: User status is pending');
return res.status(405).send({
Expand Down Expand Up @@ -290,6 +291,7 @@ router.post('/', async (req, res) => {
// reset the number of failed attempts on any successful login
const loginUpdate = {
invalidAttempt: 0,
invalidFindUsernameAttempts: 0,
lastLogin: new Date(),
};

Expand Down Expand Up @@ -353,19 +355,17 @@ router.post('/', async (req, res) => {
.json(response);
} else {
await models.sequelize.transaction(async (t) => {
const maxNumberOfFailedAttempts = 10;

// increment the number of failed attempts by one
const loginUpdate = {
invalidAttempt: establishmentUser.invalidAttempt + 1,
};
await establishmentUser.update(loginUpdate, { transaction: t });

if (establishmentUser.invalidAttempt === maxNumberOfFailedAttempts + 1) {
if (establishmentUser.invalidAttempt === MaxLoginAttempts + 1) {
// lock the account
const loginUpdate = {
isActive: false,
status: 'Locked',
status: UserAccountStatus.Locked,
};
await establishmentUser.update(loginUpdate, { transaction: t });

Expand Down Expand Up @@ -404,8 +404,7 @@ router.post('/', async (req, res) => {
const auditEvent = {
userFk: establishmentUser.user.id,
username: establishmentUser.username,
type:
establishmentUser.invalidAttempt >= maxNumberOfFailedAttempts + 1 ? 'loginWhileLocked' : 'loginFailed',
type: establishmentUser.invalidAttempt >= MaxLoginAttempts + 1 ? 'loginWhileLocked' : 'loginFailed',
property: 'password',
event: {},
};
Expand Down
7 changes: 6 additions & 1 deletion backend/server/routes/registration.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
const express = require('express');
const router = express.Router();
const { v4: uuidv4 } = require('uuid');
uuidv4();
const isLocal = require('../utils/security/isLocalTest').isLocal;
const { registerAccount } = require('./registration/registerAccount');
const models = require('../models');

const generateJWT = require('../utils/security/generateJWT');
const sendMail = require('../utils/email/notify-email').sendPasswordReset;
const { authLimiter } = require('../utils/middleware/rateLimiting');
const { findUserAccount } = require('./registration/findUserAccount');
const { findUsername } = require('./registration/findUsername');

router.use('/establishmentExistsCheck', require('./registration/establishmentExistsCheck'));

Expand Down Expand Up @@ -368,4 +369,8 @@ router.post('/validateResetPassword', async (req, res) => {
}
});

router.post('/findUserAccount', findUserAccount);

router.post('/findUsername', findUsername);

module.exports = router;
97 changes: 97 additions & 0 deletions backend/server/routes/registration/findUserAccount.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
const { isEmpty } = require('lodash');
const { sanitisePostcode } = require('../../utils/postcodeSanitizer');
const limitFindUserAccountUtils = require('../../utils/limitFindUserAccountUtils');
const HttpError = require('../../utils/errors/httpError');
const models = require('../../models/index');
const { MaxFindUsernameAttempts } = require('../../data/constants');

class FindUserAccountException extends HttpError {}

const findUserAccount = async (req, res) => {
try {
await validateRequest(req);

const { name, workplaceIdOrPostcode, email } = req.body;
let userFound = null;

const postcode = sanitisePostcode(workplaceIdOrPostcode);
if (postcode) {
userFound = await models.user.findByRelevantInfo({ name, postcode, email });
}

userFound =
userFound ?? (await models.user.findByRelevantInfo({ name, workplaceId: workplaceIdOrPostcode, email }));

if (!userFound) {
const failedAttemptsCount = await limitFindUserAccountUtils.recordFailedAttempt(req.ip);
const remainingAttempts = MaxFindUsernameAttempts - failedAttemptsCount;

return sendNotFoundResponse(res, remainingAttempts);
}

if (userFound.multipleAccountsFound) {
return sendMultipleAccountsFoundResponse(res);
}

if (userFound.accountLocked) {
throw new FindUserAccountException('User account is locked', 423);
}

return sendSuccessResponse(res, userFound);
} catch (err) {
return sendErrorResponse(res, err);
}
};

const validateRequest = async (req) => {
if (requestBodyIsInvalid(req)) {
throw new FindUserAccountException('Invalid request', 400);
}

if (await ipAddressReachedMaxAttempt(req)) {
throw new FindUserAccountException('Reached maximum retry', 429);
}
};

const requestBodyIsInvalid = (req) => {
if (!req.body) {
return true;
}
const { name, workplaceIdOrPostcode, email } = req.body;

return [name, workplaceIdOrPostcode, email].some((field) => isEmpty(field));
};

const ipAddressReachedMaxAttempt = async (req) => {
const attemptsSoFar = (await limitFindUserAccountUtils.getNumberOfFailedAttempts(req.ip)) ?? 0;
return attemptsSoFar >= MaxFindUsernameAttempts;
};

const sendSuccessResponse = (res, userFound) => {
const { uid, SecurityQuestionValue } = userFound;
return res.status(200).json({
status: 'AccountFound',
accountUid: uid,
securityQuestion: SecurityQuestionValue,
});
};

const sendNotFoundResponse = (res, remainingAttempts = 0) => {
return res.status(200).json({ status: 'AccountNotFound', remainingAttempts });
};

const sendMultipleAccountsFoundResponse = (res) => {
return res.status(200).json({ status: 'MultipleAccountsFound' });
};

const sendErrorResponse = (res, err) => {
console.error('registration POST findUserAccount - failed', err);

if (err instanceof FindUserAccountException) {
return res.status(err.statusCode).json({ message: err.message });
}

return res.status(500).send('Internal server error');
};

module.exports = { findUserAccount, FindUserAccountException };
Loading
Loading