From 16480f6e9572011fadeb981a388b92cb646fa6d9 Mon Sep 17 00:00:00 2001 From: Steve Date: Wed, 21 Dec 2022 11:58:37 -0500 Subject: [PATCH] feat: TOTP (two-factor) Authentication (#55) * Working on some initial prototype stuff for TOTP * Fixed a bug that prevented the change password menu from working * Enable/disable totp working * Added the new login procedure including TOTP! :) * misc: Changed bad description for the TOTP_SECRET env var * I forgot to include the migration for the new prisma stuff * fix: refresh user context instead refreshing the page * refactor: simplify totp error handling * Removed U2F tab + format schema * fix: tokens not saved in cookies * refactor: deleted commented out code * refactor: move password text to input description * refactor: remove tabler icon package Co-authored-by: Elias Schneider Co-authored-by: Elias Schneider <58886915+stonith404@users.noreply.github.com> --- backend/package-lock.json | 251 +++++++++++++----- backend/package.json | 3 + .../20221219061131_user_entity/migration.sql | 31 +++ backend/prisma/schema.prisma | 16 ++ backend/prisma/seed/config.seed.ts | 7 + backend/src/auth/auth.controller.ts | 30 +++ backend/src/auth/auth.service.ts | 226 +++++++++++++++- backend/src/auth/dto/authSignInTotp.dto.ts | 21 ++ backend/src/auth/dto/enableTotp.dto.ts | 5 + backend/src/auth/dto/verifyTotp.dto.ts | 8 + backend/src/user/dto/user.dto.ts | 3 + .../account/showEnableTotpModal.tsx | 131 +++++++++ frontend/src/components/auth/SignInForm.tsx | 68 ++++- frontend/src/components/auth/SignUpForm.tsx | 13 +- .../src/components/navBar/ActionAvatar.tsx | 2 +- frontend/src/components/navBar/NavBar.tsx | 2 +- frontend/src/hooks/user.hook.ts | 7 +- frontend/src/pages/_app.tsx | 2 +- frontend/src/pages/account/index.tsx | 172 ++++++++++-- frontend/src/pages/account/shares.tsx | 2 +- frontend/src/pages/admin/setup.tsx | 2 +- frontend/src/pages/auth/signIn.tsx | 2 +- frontend/src/pages/auth/signUp.tsx | 2 +- frontend/src/pages/index.tsx | 2 +- frontend/src/pages/upload.tsx | 2 +- frontend/src/services/auth.service.ts | 57 +++- frontend/src/types/user.type.ts | 6 + 27 files changed, 946 insertions(+), 127 deletions(-) create mode 100644 backend/prisma/migrations/20221219061131_user_entity/migration.sql create mode 100644 backend/src/auth/dto/authSignInTotp.dto.ts create mode 100644 backend/src/auth/dto/enableTotp.dto.ts create mode 100644 backend/src/auth/dto/verifyTotp.dto.ts create mode 100644 frontend/src/components/account/showEnableTotpModal.tsx diff --git a/backend/package-lock.json b/backend/package-lock.json index 5dd984c4..d734a21c 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -17,6 +17,7 @@ "@nestjs/platform-express": "^9.2.1", "@nestjs/schedule": "^2.1.0", "@nestjs/throttler": "^3.1.0", + "@prisma/client": "^4.7.1", "archiver": "^5.3.1", "argon2": "^0.30.2", "class-transformer": "^0.5.1", @@ -26,18 +27,20 @@ "moment": "^2.29.4", "multer": "^1.4.5-lts.1", "nodemailer": "^6.8.0", + "otplib": "^12.0.1", "passport": "^0.6.0", "passport-jwt": "^4.0.0", "passport-local": "^1.0.0", + "qrcode-svg": "^1.1.0", "reflect-metadata": "^0.1.13", "rimraf": "^3.0.2", - "rxjs": "^7.6.0" + "rxjs": "^7.6.0", + "ts-node": "^10.9.1" }, "devDependencies": { "@nestjs/cli": "^9.1.5", "@nestjs/schematics": "^9.0.3", "@nestjs/testing": "^9.2.1", - "@prisma/client": "^4.7.1", "@types/archiver": "^5.3.1", "@types/cron": "^2.0.0", "@types/express": "^4.17.14", @@ -46,6 +49,7 @@ "@types/node": "^18.11.10", "@types/nodemailer": "^6.4.6", "@types/passport-jwt": "^3.0.7", + "@types/qrcode-svg": "^1.1.1", "@types/supertest": "^2.0.12", "@typescript-eslint/eslint-plugin": "^5.45.0", "@typescript-eslint/parser": "^5.45.0", @@ -58,7 +62,6 @@ "prisma": "^4.7.1", "source-map-support": "^0.5.21", "ts-loader": "^9.4.2", - "ts-node": "^10.9.1", "tsconfig-paths": "4.1.1", "typescript": "^4.9.3", "wait-on": "^6.0.1" @@ -328,7 +331,6 @@ "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "dev": true, "dependencies": { "@jridgewell/trace-mapping": "0.3.9" }, @@ -340,7 +342,6 @@ "version": "0.3.9", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" @@ -443,7 +444,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==", - "dev": true, "engines": { "node": ">=6.0.0" } @@ -484,8 +484,7 @@ "node_modules/@jridgewell/sourcemap-codec": { "version": "1.4.14", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", - "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==", - "dev": true + "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.15", @@ -975,6 +974,48 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/@otplib/core": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/core/-/core-12.0.1.tgz", + "integrity": "sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==" + }, + "node_modules/@otplib/plugin-crypto": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/plugin-crypto/-/plugin-crypto-12.0.1.tgz", + "integrity": "sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==", + "dependencies": { + "@otplib/core": "^12.0.1" + } + }, + "node_modules/@otplib/plugin-thirty-two": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/plugin-thirty-two/-/plugin-thirty-two-12.0.1.tgz", + "integrity": "sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==", + "dependencies": { + "@otplib/core": "^12.0.1", + "thirty-two": "^1.0.2" + } + }, + "node_modules/@otplib/preset-default": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/preset-default/-/preset-default-12.0.1.tgz", + "integrity": "sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ==", + "dependencies": { + "@otplib/core": "^12.0.1", + "@otplib/plugin-crypto": "^12.0.1", + "@otplib/plugin-thirty-two": "^12.0.1" + } + }, + "node_modules/@otplib/preset-v11": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/preset-v11/-/preset-v11-12.0.1.tgz", + "integrity": "sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==", + "dependencies": { + "@otplib/core": "^12.0.1", + "@otplib/plugin-crypto": "^12.0.1", + "@otplib/plugin-thirty-two": "^12.0.1" + } + }, "node_modules/@phc/format": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz", @@ -1013,7 +1054,6 @@ "version": "4.7.1", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-4.7.1.tgz", "integrity": "sha512-/GbnOwIPtjiveZNUzGXOdp7RxTEkHL4DZP3vBaFNadfr6Sf0RshU5EULFzVaSi9i9PIK9PYd+1Rn7z2B2npb9w==", - "dev": true, "hasInstallScript": true, "dependencies": { "@prisma/engines-version": "4.7.1-1.272861e07ab64f234d3ffc4094e32bd61775599c" @@ -1034,14 +1074,13 @@ "version": "4.7.1", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-4.7.1.tgz", "integrity": "sha512-zWabHosTdLpXXlMefHmnouhXMoTB1+SCbUU3t4FCmdrtIOZcarPKU3Alto7gm/pZ9vHlGOXHCfVZ1G7OIrSbog==", - "dev": true, + "devOptional": true, "hasInstallScript": true }, "node_modules/@prisma/engines-version": { "version": "4.7.1-1.272861e07ab64f234d3ffc4094e32bd61775599c", "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-4.7.1-1.272861e07ab64f234d3ffc4094e32bd61775599c.tgz", - "integrity": "sha512-Bd4LZ+WAnUHOq31e9X/ihi5zPlr4SzTRwUZZYxvWOxlerIZ7HJlVa9zXpuKTKLpI9O1l8Ec4OYCKsivWCs5a3Q==", - "dev": true + "integrity": "sha512-Bd4LZ+WAnUHOq31e9X/ihi5zPlr4SzTRwUZZYxvWOxlerIZ7HJlVa9zXpuKTKLpI9O1l8Ec4OYCKsivWCs5a3Q==" }, "node_modules/@sideway/address": { "version": "4.1.4", @@ -1067,26 +1106,22 @@ "node_modules/@tsconfig/node10": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", - "integrity": "sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==", - "dev": true + "integrity": "sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==" }, "node_modules/@tsconfig/node12": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", - "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "dev": true + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==" }, "node_modules/@tsconfig/node14": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", - "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "dev": true + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==" }, "node_modules/@tsconfig/node16": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.3.tgz", - "integrity": "sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==", - "dev": true + "integrity": "sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==" }, "node_modules/@types/archiver": { "version": "5.3.1", @@ -1288,6 +1323,12 @@ "@types/passport": "*" } }, + "node_modules/@types/qrcode-svg": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@types/qrcode-svg/-/qrcode-svg-1.1.1.tgz", + "integrity": "sha512-uTuEgFXMknpun//Jj6b1R8T8LiMi9fNpH+cnhZr4b7col2HHTMmjYfm/WOZ7nzjuGpk+oTrpHhePe1qlWtHWTA==", + "dev": true + }, "node_modules/@types/qs": { "version": "6.9.7", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz", @@ -1701,7 +1742,6 @@ "version": "8.8.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.0.tgz", "integrity": "sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w==", - "dev": true, "bin": { "acorn": "bin/acorn" }, @@ -1731,7 +1771,6 @@ "version": "8.2.0", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", - "dev": true, "engines": { "node": ">=0.4.0" } @@ -1939,8 +1978,7 @@ "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", - "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "dev": true + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==" }, "node_modules/argon2": { "version": "0.30.2", @@ -2674,8 +2712,7 @@ "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", - "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "dev": true + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==" }, "node_modules/cron": { "version": "2.0.0", @@ -2826,7 +2863,6 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", - "dev": true, "engines": { "node": ">=0.3.1" } @@ -4736,8 +4772,7 @@ "node_modules/make-error": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==" }, "node_modules/md5": { "version": "2.3.0", @@ -5320,6 +5355,16 @@ "node": ">=0.10.0" } }, + "node_modules/otplib": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/otplib/-/otplib-12.0.1.tgz", + "integrity": "sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==", + "dependencies": { + "@otplib/core": "^12.0.1", + "@otplib/preset-default": "^12.0.1", + "@otplib/preset-v11": "^12.0.1" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -5857,7 +5902,7 @@ "version": "4.7.1", "resolved": "https://registry.npmjs.org/prisma/-/prisma-4.7.1.tgz", "integrity": "sha512-CCQP+m+1qZOGIZlvnL6T3ZwaU0LAleIHYFPN9tFSzjs/KL6vH9rlYbGOkTuG9Q1s6Ki5D0LJlYlW18Z9EBUpGg==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "dependencies": { "@prisma/engines": "4.7.1" @@ -5912,6 +5957,14 @@ "node": ">=6" } }, + "node_modules/qrcode-svg": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/qrcode-svg/-/qrcode-svg-1.1.0.tgz", + "integrity": "sha512-XyQCIXux1zEIA3NPb0AeR8UMYvXZzWEhgdBgBjH9gO7M48H9uoHzviNz8pXw3UzrAcxRRRn9gxHewAVK7bn9qw==", + "bin": { + "qrcode-svg": "bin/qrcode-svg.js" + } + }, "node_modules/qs": { "version": "6.11.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", @@ -6787,6 +6840,14 @@ "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true }, + "node_modules/thirty-two": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/thirty-two/-/thirty-two-1.0.2.tgz", + "integrity": "sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==", + "engines": { + "node": ">=0.2.6" + } + }, "node_modules/through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", @@ -6892,7 +6953,6 @@ "version": "10.9.1", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz", "integrity": "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==", - "dev": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -7052,7 +7112,6 @@ "version": "4.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.3.tgz", "integrity": "sha512-CIfGzTelbKNEnLpLdGFgdyKhG23CKdKgQPOBc+OUNrkJ2vr+KSzsSV5kq5iWhEQbok+quxgGzrAtGWCyU7tHnA==", - "dev": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7174,8 +7233,7 @@ "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", - "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "dev": true + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==" }, "node_modules/validator": { "version": "13.7.0", @@ -7499,7 +7557,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "dev": true, "engines": { "node": ">=6" } @@ -7748,7 +7805,6 @@ "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "dev": true, "requires": { "@jridgewell/trace-mapping": "0.3.9" }, @@ -7757,7 +7813,6 @@ "version": "0.3.9", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, "requires": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" @@ -7843,8 +7898,7 @@ "@jridgewell/resolve-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", - "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==", - "dev": true + "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==" }, "@jridgewell/set-array": { "version": "1.1.2", @@ -7878,8 +7932,7 @@ "@jridgewell/sourcemap-codec": { "version": "1.4.14", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", - "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==", - "dev": true + "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==" }, "@jridgewell/trace-mapping": { "version": "0.3.15", @@ -8205,6 +8258,48 @@ } } }, + "@otplib/core": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/core/-/core-12.0.1.tgz", + "integrity": "sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==" + }, + "@otplib/plugin-crypto": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/plugin-crypto/-/plugin-crypto-12.0.1.tgz", + "integrity": "sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==", + "requires": { + "@otplib/core": "^12.0.1" + } + }, + "@otplib/plugin-thirty-two": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/plugin-thirty-two/-/plugin-thirty-two-12.0.1.tgz", + "integrity": "sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==", + "requires": { + "@otplib/core": "^12.0.1", + "thirty-two": "^1.0.2" + } + }, + "@otplib/preset-default": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/preset-default/-/preset-default-12.0.1.tgz", + "integrity": "sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ==", + "requires": { + "@otplib/core": "^12.0.1", + "@otplib/plugin-crypto": "^12.0.1", + "@otplib/plugin-thirty-two": "^12.0.1" + } + }, + "@otplib/preset-v11": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/preset-v11/-/preset-v11-12.0.1.tgz", + "integrity": "sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==", + "requires": { + "@otplib/core": "^12.0.1", + "@otplib/plugin-crypto": "^12.0.1", + "@otplib/plugin-thirty-two": "^12.0.1" + } + }, "@phc/format": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz", @@ -8234,7 +8329,6 @@ "version": "4.7.1", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-4.7.1.tgz", "integrity": "sha512-/GbnOwIPtjiveZNUzGXOdp7RxTEkHL4DZP3vBaFNadfr6Sf0RshU5EULFzVaSi9i9PIK9PYd+1Rn7z2B2npb9w==", - "dev": true, "requires": { "@prisma/engines-version": "4.7.1-1.272861e07ab64f234d3ffc4094e32bd61775599c" } @@ -8243,13 +8337,12 @@ "version": "4.7.1", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-4.7.1.tgz", "integrity": "sha512-zWabHosTdLpXXlMefHmnouhXMoTB1+SCbUU3t4FCmdrtIOZcarPKU3Alto7gm/pZ9vHlGOXHCfVZ1G7OIrSbog==", - "dev": true + "devOptional": true }, "@prisma/engines-version": { "version": "4.7.1-1.272861e07ab64f234d3ffc4094e32bd61775599c", "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-4.7.1-1.272861e07ab64f234d3ffc4094e32bd61775599c.tgz", - "integrity": "sha512-Bd4LZ+WAnUHOq31e9X/ihi5zPlr4SzTRwUZZYxvWOxlerIZ7HJlVa9zXpuKTKLpI9O1l8Ec4OYCKsivWCs5a3Q==", - "dev": true + "integrity": "sha512-Bd4LZ+WAnUHOq31e9X/ihi5zPlr4SzTRwUZZYxvWOxlerIZ7HJlVa9zXpuKTKLpI9O1l8Ec4OYCKsivWCs5a3Q==" }, "@sideway/address": { "version": "4.1.4", @@ -8275,26 +8368,22 @@ "@tsconfig/node10": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", - "integrity": "sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==", - "dev": true + "integrity": "sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==" }, "@tsconfig/node12": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", - "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "dev": true + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==" }, "@tsconfig/node14": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", - "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "dev": true + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==" }, "@tsconfig/node16": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.3.tgz", - "integrity": "sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==", - "dev": true + "integrity": "sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==" }, "@types/archiver": { "version": "5.3.1", @@ -8496,6 +8585,12 @@ "@types/passport": "*" } }, + "@types/qrcode-svg": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@types/qrcode-svg/-/qrcode-svg-1.1.1.tgz", + "integrity": "sha512-uTuEgFXMknpun//Jj6b1R8T8LiMi9fNpH+cnhZr4b7col2HHTMmjYfm/WOZ7nzjuGpk+oTrpHhePe1qlWtHWTA==", + "dev": true + }, "@types/qs": { "version": "6.9.7", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz", @@ -8816,8 +8911,7 @@ "acorn": { "version": "8.8.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.0.tgz", - "integrity": "sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w==", - "dev": true + "integrity": "sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w==" }, "acorn-import-assertions": { "version": "1.8.0", @@ -8836,8 +8930,7 @@ "acorn-walk": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", - "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", - "dev": true + "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==" }, "agent-base": { "version": "6.0.2", @@ -8991,8 +9084,7 @@ "arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", - "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "dev": true + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==" }, "argon2": { "version": "0.30.2", @@ -9539,8 +9631,7 @@ "create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", - "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "dev": true + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==" }, "cron": { "version": "2.0.0", @@ -9648,8 +9739,7 @@ "diff": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", - "dev": true + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==" }, "dir-glob": { "version": "3.0.1", @@ -11123,8 +11213,7 @@ "make-error": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==" }, "md5": { "version": "2.3.0", @@ -11563,6 +11652,16 @@ "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", "dev": true }, + "otplib": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/otplib/-/otplib-12.0.1.tgz", + "integrity": "sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==", + "requires": { + "@otplib/core": "^12.0.1", + "@otplib/preset-default": "^12.0.1", + "@otplib/preset-v11": "^12.0.1" + } + }, "p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -11959,7 +12058,7 @@ "version": "4.7.1", "resolved": "https://registry.npmjs.org/prisma/-/prisma-4.7.1.tgz", "integrity": "sha512-CCQP+m+1qZOGIZlvnL6T3ZwaU0LAleIHYFPN9tFSzjs/KL6vH9rlYbGOkTuG9Q1s6Ki5D0LJlYlW18Z9EBUpGg==", - "dev": true, + "devOptional": true, "requires": { "@prisma/engines": "4.7.1" } @@ -12000,6 +12099,11 @@ "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", "dev": true }, + "qrcode-svg": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/qrcode-svg/-/qrcode-svg-1.1.0.tgz", + "integrity": "sha512-XyQCIXux1zEIA3NPb0AeR8UMYvXZzWEhgdBgBjH9gO7M48H9uoHzviNz8pXw3UzrAcxRRRn9gxHewAVK7bn9qw==" + }, "qs": { "version": "6.11.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", @@ -12645,6 +12749,11 @@ "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true }, + "thirty-two": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/thirty-two/-/thirty-two-1.0.2.tgz", + "integrity": "sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==" + }, "through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", @@ -12724,7 +12833,6 @@ "version": "10.9.1", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz", "integrity": "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==", - "dev": true, "requires": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -12835,8 +12943,7 @@ "typescript": { "version": "4.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.3.tgz", - "integrity": "sha512-CIfGzTelbKNEnLpLdGFgdyKhG23CKdKgQPOBc+OUNrkJ2vr+KSzsSV5kq5iWhEQbok+quxgGzrAtGWCyU7tHnA==", - "dev": true + "integrity": "sha512-CIfGzTelbKNEnLpLdGFgdyKhG23CKdKgQPOBc+OUNrkJ2vr+KSzsSV5kq5iWhEQbok+quxgGzrAtGWCyU7tHnA==" }, "uglify-js": { "version": "3.17.3", @@ -12916,8 +13023,7 @@ "v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", - "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "dev": true + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==" }, "validator": { "version": "13.7.0", @@ -13157,8 +13263,7 @@ "yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", - "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "dev": true + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==" }, "yocto-queue": { "version": "0.1.0", diff --git a/backend/package.json b/backend/package.json index b2a81bfd..9f6e8e06 100644 --- a/backend/package.json +++ b/backend/package.json @@ -32,9 +32,11 @@ "moment": "^2.29.4", "multer": "^1.4.5-lts.1", "nodemailer": "^6.8.0", + "otplib": "^12.0.1", "passport": "^0.6.0", "passport-jwt": "^4.0.0", "passport-local": "^1.0.0", + "qrcode-svg": "^1.1.0", "reflect-metadata": "^0.1.13", "rimraf": "^3.0.2", "rxjs": "^7.6.0", @@ -52,6 +54,7 @@ "@types/node": "^18.11.10", "@types/nodemailer": "^6.4.6", "@types/passport-jwt": "^3.0.7", + "@types/qrcode-svg": "^1.1.1", "@types/supertest": "^2.0.12", "@typescript-eslint/eslint-plugin": "^5.45.0", "@typescript-eslint/parser": "^5.45.0", diff --git a/backend/prisma/migrations/20221219061131_user_entity/migration.sql b/backend/prisma/migrations/20221219061131_user_entity/migration.sql new file mode 100644 index 00000000..fe3e439c --- /dev/null +++ b/backend/prisma/migrations/20221219061131_user_entity/migration.sql @@ -0,0 +1,31 @@ +-- CreateTable +CREATE TABLE "LoginToken" ( + "token" TEXT NOT NULL PRIMARY KEY, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expiresAt" DATETIME NOT NULL, + "userId" TEXT NOT NULL, + "used" BOOLEAN NOT NULL DEFAULT false, + CONSTRAINT "LoginToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- RedefineTables +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_User" ( + "id" TEXT NOT NULL PRIMARY KEY, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + "username" TEXT NOT NULL, + "email" TEXT NOT NULL, + "password" TEXT NOT NULL, + "isAdmin" BOOLEAN NOT NULL DEFAULT false, + "totpEnabled" BOOLEAN NOT NULL DEFAULT false, + "totpVerified" BOOLEAN NOT NULL DEFAULT false, + "totpSecret" TEXT +); +INSERT INTO "new_User" ("createdAt", "email", "id", "isAdmin", "password", "updatedAt", "username") SELECT "createdAt", "email", "id", "isAdmin", "password", "updatedAt", "username" FROM "User"; +DROP TABLE "User"; +ALTER TABLE "new_User" RENAME TO "User"; +CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); +PRAGMA foreign_key_check; +PRAGMA foreign_keys=ON; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 04b07335..65031048 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -19,6 +19,11 @@ model User { shares Share[] refreshTokens RefreshToken[] + loginTokens LoginToken[] + + totpEnabled Boolean @default(false) + totpVerified Boolean @default(false) + totpSecret String? } model RefreshToken { @@ -31,6 +36,17 @@ model RefreshToken { user User @relation(fields: [userId], references: [id], onDelete: Cascade) } +model LoginToken { + token String @id @default(uuid()) + createdAt DateTime @default(now()) + + expiresAt DateTime + + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + used Boolean @default(false) +} + model Share { id String @id @default(uuid()) createdAt DateTime @default(now()) diff --git a/backend/prisma/seed/config.seed.ts b/backend/prisma/seed/config.seed.ts index 4220bda1..6ae546b2 100644 --- a/backend/prisma/seed/config.seed.ts +++ b/backend/prisma/seed/config.seed.ts @@ -52,6 +52,13 @@ const configVariables: Prisma.ConfigCreateInput[] = [ value: crypto.randomBytes(256).toString("base64"), locked: true, }, + { + key: "TOTP_SECRET", + description: "A 16 byte random string used to generate TOTP secrets", + type: "string", + value: crypto.randomBytes(16).toString("base64"), + locked: true, + }, { key: "ENABLE_EMAIL_RECIPIENTS", description: diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index f9afde38..291ca512 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -14,8 +14,11 @@ import { AuthService } from "./auth.service"; import { GetUser } from "./decorator/getUser.decorator"; import { AuthRegisterDTO } from "./dto/authRegister.dto"; import { AuthSignInDTO } from "./dto/authSignIn.dto"; +import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto"; +import { EnableTotpDTO } from "./dto/enableTotp.dto"; import { RefreshAccessTokenDTO } from "./dto/refreshAccessToken.dto"; import { UpdatePasswordDTO } from "./dto/updatePassword.dto"; +import { VerifyTotpDTO } from "./dto/verifyTotp.dto"; import { JwtGuard } from "./guard/jwt.guard"; @Controller("auth") @@ -40,6 +43,13 @@ export class AuthController { return this.authService.signIn(dto); } + @Throttle(10, 5 * 60) + @Post("signIn/totp") + @HttpCode(200) + signInTotp(@Body() dto: AuthSignInTotpDTO) { + return this.authService.signInTotp(dto); + } + @Patch("password") @UseGuards(JwtGuard) async updatePassword(@GetUser() user: User, @Body() dto: UpdatePasswordDTO) { @@ -54,4 +64,24 @@ export class AuthController { ); return { accessToken }; } + + // TODO: Implement recovery codes to disable 2FA just in case someone gets locked out + @Post("totp/enable") + @UseGuards(JwtGuard) + async enableTotp(@GetUser() user: User, @Body() body: EnableTotpDTO) { + return this.authService.enableTotp(user, body.password); + } + + @Post("totp/verify") + @UseGuards(JwtGuard) + async verifyTotp(@GetUser() user: User, @Body() body: VerifyTotpDTO) { + return this.authService.verifyTotp(user, body.password, body.code); + } + + @Post("totp/disable") + @UseGuards(JwtGuard) + async disableTotp(@GetUser() user: User, @Body() body: VerifyTotpDTO) { + // Note: We use VerifyTotpDTO here because it has both fields we need: password and totp code + return this.authService.disableTotp(user, body.password, body.code); + } } diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index 76f8fff4..89caf837 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -13,6 +13,10 @@ import { ConfigService } from "src/config/config.service"; import { PrismaService } from "src/prisma/prisma.service"; import { AuthRegisterDTO } from "./dto/authRegister.dto"; import { AuthSignInDTO } from "./dto/authSignIn.dto"; +import { authenticator, totp } from "otplib"; +import * as qrcode from "qrcode-svg"; +import * as crypto from "crypto"; +import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto"; @Injectable() export class AuthService { @@ -63,6 +67,69 @@ export class AuthService { if (!user || !(await argon.verify(user.password, dto.password))) throw new UnauthorizedException("Wrong email or password"); + // TODO: Make all old loginTokens invalid when a new one is created + // Check if the user has TOTP enabled + if (user.totpVerified) { + const loginToken = await this.createLoginToken(user.id); + + return { loginToken }; + } + + const accessToken = await this.createAccessToken(user); + const refreshToken = await this.createRefreshToken(user.id); + + return { accessToken, refreshToken }; + } + + async signInTotp(dto: AuthSignInTotpDTO) { + if (!dto.email && !dto.username) + throw new BadRequestException("Email or username is required"); + + const user = await this.prisma.user.findFirst({ + where: { + OR: [{ email: dto.email }, { username: dto.username }], + }, + }); + + if (!user || !(await argon.verify(user.password, dto.password))) + throw new UnauthorizedException("Wrong email or password"); + + const token = await this.prisma.loginToken.findFirst({ + where: { + token: dto.loginToken, + }, + }); + + if (!token || token.userId != user.id || token.used) + throw new UnauthorizedException("Invalid login token"); + + if (token.expiresAt < new Date()) + throw new UnauthorizedException("Login token expired"); + + // Check the TOTP code + const { totpSecret } = await this.prisma.user.findUnique({ + where: { id: user.id }, + select: { totpSecret: true }, + }); + + if (!totpSecret) { + throw new BadRequestException("TOTP is not enabled"); + } + + const decryptedSecret = this.decryptTotpSecret(totpSecret, dto.password); + + const expected = authenticator.generate(decryptedSecret); + + if (dto.totp !== expected) { + throw new BadRequestException("Invalid code"); + } + + // Set the login token to used + await this.prisma.loginToken.update({ + where: { token: token.token }, + data: { used: true }, + }); + const accessToken = await this.createAccessToken(user); const refreshToken = await this.createRefreshToken(user.id); @@ -70,7 +137,7 @@ export class AuthService { } async updatePassword(user: User, oldPassword: string, newPassword: string) { - if (argon.verify(user.password, oldPassword)) + if (!(await argon.verify(user.password, oldPassword))) throw new ForbiddenException("Invalid password"); const hash = await argon.hash(newPassword); @@ -115,4 +182,161 @@ export class AuthService { return refreshToken; } + + async createLoginToken(userId: string) { + const loginToken = ( + await this.prisma.loginToken.create({ + data: { userId, expiresAt: moment().add(5, "minutes").toDate() }, + }) + ).token; + + return loginToken; + } + + encryptTotpSecret(totpSecret: string, password: string) { + let iv = this.config.get("TOTP_SECRET"); + iv = Buffer.from(iv, "base64"); + const key = crypto + .createHash("sha256") + .update(String(password)) + .digest("base64") + .substr(0, 32); + + const cipher = crypto.createCipheriv("aes-256-cbc", key, iv); + + let encrypted = cipher.update(totpSecret); + + encrypted = Buffer.concat([encrypted, cipher.final()]); + + return encrypted.toString("base64"); + } + + decryptTotpSecret(encryptedTotpSecret: string, password: string) { + let iv = this.config.get("TOTP_SECRET"); + iv = Buffer.from(iv, "base64"); + const key = crypto + .createHash("sha256") + .update(String(password)) + .digest("base64") + .substr(0, 32); + + const encryptedText = Buffer.from(encryptedTotpSecret, "base64"); + const decipher = crypto.createDecipheriv("aes-256-cbc", key, iv); + let decrypted = decipher.update(encryptedText); + decrypted = Buffer.concat([decrypted, decipher.final()]); + + return decrypted.toString(); + } + + async enableTotp(user: User, password: string) { + if (!(await argon.verify(user.password, password))) + throw new ForbiddenException("Invalid password"); + + // Check if we have a secret already + const { totpVerified } = await this.prisma.user.findUnique({ + where: { id: user.id }, + select: { totpVerified: true }, + }); + + if (totpVerified) { + throw new BadRequestException("TOTP is already enabled"); + } + + // TODO: Maybe make the issuer configurable with env vars? + const secret = authenticator.generateSecret(); + const encryptedSecret = this.encryptTotpSecret(secret, password); + + const otpURL = totp.keyuri( + user.username || user.email, + "pingvin-share", + secret + ); + + await this.prisma.user.update({ + where: { id: user.id }, + data: { + totpEnabled: true, + totpSecret: encryptedSecret, + }, + }); + + // TODO: Maybe we should generate the QR code on the client rather than the server? + const qrCode = new qrcode({ + content: otpURL, + container: "svg-viewbox", + join: true, + }).svg(); + + return { + totpAuthUrl: otpURL, + totpSecret: secret, + qrCode: + "data:image/svg+xml;base64," + Buffer.from(qrCode).toString("base64"), + }; + } + + // TODO: Maybe require a token to verify that the user who started enabling totp is the one who is verifying it? + async verifyTotp(user: User, password: string, code: string) { + if (!(await argon.verify(user.password, password))) + throw new ForbiddenException("Invalid password"); + + const { totpSecret } = await this.prisma.user.findUnique({ + where: { id: user.id }, + select: { totpSecret: true }, + }); + + if (!totpSecret) { + throw new BadRequestException("TOTP is not in progress"); + } + + const decryptedSecret = this.decryptTotpSecret(totpSecret, password); + + const expected = authenticator.generate(decryptedSecret); + + if (code !== expected) { + throw new BadRequestException("Invalid code"); + } + + await this.prisma.user.update({ + where: { id: user.id }, + data: { + totpVerified: true, + }, + }); + + return true; + } + + async disableTotp(user: User, password: string, code: string) { + if (!(await argon.verify(user.password, password))) + throw new ForbiddenException("Invalid password"); + + const { totpSecret } = await this.prisma.user.findUnique({ + where: { id: user.id }, + select: { totpSecret: true }, + }); + + if (!totpSecret) { + throw new BadRequestException("TOTP is not enabled"); + } + + const decryptedSecret = this.decryptTotpSecret(totpSecret, password); + + const expected = authenticator.generate(decryptedSecret); + + if (code !== expected) { + throw new BadRequestException("Invalid code"); + } + + await this.prisma.user.update({ + where: { id: user.id }, + data: { + totpVerified: false, + totpEnabled: false, + totpSecret: null, + }, + }); + + return true; + } } diff --git a/backend/src/auth/dto/authSignInTotp.dto.ts b/backend/src/auth/dto/authSignInTotp.dto.ts new file mode 100644 index 00000000..a90559da --- /dev/null +++ b/backend/src/auth/dto/authSignInTotp.dto.ts @@ -0,0 +1,21 @@ +import { PickType } from "@nestjs/mapped-types"; +import { IsEmail, IsOptional, IsString } from "class-validator"; +import { UserDTO } from "src/user/dto/user.dto"; + +export class AuthSignInTotpDTO extends PickType(UserDTO, [ + "password", +] as const) { + @IsEmail() + @IsOptional() + email: string; + + @IsString() + @IsOptional() + username: string; + + @IsString() + totp: string; + + @IsString() + loginToken: string; +} diff --git a/backend/src/auth/dto/enableTotp.dto.ts b/backend/src/auth/dto/enableTotp.dto.ts new file mode 100644 index 00000000..b1b49ddb --- /dev/null +++ b/backend/src/auth/dto/enableTotp.dto.ts @@ -0,0 +1,5 @@ +import { PickType } from "@nestjs/mapped-types"; +import { IsEmail, IsOptional, IsString } from "class-validator"; +import { UserDTO } from "src/user/dto/user.dto"; + +export class EnableTotpDTO extends PickType(UserDTO, ["password"] as const) {} diff --git a/backend/src/auth/dto/verifyTotp.dto.ts b/backend/src/auth/dto/verifyTotp.dto.ts new file mode 100644 index 00000000..3709fc85 --- /dev/null +++ b/backend/src/auth/dto/verifyTotp.dto.ts @@ -0,0 +1,8 @@ +import { PickType } from "@nestjs/mapped-types"; +import { IsEmail, IsOptional, IsString } from "class-validator"; +import { UserDTO } from "src/user/dto/user.dto"; + +export class VerifyTotpDTO extends PickType(UserDTO, ["password"] as const) { + @IsString() + code: string; +} diff --git a/backend/src/user/dto/user.dto.ts b/backend/src/user/dto/user.dto.ts index 85d661a8..7f37fa86 100644 --- a/backend/src/user/dto/user.dto.ts +++ b/backend/src/user/dto/user.dto.ts @@ -22,6 +22,9 @@ export class UserDTO { @Expose() isAdmin: boolean; + @Expose() + totpVerified: boolean; + from(partial: Partial) { return plainToClass(UserDTO, partial, { excludeExtraneousValues: true }); } diff --git a/frontend/src/components/account/showEnableTotpModal.tsx b/frontend/src/components/account/showEnableTotpModal.tsx new file mode 100644 index 00000000..1e3828c4 --- /dev/null +++ b/frontend/src/components/account/showEnableTotpModal.tsx @@ -0,0 +1,131 @@ +import { + Button, + Center, + Col, + Grid, + Image, + Stack, + Text, + TextInput, + Title, + Tooltip, +} from "@mantine/core"; +import { useForm, yupResolver } from "@mantine/form"; +import { useModals } from "@mantine/modals"; +import { ModalsContextProps } from "@mantine/modals/lib/context"; +import * as yup from "yup"; +import useUser from "../../hooks/user.hook"; +import authService from "../../services/auth.service"; +import toast from "../../utils/toast.util"; + +const showEnableTotpModal = ( + modals: ModalsContextProps, + refreshUser: () => {}, + options: { + qrCode: string; + secret: string; + password: string; + } +) => { + return modals.openModal({ + title: Enable TOTP, + children: ( + + ), + }); +}; + +const CreateEnableTotpModal = ({ + options, + refreshUser, +}: { + options: { + qrCode: string; + secret: string; + password: string; + }; + refreshUser: () => {}; +}) => { + const modals = useModals(); + const user = useUser(); + + console.log(user.user); + + const validationSchema = yup.object().shape({ + code: yup + .string() + .min(6) + .max(6) + .required() + .matches(/^[0-9]+$/, { message: "Code must be a number" }), + }); + + const form = useForm({ + initialValues: { + code: "", + }, + validate: yupResolver(validationSchema), + }); + + return ( +
+
+ + Step 1: Add your authenticator + QR Code + +
+ OR +
+ + + + +
+ Enter manually +
+ + Step 2: Validate your code + +
{ + authService + .verifyTOTP(values.code, options.password) + .then(() => { + toast.success("Successfully enabled TOTP"); + modals.closeAll(); + refreshUser(); + }) + .catch(toast.axiosError); + })} + > + + + + + + + + +
+
+
+
+ ); +}; + +export default showEnableTotpModal; diff --git a/frontend/src/components/auth/SignInForm.tsx b/frontend/src/components/auth/SignInForm.tsx index 1857f28f..e111d7d9 100644 --- a/frontend/src/components/auth/SignInForm.tsx +++ b/frontend/src/components/auth/SignInForm.tsx @@ -9,7 +9,11 @@ import { Title, } from "@mantine/core"; import { useForm, yupResolver } from "@mantine/form"; +import { showNotification } from "@mantine/notifications"; +import { setCookie } from "cookies-next"; import Link from "next/link"; +import React from "react"; +import { TbInfoCircle } from "react-icons/tb"; import * as yup from "yup"; import useConfig from "../../hooks/config.hook"; import authService from "../../services/auth.service"; @@ -17,16 +21,24 @@ import toast from "../../utils/toast.util"; const SignInForm = () => { const config = useConfig(); + const [showTotp, setShowTotp] = React.useState(false); + const [loginToken, setLoginToken] = React.useState(""); const validationSchema = yup.object().shape({ emailOrUsername: yup.string().required(), password: yup.string().min(8).required(), + totp: yup.string().when("totpRequired", { + is: true, + then: yup.string().min(6).max(6).required(), + otherwise: yup.string(), + }), }); const form = useForm({ initialValues: { emailOrUsername: "", password: "", + totp: "", }, validate: yupResolver(validationSchema), }); @@ -34,10 +46,47 @@ const SignInForm = () => { const signIn = (email: string, password: string) => { authService .signIn(email, password) - .then(() => window.location.replace("/")) + .then((response) => { + if (response.data["loginToken"]) { + // Prompt the user to enter their totp code + setShowTotp(true); + showNotification({ + icon: , + color: "blue", + radius: "md", + title: "Two-factor authentication required", + message: "Please enter your two-factor authentication code", + }); + setLoginToken(response.data["loginToken"]); + } else { + setCookie("access_token", response.data.accessToken); + setCookie("refresh_token", response.data.refreshToken); + window.location.replace("/"); + } + }) .catch(toast.axiosError); }; + const signInTotp = (email: string, password: string, totp: string) => { + authService + .signInTotp(email, password, totp, loginToken) + .then((response) => { + setCookie("access_token", response.data.accessToken); + setCookie("refresh_token", response.data.refreshToken); + window.location.replace("/"); + }) + .catch((error) => { + if (error?.response?.data?.message == "Login token expired") { + toast.error("Login token expired"); + // Refresh the page to start over + window.location.reload(); + } + + toast.axiosError(error); + form.setValues({ totp: "" }); + }); + }; + return ( { )} <Paper withBorder shadow="md" p={30} mt={30} radius="md"> <form - onSubmit={form.onSubmit((values) => - signIn(values.emailOrUsername, values.password) - )} + onSubmit={form.onSubmit((values) => { + if (showTotp) + signInTotp(values.emailOrUsername, values.password, values.totp); + else signIn(values.emailOrUsername, values.password); + })} > <TextInput label="Email or username" @@ -74,6 +125,15 @@ const SignInForm = () => { mt="md" {...form.getInputProps("password")} /> + {showTotp && ( + <TextInput + variant="filled" + label="Code" + placeholder="******" + mt="md" + {...form.getInputProps("totp")} + /> + )} <Button fullWidth mt="xl" type="submit"> Sign in </Button> diff --git a/frontend/src/components/auth/SignUpForm.tsx b/frontend/src/components/auth/SignUpForm.tsx index a634ccc1..42713bc2 100644 --- a/frontend/src/components/auth/SignUpForm.tsx +++ b/frontend/src/components/auth/SignUpForm.tsx @@ -9,6 +9,7 @@ import { Title, } from "@mantine/core"; import { useForm, yupResolver } from "@mantine/form"; +import { setCookie } from "cookies-next"; import Link from "next/link"; import * as yup from "yup"; import useConfig from "../../hooks/config.hook"; @@ -33,16 +34,14 @@ const SignUpForm = () => { validate: yupResolver(validationSchema), }); - const signIn = (email: string, password: string) => { - authService - .signIn(email, password) - .then(() => window.location.replace("/")) - .catch(toast.axiosError); - }; const signUp = (email: string, username: string, password: string) => { authService .signUp(email, username, password) - .then(() => signIn(email, password)) + .then((response) => { + setCookie("access_token", response.data.accessToken); + setCookie("refresh_token", response.data.refreshToken); + window.location.replace("/"); + }) .catch(toast.axiosError); }; diff --git a/frontend/src/components/navBar/ActionAvatar.tsx b/frontend/src/components/navBar/ActionAvatar.tsx index 37eca730..9af43410 100644 --- a/frontend/src/components/navBar/ActionAvatar.tsx +++ b/frontend/src/components/navBar/ActionAvatar.tsx @@ -5,7 +5,7 @@ import useUser from "../../hooks/user.hook"; import authService from "../../services/auth.service"; const ActionAvatar = () => { - const user = useUser(); + const { user } = useUser(); return ( <Menu position="bottom-start" withinPortal> diff --git a/frontend/src/components/navBar/NavBar.tsx b/frontend/src/components/navBar/NavBar.tsx index ea5da077..4f172c09 100644 --- a/frontend/src/components/navBar/NavBar.tsx +++ b/frontend/src/components/navBar/NavBar.tsx @@ -107,7 +107,7 @@ const useStyles = createStyles((theme) => ({ })); const NavBar = () => { - const user = useUser(); + const { user } = useUser(); const config = useConfig(); const [opened, toggleOpened] = useDisclosure(false); diff --git a/frontend/src/hooks/user.hook.ts b/frontend/src/hooks/user.hook.ts index 01eda0ed..08be50c0 100644 --- a/frontend/src/hooks/user.hook.ts +++ b/frontend/src/hooks/user.hook.ts @@ -1,7 +1,10 @@ import { createContext, useContext } from "react"; -import { CurrentUser } from "../types/user.type"; +import { UserHook } from "../types/user.type"; -export const UserContext = createContext<CurrentUser | null>(null); +export const UserContext = createContext<UserHook>({ + user: null, + setUser: () => {}, +}); const useUser = () => { return useContext(UserContext); diff --git a/frontend/src/pages/_app.tsx b/frontend/src/pages/_app.tsx index 314323f3..6db2d396 100644 --- a/frontend/src/pages/_app.tsx +++ b/frontend/src/pages/_app.tsx @@ -73,7 +73,7 @@ function App({ Component, pageProps }: AppProps) { <LoadingOverlay visible overlayOpacity={1} /> ) : ( <ConfigContext.Provider value={configVariables}> - <UserContext.Provider value={user} > + <UserContext.Provider value={{ user, setUser }}> <LoadingOverlay visible={isLoading} overlayOpacity={1} /> <Header /> <Container> diff --git a/frontend/src/pages/account/index.tsx b/frontend/src/pages/account/index.tsx index 47c7ff40..3fde8370 100644 --- a/frontend/src/pages/account/index.tsx +++ b/frontend/src/pages/account/index.tsx @@ -6,6 +6,7 @@ import { Paper, PasswordInput, Stack, + Tabs, Text, TextInput, Title, @@ -13,14 +14,16 @@ import { import { useForm, yupResolver } from "@mantine/form"; import { useModals } from "@mantine/modals"; import { useRouter } from "next/router"; +import { Tb2Fa } from "react-icons/tb"; import * as yup from "yup"; +import showEnableTotpModal from "../../components/account/showEnableTotpModal"; import useUser from "../../hooks/user.hook"; import authService from "../../services/auth.service"; import userService from "../../services/user.service"; import toast from "../../utils/toast.util"; const Account = () => { - const user = useUser(); + const { user, setUser } = useUser(); const modals = useModals(); const router = useRouter(); @@ -50,6 +53,36 @@ const Account = () => { ), }); + const enableTotpForm = useForm({ + initialValues: { + password: "", + }, + validate: yupResolver( + yup.object().shape({ + password: yup.string().min(8), + }) + ), + }); + + const disableTotpForm = useForm({ + initialValues: { + password: "", + code: "", + }, + validate: yupResolver( + yup.object().shape({ + password: yup.string().min(8), + code: yup + .string() + .min(6) + .max(6) + .matches(/^[0-9]+$/, { message: "Code must be a number" }), + }) + ), + }); + + const refreshUser = async () => setUser(await userService.getCurrentUser()); + if (!user) { router.push("/"); return; @@ -117,31 +150,120 @@ const Account = () => { </Stack> </form> </Paper> - <Center mt={80}> - <Button - variant="light" - color="red" - onClick={() => - modals.openConfirmModal({ - title: "Account deletion", - children: ( - <Text size="sm"> - Do you really want to delete your account including all your - active shares? - </Text> - ), - labels: { confirm: "Delete", cancel: "Cancel" }, - confirmProps: { color: "red" }, - onConfirm: async () => { - await userService.removeCurrentUser(); - window.location.reload(); - }, - }) - } - > - Delete Account - </Button> + <Paper withBorder p="xl" mt="lg"> + <Title order={5} mb="xs"> + Security + + + + + }> + TOTP + + + + + {/* TODO: This is ugly, make it prettier */} + {/* If we have totp enabled, show different text */} + {user.totpVerified ? ( + <> +
{ + authService + .disableTOTP(values.code, values.password) + .then(() => { + toast.success("Successfully disabled TOTP"); + values.password = ""; + values.code = ""; + refreshUser(); + }) + .catch(toast.axiosError); + })} + > + + + + + + + + + +
+ + ) : ( + <> +
{ + authService + .enableTOTP(values.password) + .then((result) => { + showEnableTotpModal(modals, refreshUser, { + qrCode: result.qrCode, + secret: result.totpSecret, + password: values.password, + }); + values.password = ""; + }) + .catch(toast.axiosError); + })} + > + + + + + + +
+ + )} +
+
+ + +
+ + +
); diff --git a/frontend/src/pages/account/shares.tsx b/frontend/src/pages/account/shares.tsx index ed2b8384..c9972dfb 100644 --- a/frontend/src/pages/account/shares.tsx +++ b/frontend/src/pages/account/shares.tsx @@ -28,7 +28,7 @@ const MyShares = () => { const modals = useModals(); const clipboard = useClipboard(); const router = useRouter(); - const user = useUser(); + const { user } = useUser(); const [shares, setShares] = useState(); diff --git a/frontend/src/pages/admin/setup.tsx b/frontend/src/pages/admin/setup.tsx index 109a1dcc..3c940692 100644 --- a/frontend/src/pages/admin/setup.tsx +++ b/frontend/src/pages/admin/setup.tsx @@ -10,7 +10,7 @@ import configService from "../../services/config.service"; const Setup = () => { const router = useRouter(); const config = useConfig(); - const user = useUser(); + const { user } = useUser(); const [isLoading, setIsLoading] = useState(false); diff --git a/frontend/src/pages/auth/signIn.tsx b/frontend/src/pages/auth/signIn.tsx index a8abf9a8..279bf0ff 100644 --- a/frontend/src/pages/auth/signIn.tsx +++ b/frontend/src/pages/auth/signIn.tsx @@ -4,7 +4,7 @@ import Meta from "../../components/Meta"; import useUser from "../../hooks/user.hook"; const SignIn = () => { - const user = useUser(); + const { user } = useUser(); const router = useRouter(); if (user) { router.replace("/"); diff --git a/frontend/src/pages/auth/signUp.tsx b/frontend/src/pages/auth/signUp.tsx index 316b90e8..dfd9683a 100644 --- a/frontend/src/pages/auth/signUp.tsx +++ b/frontend/src/pages/auth/signUp.tsx @@ -6,7 +6,7 @@ import useUser from "../../hooks/user.hook"; const SignUp = () => { const config = useConfig(); - const user = useUser(); + const { user } = useUser(); const router = useRouter(); if (user) { router.replace("/"); diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index 0e80be93..039d873b 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -70,7 +70,7 @@ const useStyles = createStyles((theme) => ({ export default function Home() { const config = useConfig(); - const user = useUser(); + const { user } = useUser(); const { classes } = useStyles(); const router = useRouter(); diff --git a/frontend/src/pages/upload.tsx b/frontend/src/pages/upload.tsx index 99d8b69e..4afaf1b2 100644 --- a/frontend/src/pages/upload.tsx +++ b/frontend/src/pages/upload.tsx @@ -23,7 +23,7 @@ const Upload = () => { const router = useRouter(); const modals = useModals(); - const user = useUser(); + const { user } = useUser(); const config = useConfig(); const [files, setFiles] = useState([]); const [isUploading, setisUploading] = useState(false); diff --git a/frontend/src/services/auth.service.ts b/frontend/src/services/auth.service.ts index cf5610fb..77bf5d7f 100644 --- a/frontend/src/services/auth.service.ts +++ b/frontend/src/services/auth.service.ts @@ -1,4 +1,4 @@ -import { getCookie, setCookies } from "cookies-next"; +import { getCookie, setCookie } from "cookies-next"; import * as jose from "jose"; import api from "./api.service"; @@ -11,8 +11,25 @@ const signIn = async (emailOrUsername: string, password: string) => { ...emailOrUsernameBody, password, }); - setCookies("access_token", response.data.accessToken); - setCookies("refresh_token", response.data.refreshToken); + return response; +}; + +const signInTotp = async ( + emailOrUsername: string, + password: string, + totp: string, + loginToken: string +) => { + const emailOrUsernameBody = emailOrUsername.includes("@") + ? { email: emailOrUsername } + : { username: emailOrUsername }; + + const response = await api.post("auth/signIn/totp", { + ...emailOrUsernameBody, + password, + totp, + loginToken, + }); return response; }; @@ -21,8 +38,8 @@ const signUp = async (email: string, username: string, password: string) => { }; const signOut = () => { - setCookies("access_token", null); - setCookies("refresh_token", null); + setCookie("access_token", null); + setCookie("refresh_token", null); window.location.reload(); }; @@ -37,7 +54,7 @@ const refreshAccessToken = async () => { const refreshToken = getCookie("refresh_token"); const response = await api.post("auth/token", { refreshToken }); - setCookies("access_token", response.data.accessToken); + setCookie("access_token", response.data.accessToken); } } catch { console.info("Refresh token invalid or expired"); @@ -48,10 +65,38 @@ const updatePassword = async (oldPassword: string, password: string) => { await api.patch("/auth/password", { oldPassword, password }); }; +const enableTOTP = async (password: string) => { + const { data } = await api.post("/auth/totp/enable", { password }); + + return { + totpAuthUrl: data.totpAuthUrl, + totpSecret: data.totpSecret, + qrCode: data.qrCode, + }; +}; + +const verifyTOTP = async (totpCode: string, password: string) => { + await api.post("/auth/totp/verify", { + code: totpCode, + password, + }); +}; + +const disableTOTP = async (totpCode: string, password: string) => { + await api.post("/auth/totp/disable", { + code: totpCode, + password, + }); +}; + export default { signIn, + signInTotp, signUp, signOut, refreshAccessToken, updatePassword, + enableTOTP, + verifyTOTP, + disableTOTP, }; diff --git a/frontend/src/types/user.type.ts b/frontend/src/types/user.type.ts index def08b76..dcf8ad98 100644 --- a/frontend/src/types/user.type.ts +++ b/frontend/src/types/user.type.ts @@ -3,6 +3,7 @@ type User = { username: string; email: string; isAdmin: boolean; + totpVerified: boolean; }; export type CreateUser = { @@ -26,4 +27,9 @@ export type UpdateCurrentUser = { export type CurrentUser = User & {}; +export type UserHook = { + user: CurrentUser | null; + setUser: (user: CurrentUser | null) => void; +}; + export default User;