Skip to content

Commit

Permalink
Merge pull request #6466 from NMDSdevopsServiceAdm/feature-branch/for…
Browse files Browse the repository at this point in the history
…gotten-username

Feature branch/forgotten username
  • Loading branch information
kapppa-joe authored Jan 7, 2025
2 parents a30643b + 9fc243f commit 3cfd4ef
Show file tree
Hide file tree
Showing 60 changed files with 3,082 additions and 118 deletions.
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

0 comments on commit 3cfd4ef

Please sign in to comment.