diff --git a/infra/migrations/1725497930423_alter-table-users-add-mfa-totp-recovery-codes.js b/infra/migrations/1725497930423_alter-table-users-add-mfa-totp-recovery-codes.js new file mode 100644 index 000000000..6cc0be263 --- /dev/null +++ b/infra/migrations/1725497930423_alter-table-users-add-mfa-totp-recovery-codes.js @@ -0,0 +1,10 @@ +exports.up = (pgm) => { + pgm.addColumns('users', { + totp_recovery_codes: { + type: 'varchar(512)', + notNull: false, + }, + }); +}; + +exports.down = false; diff --git a/models/authorization.js b/models/authorization.js index 9cd8c3dae..f48a9259a 100644 --- a/models/authorization.js +++ b/models/authorization.js @@ -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, }; } diff --git a/models/otp.js b/models/otp.js index 0ea351b0a..f90d9e8fa 100644 --- a/models/otp.js +++ b/models/otp.js @@ -1,6 +1,8 @@ import crypto from 'crypto'; import * as OTPAuth from 'otpauth'; +import user from 'models/user'; + const defaultTOTPConfigurations = { issuer: 'TabNews', algorithm: 'SHA1', @@ -54,6 +56,45 @@ function validateTotp(secret, token) { return totp.validate({ token }) !== null; } +function makeCode(length = 10) { + const characters = 'abcdefghijklmnopqrstuvwxyz0123456789'; + let code = ''; + + for (let i = 0; i < length; i++) { + code += characters.charAt(Math.floor(Math.random() * characters.length)); + } + + return code; +} + +function createRecoveryCodes() { + const RECOVERY_CODES_LENTGH = 10; + const RECOVERY_CODES_AMOUNT = 10; + + const recoveryCodesObject = {}; + + for (let i = 0; i < RECOVERY_CODES_AMOUNT; i++) { + const newCode = makeCode(RECOVERY_CODES_LENTGH); + recoveryCodesObject[newCode] = true; + } + + return recoveryCodesObject; +} + +async function validateAndMarkRecoveryCode(targetUser, recoveryCode) { + const recoveryCodes = JSON.parse(decryptData(targetUser.totp_recovery_codes)); + + if (recoveryCodes[recoveryCode]) { + recoveryCodes[recoveryCode] = false; + + await user.update(targetUser, { totp_recovery_codes: recoveryCodes }); + + return true; + } + + return false; +} + export default Object.freeze({ createTotp, createSecret, @@ -61,4 +102,7 @@ export default Object.freeze({ encryptData, validateUserTotp, validateTotp, + makeCode, + createRecoveryCodes, + validateAndMarkRecoveryCode, }); diff --git a/models/user.js b/models/user.js index d2336dccf..8f08ba9cc 100644 --- a/models/user.js +++ b/models/user.js @@ -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, }); @@ -371,6 +375,7 @@ function validatePatchSchema(postedUserData) { description: 'optional', notifications: 'optional', totp_secret: 'optional', + totp_recovery_codes: 'optional', }); return cleanValues; @@ -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; diff --git a/models/validator.js b/models/validator.js index 95ba51af6..cf27b89fa 100644 --- a/models/validator.js +++ b/models/validator.js @@ -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() diff --git a/pages/api/v1/sessions/index.public.js b/pages/api/v1/sessions/index.public.js index 3c107a55d..df11d18c1 100644 --- a/pages/api/v1/sessions/index.public.js +++ b/pages/api/v1/sessions/index.public.js @@ -40,6 +40,7 @@ function postValidationHandler(request, response, next) { email: 'required', password: 'required', totp: 'optional', + totp_recovery_code: 'optional', }); request.body = cleanValues; @@ -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', }); @@ -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`, + }); + } } } diff --git a/pages/api/v1/users/[username]/index.public.js b/pages/api/v1/users/[username]/index.public.js index ab0edd5fc..5b93ea60a 100644 --- a/pages/api/v1/users/[username]/index.public.js +++ b/pages/api/v1/users/[username]/index.public.js @@ -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(); } } @@ -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) { diff --git a/tests/integration/api/v1/sessions/post.test.js b/tests/integration/api/v1/sessions/post.test.js index 1db26291a..e410c4828 100644 --- a/tests/integration/api/v1/sessions/post.test.js +++ b/tests/integration/api/v1/sessions/post.test.js @@ -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, @@ -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', diff --git a/tests/integration/api/v1/users/[username]/patch.test.js b/tests/integration/api/v1/users/[username]/patch.test.js index dbbf5af11..684c6200f 100644 --- a/tests/integration/api/v1/users/[username]/patch.test.js +++ b/tests/integration/api/v1/users/[username]/patch.test.js @@ -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, }); diff --git a/tests/unit/models/otp.test.js b/tests/unit/models/otp.test.js index 448bd4292..9b748fb46 100644 --- a/tests/unit/models/otp.test.js +++ b/tests/unit/models/otp.test.js @@ -45,4 +45,15 @@ 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); + }); });