2fa.js 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  1. import crypto from "node:crypto";
  2. import bcrypt from "bcrypt";
  3. import { createGuardrails, generateSecret, generateURI, verify } from "otplib";
  4. import errs from "../lib/error.js";
  5. import authModel from "../models/auth.js";
  6. import internalUser from "./user.js";
  7. const APP_NAME = "Nginx Proxy Manager";
  8. const BACKUP_CODE_COUNT = 8;
  9. /**
  10. * Generate backup codes
  11. * @returns {Promise<{plain: string[], hashed: string[]}>}
  12. */
  13. const generateBackupCodes = async () => {
  14. const plain = [];
  15. const hashed = [];
  16. for (let i = 0; i < BACKUP_CODE_COUNT; i++) {
  17. const code = crypto.randomBytes(4).toString("hex").toUpperCase();
  18. plain.push(code);
  19. const hash = await bcrypt.hash(code, 10);
  20. hashed.push(hash);
  21. }
  22. return { plain, hashed };
  23. };
  24. const internal2fa = {
  25. /**
  26. * Check if user has 2FA enabled
  27. * @param {number} userId
  28. * @returns {Promise<boolean>}
  29. */
  30. isEnabled: async (userId) => {
  31. const auth = await internal2fa.getUserPasswordAuth(userId);
  32. return auth?.meta?.totp_enabled === true;
  33. },
  34. /**
  35. * Get 2FA status for user
  36. * @param {Access} access
  37. * @param {number} userId
  38. * @returns {Promise<{enabled: boolean, backup_codes_remaining: number}>}
  39. */
  40. getStatus: async (access, userId) => {
  41. await access.can("users:password", userId);
  42. await internalUser.get(access, { id: userId });
  43. const auth = await internal2fa.getUserPasswordAuth(userId);
  44. const enabled = auth?.meta?.totp_enabled === true;
  45. let backup_codes_remaining = 0;
  46. if (enabled) {
  47. const backupCodes = auth.meta.backup_codes || [];
  48. backup_codes_remaining = backupCodes.length;
  49. }
  50. return {
  51. enabled,
  52. backup_codes_remaining,
  53. };
  54. },
  55. /**
  56. * Start 2FA setup - store pending secret
  57. *
  58. * @param {Access} access
  59. * @param {number} userId
  60. * @returns {Promise<{secret: string, otpauth_url: string}>}
  61. */
  62. startSetup: async (access, userId) => {
  63. await access.can("users:password", userId);
  64. const user = await internalUser.get(access, { id: userId });
  65. const secret = generateSecret();
  66. const otpauth_url = generateURI({
  67. issuer: APP_NAME,
  68. label: user.email,
  69. secret: secret,
  70. });
  71. const auth = await internal2fa.getUserPasswordAuth(userId);
  72. // ensure user isn't already setup for 2fa
  73. const enabled = auth?.meta?.totp_enabled === true;
  74. if (enabled) {
  75. throw new errs.ValidationError("2FA is already enabled");
  76. }
  77. const meta = auth.meta || {};
  78. meta.totp_pending_secret = secret;
  79. await authModel
  80. .query()
  81. .where("id", auth.id)
  82. .andWhere("user_id", userId)
  83. .andWhere("type", "password")
  84. .patch({ meta });
  85. return { secret, otpauth_url };
  86. },
  87. /**
  88. * Enable 2FA after verifying code
  89. *
  90. * @param {Access} access
  91. * @param {number} userId
  92. * @param {string} code
  93. * @returns {Promise<{backup_codes: string[]}>}
  94. */
  95. enable: async (access, userId, code) => {
  96. await access.can("users:password", userId);
  97. await internalUser.get(access, { id: userId });
  98. const auth = await internal2fa.getUserPasswordAuth(userId);
  99. const secret = auth?.meta?.totp_pending_secret || false;
  100. if (!secret) {
  101. throw new errs.ValidationError("No pending 2FA setup found");
  102. }
  103. const result = await verify({ token: code, secret });
  104. if (!result.valid) {
  105. throw new errs.ValidationError("Invalid verification code");
  106. }
  107. const { plain, hashed } = await generateBackupCodes();
  108. const meta = {
  109. ...auth.meta,
  110. totp_secret: secret,
  111. totp_enabled: true,
  112. totp_enabled_at: new Date().toISOString(),
  113. backup_codes: hashed,
  114. };
  115. delete meta.totp_pending_secret;
  116. await authModel
  117. .query()
  118. .where("id", auth.id)
  119. .andWhere("user_id", userId)
  120. .andWhere("type", "password")
  121. .patch({ meta });
  122. return { backup_codes: plain };
  123. },
  124. /**
  125. * Disable 2FA
  126. *
  127. * @param {Access} access
  128. * @param {number} userId
  129. * @param {string} code
  130. * @returns {Promise<void>}
  131. */
  132. disable: async (access, userId, code) => {
  133. await access.can("users:password", userId);
  134. await internalUser.get(access, { id: userId });
  135. const auth = await internal2fa.getUserPasswordAuth(userId);
  136. const enabled = auth?.meta?.totp_enabled === true;
  137. if (!enabled) {
  138. throw new errs.ValidationError("2FA is not enabled");
  139. }
  140. const result = await verify({
  141. token: code,
  142. secret: auth.meta.totp_secret,
  143. guardrails: createGuardrails({
  144. MIN_SECRET_BYTES: 10,
  145. }),
  146. });
  147. if (!result.valid) {
  148. throw new errs.AuthError("Invalid verification code");
  149. }
  150. const meta = { ...auth.meta };
  151. delete meta.totp_secret;
  152. delete meta.totp_enabled;
  153. delete meta.totp_enabled_at;
  154. delete meta.backup_codes;
  155. await authModel
  156. .query()
  157. .where("id", auth.id)
  158. .andWhere("user_id", userId)
  159. .andWhere("type", "password")
  160. .patch({ meta });
  161. },
  162. /**
  163. * Verify 2FA code for login
  164. *
  165. * @param {number} userId
  166. * @param {string} token
  167. * @returns {Promise<boolean>}
  168. */
  169. verifyForLogin: async (userId, token) => {
  170. const auth = await internal2fa.getUserPasswordAuth(userId);
  171. const secret = auth?.meta?.totp_secret || false;
  172. if (!secret) {
  173. return false;
  174. }
  175. // Try TOTP code first, if it's 6 chars. it will throw errors if it's not 6 chars
  176. // and the backup codes are 8 chars.
  177. if (token.length === 6) {
  178. const result = await verify({
  179. token,
  180. secret,
  181. // These guardrails lower the minimum length requirement for secrets.
  182. // In v12 of otplib the default minimum length is 10 and in v13 it is 16.
  183. // Since there are 2fa secrets in the wild generated with v12 we need to allow shorter secrets
  184. // so people won't be locked out when upgrading.
  185. guardrails: createGuardrails({
  186. MIN_SECRET_BYTES: 10,
  187. }),
  188. });
  189. if (result.valid) {
  190. return true;
  191. }
  192. }
  193. // Try backup codes
  194. const backupCodes = auth?.meta?.backup_codes || [];
  195. for (let i = 0; i < backupCodes.length; i++) {
  196. const match = await bcrypt.compare(token.toUpperCase(), backupCodes[i]);
  197. if (match) {
  198. // Remove used backup code
  199. const updatedCodes = [...backupCodes];
  200. updatedCodes.splice(i, 1);
  201. const meta = { ...auth.meta, backup_codes: updatedCodes };
  202. await authModel
  203. .query()
  204. .where("id", auth.id)
  205. .andWhere("user_id", userId)
  206. .andWhere("type", "password")
  207. .patch({ meta });
  208. return true;
  209. }
  210. }
  211. return false;
  212. },
  213. /**
  214. * Regenerate backup codes
  215. *
  216. * @param {Access} access
  217. * @param {number} userId
  218. * @param {string} token
  219. * @returns {Promise<{backup_codes: string[]}>}
  220. */
  221. regenerateBackupCodes: async (access, userId, token) => {
  222. await access.can("users:password", userId);
  223. await internalUser.get(access, { id: userId });
  224. const auth = await internal2fa.getUserPasswordAuth(userId);
  225. const enabled = auth?.meta?.totp_enabled === true;
  226. const secret = auth?.meta?.totp_secret || false;
  227. if (!enabled) {
  228. throw new errs.ValidationError("2FA is not enabled");
  229. }
  230. if (!secret) {
  231. throw new errs.ValidationError("No 2FA secret found");
  232. }
  233. const result = await verify({
  234. token,
  235. secret,
  236. });
  237. if (!result.valid) {
  238. throw new errs.ValidationError("Invalid verification code");
  239. }
  240. const { plain, hashed } = await generateBackupCodes();
  241. const meta = { ...auth.meta, backup_codes: hashed };
  242. await authModel
  243. .query()
  244. .where("id", auth.id)
  245. .andWhere("user_id", userId)
  246. .andWhere("type", "password")
  247. .patch({ meta });
  248. return { backup_codes: plain };
  249. },
  250. getUserPasswordAuth: async (userId) => {
  251. const auth = await authModel
  252. .query()
  253. .where("user_id", userId)
  254. .andWhere("type", "password")
  255. .first();
  256. if (!auth) {
  257. throw new errs.ItemNotFoundError("Auth not found");
  258. }
  259. return auth;
  260. },
  261. };
  262. export default internal2fa;