Skip to content

Commit

Permalink
feat(2fa): implementing backend code for recovery codes
Browse files Browse the repository at this point in the history
  • Loading branch information
lspaulucio committed Sep 13, 2024
1 parent 4e71064 commit 9f02ecf
Show file tree
Hide file tree
Showing 9 changed files with 253 additions and 4 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
exports.up = (pgm) => {
pgm.addColumns('users', {
totp_recovery_codes: {
type: 'varchar(416)',
notNull: false,
},
});
};

exports.down = false;
1 change: 1 addition & 0 deletions models/authorization.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ function filterInput(user, feature, input, target) {
email: input.email,
password: input.password,
totp: input.totp,
totp_recovery_code: input.totp_recovery_code,
};
}

Expand Down
10 changes: 10 additions & 0 deletions models/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,10 @@ async function update(targetUser, postedUserData, options = {}) {
encryptTotpSecretInObject(validPostedUserData);
}

if (validPostedUserData.totp_recovery_codes) {
encryptRecoveryCodesInObject(validPostedUserData);
}

const updatedUser = await runUpdateQuery(currentUser, validPostedUserData, {
transaction: options.transaction,
});
Expand Down Expand Up @@ -371,6 +375,7 @@ function validatePatchSchema(postedUserData) {
description: 'optional',
notifications: 'optional',
totp_secret: 'optional',
totp_recovery_codes: 'optional',
});

return cleanValues;
Expand Down Expand Up @@ -445,6 +450,11 @@ function encryptTotpSecretInObject(userObject) {
return userObject;
}

function encryptRecoveryCodesInObject(userObject) {
userObject.totp_recovery_codes = otp.encryptData(JSON.stringify(userObject.totp_recovery_codes));
return userObject;
}

async function removeFeatures(userId, features, options = {}) {
let lastUpdatedUser;

Expand Down
22 changes: 22 additions & 0 deletions models/validator.js
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,28 @@ const schemas = {
});
},

totp_recovery_code: function () {
return Joi.object({
totp_recovery_code: Joi.string().length(10).when('$required.totp_recovery_code', {
is: 'required',
then: Joi.required(),
otherwise: Joi.optional(),
}),
});
},

totp_recovery_codes: function () {
return Joi.object({
totp_recovery_codes: Joi.object()
.pattern(Joi.string().length(10), Joi.boolean())
.when('$required.totp_recovery_codes', {
is: 'required',
then: Joi.required(),
otherwise: Joi.optional().allow(null),
}),
});
},

token_id: function () {
return Joi.object({
token_id: Joi.string()
Expand Down
15 changes: 13 additions & 2 deletions pages/api/v1/sessions/index.public.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ function postValidationHandler(request, response, next) {
email: 'required',
password: 'required',
totp: 'optional',
totp_recovery_code: 'optional',
});

request.body = cleanValues;
Expand Down Expand Up @@ -67,10 +68,10 @@ async function postHandler(request, response) {
}

if (storedUser.totp_secret) {
if (!secureInputValues.totp) {
if (!secureInputValues.totp && !secureInputValues.totp_recovery_code) {
throw new ValidationError({
message: 'O duplo fator de autenticação está habilitado para esta conta.',
action: 'Refaça a requisição enviando o código TOTP.',
action: 'Refaça a requisição enviando o código TOTP ou um código de recuperação.',
errorLocationCode: 'CONTROLER:SESSIONS:POST_HANDLER:MFA:TOTP:TOKEN_NOT_SENT',
key: 'totp',
});
Expand All @@ -86,6 +87,16 @@ async function postHandler(request, response) {
errorLocationCode: `CONTROLLER:SESSIONS:POST_HANDLER:MFA:TOTP:INVALID_TOKEN`,
});
}
} else {
const validRecoveryCode = await otp.validateAndMarkRecoveryCode(storedUser, secureInputValues.totp_recovery_code);

if (!validRecoveryCode) {
throw new UnauthorizedError({
message: `O código de recuperação informado já foi usado ou é inválido.`,
action: `Verifique se os dados enviados estão corretos.`,
errorLocationCode: `CONTROLLER:SESSIONS:POST_HANDLER:MFA:TOTP:INVALID_RECOVERY_CODE`,
});
}
}
}

Expand Down
10 changes: 9 additions & 1 deletion pages/api/v1/users/[username]/index.public.js
Original file line number Diff line number Diff line change
Expand Up @@ -129,10 +129,12 @@ async function patchHandler(request, response) {
if (!isTokenValid) {
throw new ForbiddenError({
message: 'O código TOTP informado é inválido.',
action: 'Verifique o código TOTP enviado e tente novamente.',
action: 'Verifique o código TOTP e tente novamente.',
errorLocationCode: 'CONTROLLER:USERS:USERNAME:PATCH:MFA:TOTP:INVALID_CODE',
});
}

secureInputValues.totp_recovery_codes = otp.createRecoveryCodes();
}
}

Expand Down Expand Up @@ -186,6 +188,12 @@ async function patchHandler(request, response) {
updatedUser,
);

if (secureInputValues.totp_recovery_codes) {
const recoveryCodesKeys = Object.keys(secureInputValues.totp_recovery_codes);

secureOutputValues.totp_recovery_codes = recoveryCodesKeys;
}

return response.status(200).json(secureOutputValues);

function getEventMetadata(originalUser, updatedUser) {
Expand Down
162 changes: 161 additions & 1 deletion tests/integration/api/v1/sessions/post.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ describe('POST /api/v1/sessions', () => {
expect(responseBody).toStrictEqual({
name: 'ValidationError',
message: 'O duplo fator de autenticação está habilitado para esta conta.',
action: 'Refaça a requisição enviando o código TOTP.',
action: 'Refaça a requisição enviando o código TOTP ou um código de recuperação.',
status_code: 400,
key: 'totp',
error_id: responseBody.error_id,
Expand Down Expand Up @@ -197,6 +197,166 @@ describe('POST /api/v1/sessions', () => {
expect(uuidVersion(responseBody.request_id)).toBe(4);
});

test('Using a valid email and password with TOTP enabled and sending a valid recovery code', async () => {
const usersRequestBuilder = new RequestBuilder('/api/v1/users');
const defaultUser = await orchestrator.createUser({
email: 'emailWithValidRecoveryCode@gmail.com',
password: 'ValidRecoveryCode',
});

await orchestrator.activateUser(defaultUser);
await usersRequestBuilder.setUser(defaultUser);

const totp_secret = otp.createSecret();
const totp = otp.createTotp(totp_secret).generate();

let { response, responseBody } = await usersRequestBuilder.patch(`/${defaultUser.username}`, {
totp_secret,
totp,
});

const recoveryCode = responseBody.totp_recovery_codes[Math.floor(Math.random() * 10)];

response = await fetch(`${orchestrator.webserverUrl}/api/v1/sessions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: 'emailWithValidRecoveryCode@gmail.com',
password: 'ValidRecoveryCode',
totp_recovery_code: recoveryCode,
}),
});

responseBody = await response.json();

expect.soft(response.status).toBe(201);
expect(responseBody.token.length).toBe(96);
expect(uuidVersion(responseBody.id)).toBe(4);
expect(Date.parse(responseBody.expires_at)).not.toBeNaN();
expect(Date.parse(responseBody.created_at)).not.toBeNaN();
expect(Date.parse(responseBody.updated_at)).not.toBeNaN();

const sessionObjectInDatabase = await session.findOneById(responseBody.id);
expect(sessionObjectInDatabase.user_id).toBe(defaultUser.id);

const parsedCookiesFromResponse = orchestrator.parseSetCookies(response);
expect(parsedCookiesFromResponse.session_id.name).toBe('session_id');
expect(parsedCookiesFromResponse.session_id.value).toBe(responseBody.token);
expect(parsedCookiesFromResponse.session_id.maxAge).toBe(60 * 60 * 24 * 30);
expect(parsedCookiesFromResponse.session_id.path).toBe('/');
expect(parsedCookiesFromResponse.session_id.httpOnly).toBe(true);
});

test('Using a valid email and password with TOTP enabled and sending an invalid recovery code', async () => {
const usersRequestBuilder = new RequestBuilder('/api/v1/users');
const defaultUser = await orchestrator.createUser({
email: 'emailWithInvalidRecoveryCode@gmail.com',
password: 'ValidPasswordAndInvalidRecoveryCode',
});

await orchestrator.activateUser(defaultUser);
await usersRequestBuilder.setUser(defaultUser);

const totp_secret = otp.createSecret();
const totp = otp.createTotp(totp_secret).generate();

await usersRequestBuilder.patch(`/${defaultUser.username}`, {
totp_secret,
totp,
});

const response = await fetch(`${orchestrator.webserverUrl}/api/v1/sessions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: 'emailWithInvalidTotp@gmail.com',
password: 'ValidPasswordAndInvalidTotp',
totp_recovery_code: otp.makeCode(),
}),
});

const responseBody = await response.json();

expect(response.status).toBe(401);
expect(responseBody).toStrictEqual({
name: 'UnauthorizedError',
message: 'O código de recuperação informado já foi usado ou é inválido.',
action: 'Verifique se os dados enviados estão corretos.',
status_code: 401,
error_id: responseBody.error_id,
request_id: responseBody.request_id,
error_location_code: 'CONTROLLER:SESSIONS:POST_HANDLER:MFA:TOTP:INVALID_RECOVERY_CODE',
});

expect(uuidVersion(responseBody.error_id)).toBe(4);
expect(uuidVersion(responseBody.request_id)).toBe(4);
});

test('Using a valid email and password with TOTP enabled and sending a valid, but already used, recovery code', async () => {
const usersRequestBuilder = new RequestBuilder('/api/v1/users');
const defaultUser = await orchestrator.createUser({
email: 'emailWithReusedRecoveryCodeTotp@gmail.com',
password: 'ReusedRecoveryCodeTotp',
});

await orchestrator.activateUser(defaultUser);
await usersRequestBuilder.setUser(defaultUser);

const totp_secret = otp.createSecret();
const totp = otp.createTotp(totp_secret).generate();

let { response, responseBody } = await usersRequestBuilder.patch(`/${defaultUser.username}`, {
totp_secret,
totp,
});

const recoveryCode = responseBody.totp_recovery_codes[Math.floor(Math.random() * 10)];

await fetch(`${orchestrator.webserverUrl}/api/v1/sessions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: 'emailWithReusedRecoveryCodeTotp@gmail.com',
password: 'ReusedRecoveryCodeTotp',
totp_recovery_code: recoveryCode,
}),
});

response = await fetch(`${orchestrator.webserverUrl}/api/v1/sessions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: 'emailWithReusedRecoveryCodeTotp@gmail.com',
password: 'ReusedRecoveryCodeTotp',
totp_recovery_code: recoveryCode,
}),
});

responseBody = await response.json();

expect(response.status).toBe(401);
expect(responseBody).toStrictEqual({
name: 'UnauthorizedError',
message: 'O código de recuperação informado já foi usado ou é inválido.',
action: 'Verifique se os dados enviados estão corretos.',
status_code: 401,
error_id: responseBody.error_id,
request_id: responseBody.request_id,
error_location_code: 'CONTROLLER:SESSIONS:POST_HANDLER:MFA:TOTP:INVALID_RECOVERY_CODE',
});

expect(uuidVersion(responseBody.error_id)).toBe(4);
expect(uuidVersion(responseBody.request_id)).toBe(4);
});

test('Using a valid email and password, but user lost the feature "create:session"', async () => {
const defaultUser = await orchestrator.createUser({
email: 'emailToBeFoundAndLostFeature@gmail.com',
Expand Down
1 change: 1 addition & 0 deletions tests/integration/api/v1/users/[username]/patch.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -922,6 +922,7 @@ describe('PATCH /api/v1/users/[username]', () => {
tabcoins: 0,
tabcash: 0,
totp_enabled: true,
totp_recovery_codes: responseBody.totp_recovery_codes,
created_at: defaultUser.created_at.toISOString(),
updated_at: responseBody.updated_at,
});
Expand Down
26 changes: 26 additions & 0 deletions tests/unit/models/otp.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,30 @@ describe('OTP model', () => {

expect(decryptedSecret).toStrictEqual(secret);
});

it('should create recovery codes', () => {
const recoveryCodes = otp.createRecoveryCodes();

for (const key in recoveryCodes) {
expect(key).toHaveLength(10);
expect(recoveryCodes[key]).toBeTruthy();
}

expect(Object.keys(recoveryCodes)).toHaveLength(10);
});

it('should encrypt recovery codes into a data of size 416', () => {
const recoveryCodes = otp.createRecoveryCodes();
const encryptedCodes = otp.encryptData(JSON.stringify(recoveryCodes));

expect(encryptedCodes).toHaveLength(416);
});

it('should decrypt recovery codes to the same before encryption', () => {
const recoveryCodes = otp.createRecoveryCodes();
const encryptedCodes = otp.encryptData(JSON.stringify(recoveryCodes));
const decryptedCodes = JSON.parse(otp.decryptData(encryptedCodes));

expect(decryptedCodes).toStrictEqual(recoveryCodes);
});
});

0 comments on commit 9f02ecf

Please sign in to comment.