Skip to content
This repository has been archived by the owner on Apr 19, 2023. It is now read-only.

Commit

Permalink
✨ Support for 2FA
Browse files Browse the repository at this point in the history
  • Loading branch information
AnandChowdhary committed May 30, 2019
1 parent d41ad09 commit d40db7f
Show file tree
Hide file tree
Showing 9 changed files with 215 additions and 9 deletions.
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"@types/mustache": "^0.8.32",
"@types/mysql": "^2.15.6",
"@types/node": "^12.0.3",
"@types/qrcode": "^1.3.3",
"@types/request": "^2.48.1",
"@types/response-time": "^2.3.3",
"@types/stripe": "^6.26.3",
Expand Down Expand Up @@ -90,6 +91,9 @@
"node-cache": "^4.2.0",
"node-emoji": "^1.10.0",
"node-ses": "^2.2.1",
"otplib": "^11.0.1",
"qrcode": "^1.3.3",
"random-int": "^2.0.0",
"response-time": "^2.3.2",
"rotating-file-stream": "^1.4.1",
"stripe": "^7.1.0",
Expand Down
2 changes: 2 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ export const SES_SECRET = process.env.SES_SECRET || "";
// Auth and tokens
export const JWT_SECRET = process.env.JWT_SECRET || "staart";
export const JWT_ISSUER = process.env.JWT_ISSUER || "staart";
export const SERVICE_2FA = process.env.SERVICE_2FA || "staart";

export const TOKEN_EXPIRY_EMAIL_VERIFICATION =
process.env.TOKEN_EXPIRY_EMAIL_VERIFICATION || "7d";
export const TOKEN_EXPIRY_PASSWORD_RESET =
Expand Down
44 changes: 43 additions & 1 deletion src/controllers/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ import {
updateApiKeyForUser,
deleteApiKeyForUser,
getNotificationsForUser,
updateNotificationForUser
updateNotificationForUser,
enable2FAForUser,
disable2FAForUser,
verify2FAForUser
} from "../rest/user";
import { ErrorCode } from "../interfaces/enum";
import {
Expand Down Expand Up @@ -302,4 +305,43 @@ export class UserController {
)
);
}

@Get(":id/2fa/enable")
async getEnable2FA(req: Request, res: Response) {
let id = req.params.id;
if (id === "me") id = res.locals.token.id;
joiValidate(
{ id: [Joi.string().required(), Joi.number().required()] },
{ id }
);
res.json(await enable2FAForUser(res.locals.token.id, id));
}

@Post(":id/2fa/verify")
async postVerify2FA(req: Request, res: Response) {
let id = req.params.id;
if (id === "me") id = res.locals.token.id;
const code = req.body.code;
joiValidate(
{
id: [Joi.string().required(), Joi.number().required()],
code: Joi.number()
.min(5)
.required()
},
{ id, code }
);
res.json(await verify2FAForUser(res.locals.token.id, id, code));
}

@Delete(":id/2fa")
async delete2FA(req: Request, res: Response) {
let id = req.params.id;
if (id === "me") id = res.locals.token.id;
joiValidate(
{ id: [Joi.string().required(), Joi.number().required()] },
{ id }
);
res.json(await disable2FAForUser(res.locals.token.id, id));
}
}
54 changes: 54 additions & 0 deletions src/crud/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import { getEmail, getVerifiedEmailObject } from "./email";
import { cachedQuery, deleteItemFromCache } from "../helpers/cache";
import md5 from "md5";
import cryptoRandomString from "crypto-random-string";
import randomInt from "random-int";
import { BackupCode } from "../interfaces/tables/backup-codes";

/**
* Get a list of all users
Expand Down Expand Up @@ -251,3 +253,55 @@ export const deleteApiKey = async (apiKey: string) => {
apiKey
]);
};

/**
* Create 2FA backup codes for user
* @param count - Number of backup codes to create
*/
export const createBackupCodes = async (userId: number, count = 1) => {
for await (const x of Array.from(Array(count).keys())) {
const code: BackupCode = { code: randomInt(100000, 999999), userId };
code.createdAt = new Date();
code.updatedAt = code.createdAt;
await query(
`INSERT INTO \`backup-codes\` ${tableValues(code)}`,
Object.values(code)
);
}
return;
};

/**
* Update a backup code
*/
export const updateBackupCode = async (
backupCodeId: number,
code: KeyValue
) => {
code.updatedAt = new Date();
return await query(
`UPDATE \`backup-codes\` SET ${setValues(code)} WHERE id = ?`,
[...Object.values(code), backupCodeId]
);
};

/**
* Delete a backup code
*/
export const deleteBackupCode = async (backupCodeId: number) => {
return await query("DELETE FROM `backup-codes` WHERE id = ?", [backupCodeId]);
};

/**
* Delete all backup codes of a user
*/
export const deleteUserBackupCodes = async (userId: number) => {
return await query("DELETE FROM `backup-codes` WHERE userId = ?", [userId]);
};

/**
* Get all backup codes of a user
*/
export const getUserBackupCodes = async (userId: number) => {
return await query("SELECT * FROM `backup-codes` WHERE userId = ?", [userId]);
};
4 changes: 3 additions & 1 deletion src/interfaces/enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,9 @@ export enum ErrorCode {
CANNOT_DELETE_SOLE_OWNER = "400/cannot-delete-sole-owner",
CANNOT_UPDATE_SOLE_OWNER = "400/cannot-update-sole-owner",
USER_IS_MEMBER_ALREADY = "400/user-is-member-already",
STRIPE_NO_CUSTOMER = "404/no-customer"
STRIPE_NO_CUSTOMER = "404/no-customer",
NOT_ENABLED_2FA = "400/invalid-2fa-token",
INVALID_2FA_TOKEN = "401/invalid-2fa-token"
}

export enum Templates {
Expand Down
6 changes: 3 additions & 3 deletions src/interfaces/tables/backup-codes.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export interface BackupCode {
code: number;
userId: number;
used: boolean;
createdAt: Date;
updatedAt: Date;
used?: boolean;
createdAt?: Date;
updatedAt?: Date;
}
44 changes: 42 additions & 2 deletions src/rest/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,15 @@ import {
createApiKey,
getApiKey,
updateApiKey,
deleteApiKey
deleteApiKey,
createBackupCodes,
deleteUserBackupCodes
} from "../crud/user";
import {
deleteAllUserMemberships,
getUserMembershipsDetailed
} from "../crud/membership";
import { User, ApiKey } from "../interfaces/tables/user";
import { User } from "../interfaces/tables/user";
import { Locals, KeyValue } from "../interfaces/general";
import {
createEvent,
Expand All @@ -32,6 +34,9 @@ import { getUserEmails, deleteAllUserEmails } from "../crud/email";
import { can } from "../helpers/authorization";
import { validate } from "../helpers/utils";
import { getUserNotifications, updateNotification } from "../crud/notification";
import { authenticator } from "otplib";
import { toDataURL } from "qrcode";
import { SERVICE_2FA } from "../config";

export const getUserFromId = async (userId: number, tokenUserId: number) => {
if (await can(tokenUserId, Authorizations.READ, "user", userId))
Expand Down Expand Up @@ -231,3 +236,38 @@ export const updateNotificationForUser = async (
return await updateNotification(notificationId, data);
throw new Error(ErrorCode.INSUFFICIENT_PERMISSION);
};

export const enable2FAForUser = async (tokenUserId: number, userId: number) => {
if (!(await can(tokenUserId, Authorizations.UPDATE_SECURE, "user", userId)))
throw new Error(ErrorCode.INSUFFICIENT_PERMISSION);
const secret = authenticator.generateSecret();
await updateUser(userId, { twoFactorSecret: secret });
const authPath = authenticator.keyuri(`user-${userId}`, SERVICE_2FA, secret);
const qrCode = await toDataURL(authPath);
return { qrCode };
};

export const verify2FAForUser = async (
tokenUserId: number,
userId: number,
verificationCode: number
) => {
if (!(await can(tokenUserId, Authorizations.UPDATE_SECURE, "user", userId)))
throw new Error(ErrorCode.INSUFFICIENT_PERMISSION);
const secret = (await getUser(userId, true)).twoFactorSecret as string;
if (!secret) throw new Error(ErrorCode.NOT_ENABLED_2FA);
if (!authenticator.check(verificationCode.toString(), secret))
throw new Error(ErrorCode.INVALID_2FA_TOKEN);
await createBackupCodes(userId, 10);
await updateUser(userId, { twoFactorEnabled: true });
};

export const disable2FAForUser = async (
tokenUserId: number,
userId: number
) => {
if (!(await can(tokenUserId, Authorizations.UPDATE_SECURE, "user", userId)))
throw new Error(ErrorCode.INSUFFICIENT_PERMISSION);
await deleteUserBackupCodes(userId);
await updateUser(userId, { twoFactorEnabled: false, twoFactorSecret: "" });
};
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"moduleResolution": "node",
"target": "es6",
"module": "commonjs",
"lib": ["esnext"],
"lib": ["esnext", "dom"],
"strict": true,
"sourceMap": true,
"declaration": true,
Expand Down
64 changes: 63 additions & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1233,6 +1233,13 @@
resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz#e486d0d97396d79beedd0a6e33f4534ff6b4973e"
integrity sha512-f5j5b/Gf71L+dbqxIpQ4Z2WlmI/mPJ0fOkGGmFgtb6sAu97EPczzbS3/tJKxmcYDj55OX6ssqwDAWOHIYDRDGA==

"@types/qrcode@^1.3.3":
version "1.3.3"
resolved "https://registry.yarnpkg.com/@types/qrcode/-/qrcode-1.3.3.tgz#589e42514d7054f9dd985a20e0531f79b5b615ba"
integrity sha512-+5vox9KhEPGP+d2ah8V+gnHAaTDvFHssLz8KJS7OgJuessGGybChJYfmo+fwNFzOVUtfcWkTCJqkFDRz15hCYw==
dependencies:
"@types/node" "*"

"@types/range-parser@*":
version "1.2.3"
resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.3.tgz#7ee330ba7caafb98090bece86a5ee44115904c2c"
Expand Down Expand Up @@ -1779,6 +1786,13 @@ [email protected]:
resolved "https://registry.yarnpkg.com/camelize/-/camelize-1.0.0.tgz#164a5483e630fa4321e5af07020e531831b2609b"
integrity sha1-FkpUg+Yw+kMh5a8HAg5TGDGyYJs=

[email protected]:
version "0.0.1"
resolved "https://registry.yarnpkg.com/can-promise/-/can-promise-0.0.1.tgz#7a7597ad801fb14c8b22341dfec314b6bd6ad8d3"
integrity sha512-gzVrHyyrvgt0YpDm7pn04MQt8gjh0ZAhN4ZDyCRtGl6YnuuK6b4aiUTD7G52r9l4YNmxfTtEscb92vxtAlL6XQ==
dependencies:
window-or-global "^1.0.1"

caniuse-lite@^1.0.30000963:
version "1.0.30000967"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30000967.tgz#a5039577806fccee80a04aaafb2c0890b1ee2f73"
Expand Down Expand Up @@ -2276,6 +2290,11 @@ diff-sequences@^24.3.0:
resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-24.3.0.tgz#0f20e8a1df1abddaf4d9c226680952e64118b975"
integrity sha512-xLqpez+Zj9GKSnPWS0WZw1igGocZ+uua8+y+5dDNTT934N3QuY1sp2LkHzwiaYQGz60hMq0pjAshdeXm5VUOEw==

dijkstrajs@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/dijkstrajs/-/dijkstrajs-1.0.1.tgz#d3cd81221e3ea40742cfcde556d4e99e98ddc71b"
integrity sha1-082BIh4+pAdCz83lVtTpnpjdxxs=

[email protected]:
version "0.1.0"
resolved "https://registry.yarnpkg.com/dns-prefetch-control/-/dns-prefetch-control-0.1.0.tgz#60ddb457774e178f1f9415f0cabb0e85b0b300b2"
Expand Down Expand Up @@ -3489,6 +3508,11 @@ [email protected], isarray@~1.0.0:
resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11"
integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=

isarray@^2.0.1:
version "2.0.4"
resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.4.tgz#38e7bcbb0f3ba1b7933c86ba1894ddfc3781bbb7"
integrity sha512-GMxXOiUirWg1xTKRipM0Ek07rX+ubx4nNVElTJdNLYmNO/2YrDkgJGw9CljXn+r4EWiDQg/8lsRdHyg2PJuUaA==

isexe@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
Expand Down Expand Up @@ -4818,6 +4842,13 @@ osenv@^0.1.4:
os-homedir "^1.0.0"
os-tmpdir "^1.0.0"

otplib@^11.0.1:
version "11.0.1"
resolved "https://registry.yarnpkg.com/otplib/-/otplib-11.0.1.tgz#7d64aa87029f07c99c7f96819fb10cdb67dea886"
integrity sha512-oi57teljNyWTC/JqJztHOtSGeFNDiDh5C1myd+faocUtFAX27Sm1mbx69kpEJ8/JqrblI3kAm4Pqd6tZJoOIBQ==
dependencies:
thirty-two "1.0.2"

output-file-sync@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/output-file-sync/-/output-file-sync-2.0.1.tgz#f53118282f5f553c2799541792b723a4c71430c0"
Expand Down Expand Up @@ -5006,6 +5037,11 @@ pn@^1.1.0:
resolved "https://registry.yarnpkg.com/pn/-/pn-1.1.0.tgz#e2f4cef0e219f463c179ab37463e4e1ecdccbafb"
integrity sha512-2qHaIQr2VLRFoxe2nASzsV6ef4yOOH+Fi9FBOVH6cqeSgUnoyySPZkxzLuzd+RYOQTRpROA0ztTMqxROKSb/nA==

pngjs@^3.3.0:
version "3.4.0"
resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-3.4.0.tgz#99ca7d725965fb655814eaf65f38f12bbdbf555f"
integrity sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==

posix-character-classes@^0.1.0:
version "0.1.1"
resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab"
Expand Down Expand Up @@ -5105,6 +5141,17 @@ q@>=1.0.1:
resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7"
integrity sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc=

qrcode@^1.3.3:
version "1.3.3"
resolved "https://registry.yarnpkg.com/qrcode/-/qrcode-1.3.3.tgz#5ef50c0c890cffa1897f452070f0f094936993de"
integrity sha512-SH7V13AcJusH3GT8bMNOGz4w0L+LjcpNOU/NiOgtBhT/5DoWeZE6D5ntMJnJ84AMkoaM4kjJJoHoh9g++8lWFg==
dependencies:
can-promise "0.0.1"
dijkstrajs "^1.0.1"
isarray "^2.0.1"
pngjs "^3.3.0"
yargs "^12.0.5"

[email protected], qs@^6.5.2, qs@^6.6.0:
version "6.7.0"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc"
Expand All @@ -5115,6 +5162,11 @@ qs@~6.5.2:
resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36"
integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==

random-int@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/random-int/-/random-int-2.0.0.tgz#0979bdef46207a11dbfdbf6cae980351ba8946ab"
integrity sha512-jCJ4a8BJ+z3f4SYSFtYIiRfcdxe2Bvh+Gg2J+LjriL3dVOtrF77u0tklYbO8acHoZQ7JlYJn3lNKfW5TFjcwdQ==

range-parser@~1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031"
Expand Down Expand Up @@ -5908,6 +5960,11 @@ test-exclude@^5.2.2:
read-pkg-up "^4.0.0"
require-main-filename "^2.0.0"

[email protected]:
version "1.0.2"
resolved "https://registry.yarnpkg.com/thirty-two/-/thirty-two-1.0.2.tgz#4ca2fffc02a51290d2744b9e3f557693ca6b627a"
integrity sha1-TKL//AKlEpDSdEueP1V2k8prYno=

throat@^4.0.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/throat/-/throat-4.1.0.tgz#89037cbc92c56ab18926e6ba4cbb200e15672a6a"
Expand Down Expand Up @@ -6353,6 +6410,11 @@ widest-line@^2.0.0:
dependencies:
string-width "^2.1.1"

window-or-global@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/window-or-global/-/window-or-global-1.0.1.tgz#dbe45ba2a291aabc56d62cf66c45b7fa322946de"
integrity sha1-2+RboqKRqrxW1iz2bEW3+jIpRt4=

wordwrap@~0.0.2:
version "0.0.3"
resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.3.tgz#a3d5da6cd5c0bc0008d37234bbaf1bed63059107"
Expand Down Expand Up @@ -6459,7 +6521,7 @@ yargs-parser@^11.1.1:
camelcase "^5.0.0"
decamelize "^1.2.0"

yargs@^12.0.1, yargs@^12.0.2:
yargs@^12.0.1, yargs@^12.0.2, yargs@^12.0.5:
version "12.0.5"
resolved "https://registry.yarnpkg.com/yargs/-/yargs-12.0.5.tgz#05f5997b609647b64f66b81e3b4b10a368e7ad13"
integrity sha512-Lhz8TLaYnxq/2ObqHDql8dX8CJi97oHxrjUcYtzKbbykPtVW9WB+poxI+NM2UIzsMgNCZTIf0AQwsjK5yMAqZw==
Expand Down

0 comments on commit d40db7f

Please sign in to comment.