Skip to content

Commit

Permalink
feat(2fa): implementing backend code for TOTP with recovery codes
Browse files Browse the repository at this point in the history
  • Loading branch information
lspaulucio committed Sep 3, 2024
1 parent cdfc5c4 commit 223c83f
Show file tree
Hide file tree
Showing 20 changed files with 735 additions and 8 deletions.
3 changes: 3 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,6 @@ EMAIL_HTTP_PORT=1080
EMAIL_USER=
EMAIL_PASSWORD=
UNDER_MAINTENANCE={"methodsAndPaths":["POST /api/v1/under-maintenance-test$"]}
TOTP_SECRET_KEY="ha0wFlJJXdeKwANNgoB8jT2Op1iQ0pfGWuqnK8oMTVg="
TOTP_SECRET_IV="WntJYWeekZEuUzjc"
TOTP_ENCRYPTION_METHOD="aes-256-cbc"
14 changes: 14 additions & 0 deletions infra/migrations/1715307937886_alter-table-users-add-mfa-totp.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
exports.up = (pgm) => {
pgm.addColumns('users', {
totp_secret: {
type: 'varchar(128)',
notNull: false,
},
totp_recovery_codes: {
type: 'varchar(472)',
notNull: false,
},
});
};

exports.down = false;
5 changes: 5 additions & 0 deletions models/authorization.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ function filterInput(user, feature, input, target) {
filteredInputValues = {
email: input.email,
password: input.password,
totp_token: input.totp_token,
totp_recovery_code: input.totp_recovery_code,
};
}

Expand All @@ -94,6 +96,8 @@ function filterInput(user, feature, input, target) {
password: input.password,
description: input.description,
notifications: input.notifications,
totp_token: input.totp_token,
totp_secret: input.totp_secret,
};
}

Expand Down Expand Up @@ -206,6 +210,7 @@ function filterOutput(user, feature, output) {
features: output.features,
tabcoins: output.tabcoins,
tabcash: output.tabcash,
totp_enabled: output.totp_secret !== null ? true : false,
created_at: output.created_at,
updated_at: output.updated_at,
};
Expand Down
2 changes: 1 addition & 1 deletion models/recovery.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ async function findOneTokenById(tokenId) {

if (results.rowCount === 0) {
throw new NotFoundError({
message: `O token de recuperação de senha utilizado não foi encontrado no sistema.`,
message: `O token de recuperação de senha utilizado não foi encontrado no sistema ou expirou.`,
action: 'Certifique-se que está sendo enviado o token corretamente.',
stack: new Error().stack,
});
Expand Down
101 changes: 101 additions & 0 deletions models/totp.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import crypto from 'crypto';
import * as OTPAuth from 'otpauth';

import user from 'models/user';

const defaultTOTPConfigurations = {
issuer: 'TabNews',
algorithm: 'SHA1',
digits: 6,
};

function createSecret() {
return new OTPAuth.Secret({ size: 20 }).base32;
}

function createTotp(secret, username) {
if (!secret) {
secret = createSecret();
}
return new OTPAuth.TOTP({ ...defaultTOTPConfigurations, secret, label: username });
}

const cryptoConfigurations = {
algorithm: process.env.TOTP_ENCRYPTION_METHOD,
key: crypto.createHash('sha512').update(process.env.TOTP_SECRET_KEY).digest('hex').substring(0, 32),
encryptionIV: crypto.createHash('sha512').update(process.env.TOTP_SECRET_IV).digest('hex').substring(0, 16),
};

function encryptData(data) {
const cipher = crypto.createCipheriv(
cryptoConfigurations.algorithm,
cryptoConfigurations.key,
cryptoConfigurations.encryptionIV,
);
return Buffer.from(cipher.update(data, 'utf8', 'hex') + cipher.final('hex')).toString('base64');
}

function decryptData(encryptedData) {
const buff = Buffer.from(encryptedData, 'base64');
const decipher = crypto.createDecipheriv(
cryptoConfigurations.algorithm,
cryptoConfigurations.key,
cryptoConfigurations.encryptionIV,
);
return decipher.update(buff.toString('utf8'), 'hex', 'utf8') + decipher.final('utf8');
}

function createRecoveryCodes() {
const RECOVERY_CODES_LENTGH = 8;
const RECOVERY_CODES_AMOUNT = 10;

function makeCode(length) {
let code = '';
const characters = 'abcdefghijklmnopqrstuvwxyz0123456789';
const charactersLength = characters.length;
let counter = 0;
while (counter < length) {
code += characters.charAt(Math.floor(Math.random() * charactersLength));
counter += 1;
}
return code;
}

const recoveryCodesObject = {};

for (let i = 0; i < RECOVERY_CODES_AMOUNT; i++) {
const newCode = makeCode(RECOVERY_CODES_LENTGH);
recoveryCodesObject[newCode] = true;
}

return JSON.stringify(recoveryCodesObject);
}

function validateTotp(userSecret, token) {
const userTOTP = createTotp(userSecret);
return userTOTP.validate({ token }) !== null;
}

async function validateAndMarkRecoveryCode(targetUser, recoveryCode) {
const recoveryCodes = JSON.parse(decryptData(targetUser.totp_recovery_codes));

if (recoveryCodes[recoveryCode]) {
recoveryCodes[recoveryCode] = false;

await user.update(targetUser.username, { totp_recovery_codes: encryptData(JSON.stringify(recoveryCodes)) });

return true;
}

return false;
}

export default Object.freeze({
createTotp,
createSecret,
decryptData,
encryptData,
createRecoveryCodes,
validateTotp,
validateAndMarkRecoveryCode,
});
6 changes: 6 additions & 0 deletions models/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,8 @@ async function update(username, postedUserData, options = {}) {
password = $4,
description = $5,
notifications = $6,
totp_secret = $7,
totp_recovery_codes = $8,
updated_at = (now() at time zone 'utc')
WHERE
id = $1
Expand All @@ -319,6 +321,8 @@ async function update(username, postedUserData, options = {}) {
userWithUpdatedValues.password,
userWithUpdatedValues.description,
userWithUpdatedValues.notifications,
userWithUpdatedValues.totp_secret,
userWithUpdatedValues.totp_recovery_codes,
],
};

Expand Down Expand Up @@ -351,6 +355,8 @@ async function validatePatchSchema(postedUserData) {
password: 'optional',
description: 'optional',
notifications: 'optional',
totp_secret: 'optional',
totp_recovery_codes: 'optional',
});

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

totp_secret: function () {
return Joi.object({
totp_secret: Joi.string()
.max(128)
.when('$required.totp_secret', { is: 'required', then: Joi.required(), otherwise: Joi.optional().allow(null) }),
});
},

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

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

totp_token: function () {
return Joi.object({
totp_token: Joi.string()
.max(6)
.when('$required.totp_token', { is: 'required', then: Joi.required(), otherwise: Joi.optional() }),
});
},

token_id: function () {
return Joi.object({
token_id: Joi.string()
Expand Down
20 changes: 20 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"node-pg-migrate": "7.5.1",
"nodemailer": "6.9.14",
"nprogress": "0.2.0",
"otpauth": "9.2.4",
"pg": "8.12.0",
"pino": "9.2.0",
"react": "18.3.1",
Expand Down
39 changes: 39 additions & 0 deletions pages/api/v1/mfa/totp/index.public.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import nextConnect from 'next-connect';

import { ServiceError } from 'errors';
import authentication from 'models/authentication.js';
import authorization from 'models/authorization.js';
import cacheControl from 'models/cache-control';
import controller from 'models/controller.js';
import totp from 'models/totp';

export default nextConnect({
attachParams: true,
onNoMatch: controller.onNoMatchHandler,
onError: controller.onErrorHandler,
})
.use(controller.injectRequestMetadata)
.use(controller.logRequest)
.get(
cacheControl.noCache,
authentication.injectAnonymousOrUser,
authorization.canRequest('read:session'),
getHandler,
);

async function getHandler(request, response) {
const username = request.context.user.username;
const otp = totp.createTotp(null, username);

try {
response.status(200).json({ totp: otp.toString() });
} catch (err) {
throw new ServiceError({
message: 'Não foi possível gerar um TOTP no momento.',
action: 'Tente novamente mais tarde.',
stack: new Error().stack,
errorLocationCode: 'CONTROLLER:MFA:TOTP:ENABLE_GET',
key: 'totp',
});
}
}
14 changes: 14 additions & 0 deletions pages/api/v1/recovery/index.public.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import authorization from 'models/authorization.js';
import cacheControl from 'models/cache-control';
import controller from 'models/controller.js';
import recovery from 'models/recovery.js';
import user from 'models/user';
import validator from 'models/validator.js';

export default nextConnect({
Expand Down Expand Up @@ -71,6 +72,7 @@ function patchValidationHandler(request, response, next) {
const cleanValues = validator(request.body, {
token_id: 'required',
password: 'required',
totp_token: 'optional',
});

request.body = cleanValues;
Expand All @@ -82,6 +84,18 @@ async function patchHandler(request, response) {
const userTryingToRecover = request.context.user;
const validatedInputValues = request.body;

const recoveryToken = await recovery.findOneTokenById(validatedInputValues.token_id);
const targetUser = await user.findOneById(recoveryToken.user_id);

if (targetUser.totp_secret && !validatedInputValues.totp_token) {
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.',
errorLocationCode: 'CONTROLLER:RECOVERY:PATCH_HANDLER:TOTP_TOKEN_NOT_SENT',
key: 'totp_token',
});
}

const tokenObject = await recovery.resetUserPassword(validatedInputValues);

const authorizedValuesToReturn = authorization.filterOutput(userTryingToRecover, 'read:recovery_token', tokenObject);
Expand Down
Loading

0 comments on commit 223c83f

Please sign in to comment.