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

Commit d40db7f

Browse files
✨ Support for 2FA
1 parent d41ad09 commit d40db7f

File tree

9 files changed

+215
-9
lines changed

9 files changed

+215
-9
lines changed

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
"@types/mustache": "^0.8.32",
5050
"@types/mysql": "^2.15.6",
5151
"@types/node": "^12.0.3",
52+
"@types/qrcode": "^1.3.3",
5253
"@types/request": "^2.48.1",
5354
"@types/response-time": "^2.3.3",
5455
"@types/stripe": "^6.26.3",
@@ -90,6 +91,9 @@
9091
"node-cache": "^4.2.0",
9192
"node-emoji": "^1.10.0",
9293
"node-ses": "^2.2.1",
94+
"otplib": "^11.0.1",
95+
"qrcode": "^1.3.3",
96+
"random-int": "^2.0.0",
9397
"response-time": "^2.3.2",
9498
"rotating-file-stream": "^1.4.1",
9599
"stripe": "^7.1.0",

src/config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ export const SES_SECRET = process.env.SES_SECRET || "";
3131
// Auth and tokens
3232
export const JWT_SECRET = process.env.JWT_SECRET || "staart";
3333
export const JWT_ISSUER = process.env.JWT_ISSUER || "staart";
34+
export const SERVICE_2FA = process.env.SERVICE_2FA || "staart";
35+
3436
export const TOKEN_EXPIRY_EMAIL_VERIFICATION =
3537
process.env.TOKEN_EXPIRY_EMAIL_VERIFICATION || "7d";
3638
export const TOKEN_EXPIRY_PASSWORD_RESET =

src/controllers/user.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ import {
1212
updateApiKeyForUser,
1313
deleteApiKeyForUser,
1414
getNotificationsForUser,
15-
updateNotificationForUser
15+
updateNotificationForUser,
16+
enable2FAForUser,
17+
disable2FAForUser,
18+
verify2FAForUser
1619
} from "../rest/user";
1720
import { ErrorCode } from "../interfaces/enum";
1821
import {
@@ -302,4 +305,43 @@ export class UserController {
302305
)
303306
);
304307
}
308+
309+
@Get(":id/2fa/enable")
310+
async getEnable2FA(req: Request, res: Response) {
311+
let id = req.params.id;
312+
if (id === "me") id = res.locals.token.id;
313+
joiValidate(
314+
{ id: [Joi.string().required(), Joi.number().required()] },
315+
{ id }
316+
);
317+
res.json(await enable2FAForUser(res.locals.token.id, id));
318+
}
319+
320+
@Post(":id/2fa/verify")
321+
async postVerify2FA(req: Request, res: Response) {
322+
let id = req.params.id;
323+
if (id === "me") id = res.locals.token.id;
324+
const code = req.body.code;
325+
joiValidate(
326+
{
327+
id: [Joi.string().required(), Joi.number().required()],
328+
code: Joi.number()
329+
.min(5)
330+
.required()
331+
},
332+
{ id, code }
333+
);
334+
res.json(await verify2FAForUser(res.locals.token.id, id, code));
335+
}
336+
337+
@Delete(":id/2fa")
338+
async delete2FA(req: Request, res: Response) {
339+
let id = req.params.id;
340+
if (id === "me") id = res.locals.token.id;
341+
joiValidate(
342+
{ id: [Joi.string().required(), Joi.number().required()] },
343+
{ id }
344+
);
345+
res.json(await disable2FAForUser(res.locals.token.id, id));
346+
}
305347
}

src/crud/user.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import { getEmail, getVerifiedEmailObject } from "./email";
2323
import { cachedQuery, deleteItemFromCache } from "../helpers/cache";
2424
import md5 from "md5";
2525
import cryptoRandomString from "crypto-random-string";
26+
import randomInt from "random-int";
27+
import { BackupCode } from "../interfaces/tables/backup-codes";
2628

2729
/**
2830
* Get a list of all users
@@ -251,3 +253,55 @@ export const deleteApiKey = async (apiKey: string) => {
251253
apiKey
252254
]);
253255
};
256+
257+
/**
258+
* Create 2FA backup codes for user
259+
* @param count - Number of backup codes to create
260+
*/
261+
export const createBackupCodes = async (userId: number, count = 1) => {
262+
for await (const x of Array.from(Array(count).keys())) {
263+
const code: BackupCode = { code: randomInt(100000, 999999), userId };
264+
code.createdAt = new Date();
265+
code.updatedAt = code.createdAt;
266+
await query(
267+
`INSERT INTO \`backup-codes\` ${tableValues(code)}`,
268+
Object.values(code)
269+
);
270+
}
271+
return;
272+
};
273+
274+
/**
275+
* Update a backup code
276+
*/
277+
export const updateBackupCode = async (
278+
backupCodeId: number,
279+
code: KeyValue
280+
) => {
281+
code.updatedAt = new Date();
282+
return await query(
283+
`UPDATE \`backup-codes\` SET ${setValues(code)} WHERE id = ?`,
284+
[...Object.values(code), backupCodeId]
285+
);
286+
};
287+
288+
/**
289+
* Delete a backup code
290+
*/
291+
export const deleteBackupCode = async (backupCodeId: number) => {
292+
return await query("DELETE FROM `backup-codes` WHERE id = ?", [backupCodeId]);
293+
};
294+
295+
/**
296+
* Delete all backup codes of a user
297+
*/
298+
export const deleteUserBackupCodes = async (userId: number) => {
299+
return await query("DELETE FROM `backup-codes` WHERE userId = ?", [userId]);
300+
};
301+
302+
/**
303+
* Get all backup codes of a user
304+
*/
305+
export const getUserBackupCodes = async (userId: number) => {
306+
return await query("SELECT * FROM `backup-codes` WHERE userId = ?", [userId]);
307+
};

src/interfaces/enum.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,9 @@ export enum ErrorCode {
7777
CANNOT_DELETE_SOLE_OWNER = "400/cannot-delete-sole-owner",
7878
CANNOT_UPDATE_SOLE_OWNER = "400/cannot-update-sole-owner",
7979
USER_IS_MEMBER_ALREADY = "400/user-is-member-already",
80-
STRIPE_NO_CUSTOMER = "404/no-customer"
80+
STRIPE_NO_CUSTOMER = "404/no-customer",
81+
NOT_ENABLED_2FA = "400/invalid-2fa-token",
82+
INVALID_2FA_TOKEN = "401/invalid-2fa-token"
8183
}
8284

8385
export enum Templates {
Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
export interface BackupCode {
22
code: number;
33
userId: number;
4-
used: boolean;
5-
createdAt: Date;
6-
updatedAt: Date;
4+
used?: boolean;
5+
createdAt?: Date;
6+
updatedAt?: Date;
77
}

src/rest/user.ts

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,15 @@ import {
1414
createApiKey,
1515
getApiKey,
1616
updateApiKey,
17-
deleteApiKey
17+
deleteApiKey,
18+
createBackupCodes,
19+
deleteUserBackupCodes
1820
} from "../crud/user";
1921
import {
2022
deleteAllUserMemberships,
2123
getUserMembershipsDetailed
2224
} from "../crud/membership";
23-
import { User, ApiKey } from "../interfaces/tables/user";
25+
import { User } from "../interfaces/tables/user";
2426
import { Locals, KeyValue } from "../interfaces/general";
2527
import {
2628
createEvent,
@@ -32,6 +34,9 @@ import { getUserEmails, deleteAllUserEmails } from "../crud/email";
3234
import { can } from "../helpers/authorization";
3335
import { validate } from "../helpers/utils";
3436
import { getUserNotifications, updateNotification } from "../crud/notification";
37+
import { authenticator } from "otplib";
38+
import { toDataURL } from "qrcode";
39+
import { SERVICE_2FA } from "../config";
3540

3641
export const getUserFromId = async (userId: number, tokenUserId: number) => {
3742
if (await can(tokenUserId, Authorizations.READ, "user", userId))
@@ -231,3 +236,38 @@ export const updateNotificationForUser = async (
231236
return await updateNotification(notificationId, data);
232237
throw new Error(ErrorCode.INSUFFICIENT_PERMISSION);
233238
};
239+
240+
export const enable2FAForUser = async (tokenUserId: number, userId: number) => {
241+
if (!(await can(tokenUserId, Authorizations.UPDATE_SECURE, "user", userId)))
242+
throw new Error(ErrorCode.INSUFFICIENT_PERMISSION);
243+
const secret = authenticator.generateSecret();
244+
await updateUser(userId, { twoFactorSecret: secret });
245+
const authPath = authenticator.keyuri(`user-${userId}`, SERVICE_2FA, secret);
246+
const qrCode = await toDataURL(authPath);
247+
return { qrCode };
248+
};
249+
250+
export const verify2FAForUser = async (
251+
tokenUserId: number,
252+
userId: number,
253+
verificationCode: number
254+
) => {
255+
if (!(await can(tokenUserId, Authorizations.UPDATE_SECURE, "user", userId)))
256+
throw new Error(ErrorCode.INSUFFICIENT_PERMISSION);
257+
const secret = (await getUser(userId, true)).twoFactorSecret as string;
258+
if (!secret) throw new Error(ErrorCode.NOT_ENABLED_2FA);
259+
if (!authenticator.check(verificationCode.toString(), secret))
260+
throw new Error(ErrorCode.INVALID_2FA_TOKEN);
261+
await createBackupCodes(userId, 10);
262+
await updateUser(userId, { twoFactorEnabled: true });
263+
};
264+
265+
export const disable2FAForUser = async (
266+
tokenUserId: number,
267+
userId: number
268+
) => {
269+
if (!(await can(tokenUserId, Authorizations.UPDATE_SECURE, "user", userId)))
270+
throw new Error(ErrorCode.INSUFFICIENT_PERMISSION);
271+
await deleteUserBackupCodes(userId);
272+
await updateUser(userId, { twoFactorEnabled: false, twoFactorSecret: "" });
273+
};

tsconfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"moduleResolution": "node",
44
"target": "es6",
55
"module": "commonjs",
6-
"lib": ["esnext"],
6+
"lib": ["esnext", "dom"],
77
"strict": true,
88
"sourceMap": true,
99
"declaration": true,

yarn.lock

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1233,6 +1233,13 @@
12331233
resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz#e486d0d97396d79beedd0a6e33f4534ff6b4973e"
12341234
integrity sha512-f5j5b/Gf71L+dbqxIpQ4Z2WlmI/mPJ0fOkGGmFgtb6sAu97EPczzbS3/tJKxmcYDj55OX6ssqwDAWOHIYDRDGA==
12351235

1236+
"@types/qrcode@^1.3.3":
1237+
version "1.3.3"
1238+
resolved "https://registry.yarnpkg.com/@types/qrcode/-/qrcode-1.3.3.tgz#589e42514d7054f9dd985a20e0531f79b5b615ba"
1239+
integrity sha512-+5vox9KhEPGP+d2ah8V+gnHAaTDvFHssLz8KJS7OgJuessGGybChJYfmo+fwNFzOVUtfcWkTCJqkFDRz15hCYw==
1240+
dependencies:
1241+
"@types/node" "*"
1242+
12361243
"@types/range-parser@*":
12371244
version "1.2.3"
12381245
resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.3.tgz#7ee330ba7caafb98090bece86a5ee44115904c2c"
@@ -1779,6 +1786,13 @@ camelize@1.0.0:
17791786
resolved "https://registry.yarnpkg.com/camelize/-/camelize-1.0.0.tgz#164a5483e630fa4321e5af07020e531831b2609b"
17801787
integrity sha1-FkpUg+Yw+kMh5a8HAg5TGDGyYJs=
17811788

1789+
can-promise@0.0.1:
1790+
version "0.0.1"
1791+
resolved "https://registry.yarnpkg.com/can-promise/-/can-promise-0.0.1.tgz#7a7597ad801fb14c8b22341dfec314b6bd6ad8d3"
1792+
integrity sha512-gzVrHyyrvgt0YpDm7pn04MQt8gjh0ZAhN4ZDyCRtGl6YnuuK6b4aiUTD7G52r9l4YNmxfTtEscb92vxtAlL6XQ==
1793+
dependencies:
1794+
window-or-global "^1.0.1"
1795+
17821796
caniuse-lite@^1.0.30000963:
17831797
version "1.0.30000967"
17841798
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30000967.tgz#a5039577806fccee80a04aaafb2c0890b1ee2f73"
@@ -2276,6 +2290,11 @@ diff-sequences@^24.3.0:
22762290
resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-24.3.0.tgz#0f20e8a1df1abddaf4d9c226680952e64118b975"
22772291
integrity sha512-xLqpez+Zj9GKSnPWS0WZw1igGocZ+uua8+y+5dDNTT934N3QuY1sp2LkHzwiaYQGz60hMq0pjAshdeXm5VUOEw==
22782292

2293+
dijkstrajs@^1.0.1:
2294+
version "1.0.1"
2295+
resolved "https://registry.yarnpkg.com/dijkstrajs/-/dijkstrajs-1.0.1.tgz#d3cd81221e3ea40742cfcde556d4e99e98ddc71b"
2296+
integrity sha1-082BIh4+pAdCz83lVtTpnpjdxxs=
2297+
22792298
dns-prefetch-control@0.1.0:
22802299
version "0.1.0"
22812300
resolved "https://registry.yarnpkg.com/dns-prefetch-control/-/dns-prefetch-control-0.1.0.tgz#60ddb457774e178f1f9415f0cabb0e85b0b300b2"
@@ -3489,6 +3508,11 @@ isarray@1.0.0, isarray@~1.0.0:
34893508
resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11"
34903509
integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=
34913510

3511+
isarray@^2.0.1:
3512+
version "2.0.4"
3513+
resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.4.tgz#38e7bcbb0f3ba1b7933c86ba1894ddfc3781bbb7"
3514+
integrity sha512-GMxXOiUirWg1xTKRipM0Ek07rX+ubx4nNVElTJdNLYmNO/2YrDkgJGw9CljXn+r4EWiDQg/8lsRdHyg2PJuUaA==
3515+
34923516
isexe@^2.0.0:
34933517
version "2.0.0"
34943518
resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
@@ -4818,6 +4842,13 @@ osenv@^0.1.4:
48184842
os-homedir "^1.0.0"
48194843
os-tmpdir "^1.0.0"
48204844

4845+
otplib@^11.0.1:
4846+
version "11.0.1"
4847+
resolved "https://registry.yarnpkg.com/otplib/-/otplib-11.0.1.tgz#7d64aa87029f07c99c7f96819fb10cdb67dea886"
4848+
integrity sha512-oi57teljNyWTC/JqJztHOtSGeFNDiDh5C1myd+faocUtFAX27Sm1mbx69kpEJ8/JqrblI3kAm4Pqd6tZJoOIBQ==
4849+
dependencies:
4850+
thirty-two "1.0.2"
4851+
48214852
output-file-sync@^2.0.0:
48224853
version "2.0.1"
48234854
resolved "https://registry.yarnpkg.com/output-file-sync/-/output-file-sync-2.0.1.tgz#f53118282f5f553c2799541792b723a4c71430c0"
@@ -5006,6 +5037,11 @@ pn@^1.1.0:
50065037
resolved "https://registry.yarnpkg.com/pn/-/pn-1.1.0.tgz#e2f4cef0e219f463c179ab37463e4e1ecdccbafb"
50075038
integrity sha512-2qHaIQr2VLRFoxe2nASzsV6ef4yOOH+Fi9FBOVH6cqeSgUnoyySPZkxzLuzd+RYOQTRpROA0ztTMqxROKSb/nA==
50085039

5040+
pngjs@^3.3.0:
5041+
version "3.4.0"
5042+
resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-3.4.0.tgz#99ca7d725965fb655814eaf65f38f12bbdbf555f"
5043+
integrity sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==
5044+
50095045
posix-character-classes@^0.1.0:
50105046
version "0.1.1"
50115047
resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab"
@@ -5105,6 +5141,17 @@ q@>=1.0.1:
51055141
resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7"
51065142
integrity sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc=
51075143

5144+
qrcode@^1.3.3:
5145+
version "1.3.3"
5146+
resolved "https://registry.yarnpkg.com/qrcode/-/qrcode-1.3.3.tgz#5ef50c0c890cffa1897f452070f0f094936993de"
5147+
integrity sha512-SH7V13AcJusH3GT8bMNOGz4w0L+LjcpNOU/NiOgtBhT/5DoWeZE6D5ntMJnJ84AMkoaM4kjJJoHoh9g++8lWFg==
5148+
dependencies:
5149+
can-promise "0.0.1"
5150+
dijkstrajs "^1.0.1"
5151+
isarray "^2.0.1"
5152+
pngjs "^3.3.0"
5153+
yargs "^12.0.5"
5154+
51085155
qs@6.7.0, qs@^6.5.2, qs@^6.6.0:
51095156
version "6.7.0"
51105157
resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc"
@@ -5115,6 +5162,11 @@ qs@~6.5.2:
51155162
resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36"
51165163
integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==
51175164

5165+
random-int@^2.0.0:
5166+
version "2.0.0"
5167+
resolved "https://registry.yarnpkg.com/random-int/-/random-int-2.0.0.tgz#0979bdef46207a11dbfdbf6cae980351ba8946ab"
5168+
integrity sha512-jCJ4a8BJ+z3f4SYSFtYIiRfcdxe2Bvh+Gg2J+LjriL3dVOtrF77u0tklYbO8acHoZQ7JlYJn3lNKfW5TFjcwdQ==
5169+
51185170
range-parser@~1.2.1:
51195171
version "1.2.1"
51205172
resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031"
@@ -5908,6 +5960,11 @@ test-exclude@^5.2.2:
59085960
read-pkg-up "^4.0.0"
59095961
require-main-filename "^2.0.0"
59105962

5963+
thirty-two@1.0.2:
5964+
version "1.0.2"
5965+
resolved "https://registry.yarnpkg.com/thirty-two/-/thirty-two-1.0.2.tgz#4ca2fffc02a51290d2744b9e3f557693ca6b627a"
5966+
integrity sha1-TKL//AKlEpDSdEueP1V2k8prYno=
5967+
59115968
throat@^4.0.0:
59125969
version "4.1.0"
59135970
resolved "https://registry.yarnpkg.com/throat/-/throat-4.1.0.tgz#89037cbc92c56ab18926e6ba4cbb200e15672a6a"
@@ -6353,6 +6410,11 @@ widest-line@^2.0.0:
63536410
dependencies:
63546411
string-width "^2.1.1"
63556412

6413+
window-or-global@^1.0.1:
6414+
version "1.0.1"
6415+
resolved "https://registry.yarnpkg.com/window-or-global/-/window-or-global-1.0.1.tgz#dbe45ba2a291aabc56d62cf66c45b7fa322946de"
6416+
integrity sha1-2+RboqKRqrxW1iz2bEW3+jIpRt4=
6417+
63566418
wordwrap@~0.0.2:
63576419
version "0.0.3"
63586420
resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.3.tgz#a3d5da6cd5c0bc0008d37234bbaf1bed63059107"
@@ -6459,7 +6521,7 @@ yargs-parser@^11.1.1:
64596521
camelcase "^5.0.0"
64606522
decamelize "^1.2.0"
64616523

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

0 commit comments

Comments
 (0)