Skip to content

Commit

Permalink
feat(2fa): implementing backend code for TOTP
Browse files Browse the repository at this point in the history
  • Loading branch information
lspaulucio committed Sep 13, 2024
1 parent a67be7f commit 4e71064
Show file tree
Hide file tree
Showing 20 changed files with 884 additions and 8 deletions.
2 changes: 2 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,5 @@ EMAIL_HTTP_PORT=1080
EMAIL_USER=
EMAIL_PASSWORD=
UNDER_MAINTENANCE={"methodsAndPaths":["POST /api/v1/under-maintenance-test$"]}
TOTP_SECRET_KEY="ha0wFlJJXdeKwANNgoB8jT2Op1iQ0pfGWuqnK8oMTVg="
TOTP_ENCRYPTION_METHOD="aes-256-cbc"
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
exports.up = (pgm) => {
pgm.addColumns('users', {
totp_secret: {
type: 'varchar(128)',
notNull: false,
},
});
};

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

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

Expand Down Expand Up @@ -162,6 +165,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
107 changes: 107 additions & 0 deletions models/otp.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import crypto from 'crypto';
import * as OTPAuth from 'otpauth';

import user from 'models/user';

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

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

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 });
}

function encryptData(data) {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(cryptoConfigurations.algorithm, cryptoConfigurations.key, iv);
const encryptedData = Buffer.concat([cipher.update(data, 'utf8'), cipher.final()]);

return Buffer.concat([iv, encryptedData]).toString('hex');
}

function decryptData(encrypted) {
const data = Buffer.from(encrypted, 'hex');
const iv = data.subarray(0, 16);
const encryptedData = data.subarray(16);
const decipher = crypto.createDecipheriv(cryptoConfigurations.algorithm, cryptoConfigurations.key, iv);

return decipher.update(encryptedData, 'binary', 'utf-8') + decipher.final('utf-8');
}

function validateUserTotp(userEncryptedSecret, token) {
const userSecret = decryptData(userEncryptedSecret);
const userTOTP = createTotp(userSecret);

return userTOTP.validate({ token }) !== null;
}

function validateTotp(secret, token) {
const totp = createTotp(secret);

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,
decryptData,
encryptData,
validateUserTotp,
validateTotp,
makeCode,
createRecoveryCodes,
validateAndMarkRecoveryCode,
});
12 changes: 12 additions & 0 deletions models/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { NotFoundError, ValidationError } from 'errors';
import database from 'infra/database.js';
import authentication from 'models/authentication.js';
import emailConfirmation from 'models/email-confirmation.js';
import otp from 'models/otp.js';
import pagination from 'models/pagination.js';
import validator from 'models/validator.js';

Expand Down Expand Up @@ -313,6 +314,11 @@ async function update(targetUser, postedUserData, options = {}) {
if ('password' in validPostedUserData) {
await hashPasswordInObject(validPostedUserData);
}

if (validPostedUserData.totp_secret) {
encryptTotpSecretInObject(validPostedUserData);
}

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

return cleanValues;
Expand Down Expand Up @@ -433,6 +440,11 @@ async function hashPasswordInObject(userObject) {
return userObject;
}

function encryptTotpSecretInObject(userObject) {
userObject.totp_secret = otp.encryptData(userObject.totp_secret);
return userObject;
}

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

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

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

totp: function () {
return Joi.object({
totp: Joi.string()
.length(6)
.when('$required.totp', { 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",
"parse-link-header": "2.0.0",
"pg": "8.12.0",
"pino": "9.2.0",
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 otp from 'models/otp';

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,
);

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

try {
response.status(200).json({ totp: totp.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:GET_HANDLER:GENERATE_TOTP',
key: 'totp',
});
}
}
23 changes: 23 additions & 0 deletions pages/api/v1/recovery/index.public.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ 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 otp from 'models/otp';
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 +73,7 @@ function patchValidationHandler(request, response, next) {
const cleanValues = validator(request.body, {
token_id: 'required',
password: 'required',
totp: 'optional',
});

request.body = cleanValues;
Expand All @@ -82,6 +85,26 @@ 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) {
if (!validatedInputValues.totp) {
throw new ForbiddenError({
message: 'Duplo fator de autenticação habilitado para a conta.',
action: 'Refaça a requisição enviando o código TOTP.',
errorLocationCode: 'CONTROLLER:RECOVERY:PATCH_HANDLER:MFA:TOTP:TOKEN_NOT_SENT',
});
}
if (!otp.validateUserTotp(targetUser.totp_secret, validatedInputValues.totp)) {
throw new ForbiddenError({
message: 'O código TOTP informado é inválido.',
action: 'Verifique o código TOTP e tente novamente.',
errorLocationCode: 'CONTROLLER:RECOVERY:PATCH_HANDLER:MFA:TOTP:INVALID_CODE',
});
}
}

const tokenObject = await recovery.resetUserPassword(validatedInputValues);

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

0 comments on commit 4e71064

Please sign in to comment.