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..d6707fa20 --- /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(416)', + 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/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..f0d32638a 100644 --- a/tests/unit/models/otp.test.js +++ b/tests/unit/models/otp.test.js @@ -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); + }); });