From f566cf7e8b0e6de709a6b26e49b6bd68e3dd5079 Mon Sep 17 00:00:00 2001 From: Bryan Date: Thu, 7 Mar 2024 22:53:59 +0100 Subject: [PATCH 01/11] [FEAT] Add email validation before register --- package-lock.json | 113 +++++++++++++++- package.json | 12 +- src/app.ts | 43 +++--- src/auth/isApplicationLoggedIn.ts | 2 +- src/auth/isEmailValidated.ts | 13 ++ src/dto/user/userCreateDto.ts | 4 +- src/dto/user/userDto.ts | 1 + src/errors/NotEmailValidatedError.ts | 1 + src/model/EmailValidationOTPModel.ts | 20 +++ src/model/UserModel.ts | 1 + src/model/db.ts | 31 +++++ src/routes/auth.ts | 16 +-- src/routes/users.ts | 102 -------------- src/routes/users/me.ts | 191 +++++++++++++++++++++++++++ 14 files changed, 413 insertions(+), 137 deletions(-) create mode 100644 src/auth/isEmailValidated.ts create mode 100644 src/errors/NotEmailValidatedError.ts create mode 100644 src/model/EmailValidationOTPModel.ts delete mode 100644 src/routes/users.ts create mode 100644 src/routes/users/me.ts diff --git a/package-lock.json b/package-lock.json index b151914..995304b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@fastify/auth": "^4.6.1", "@fastify/cookie": "^9.3.1", + "@fastify/rate-limit": "^9.1.0", "@fastify/swagger": "^8.14.0", "@fastify/swagger-ui": "^3.0.0", "bcryptjs": "^2.4.3", @@ -20,7 +21,10 @@ "jose": "^5.2.3", "morgan": "^1.10.0", "mysql2": "^3.9.2", + "node-cron": "^3.0.3", "nodemailer": "^6.9.12", + "otp-generator": "^4.0.1", + "otplib": "^12.0.1", "sequelize": "^6.37.1", "sharp": "^0.33.2", "zod": "^3.22.4" @@ -29,7 +33,9 @@ "@types/bcryptjs": "^2.4.6", "@types/cookie-parser": "^1.4.7", "@types/node": "^20.11.19", + "@types/node-cron": "^3.0.11", "@types/nodemailer": "^6.4.14", + "@types/otp-generator": "^4.0.2", "prettier": "3.2.5", "tsx": "^4.7.1", "typescript": "^5.4.2" @@ -474,6 +480,16 @@ "fast-json-stringify": "^5.7.0" } }, + "node_modules/@fastify/rate-limit": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-9.1.0.tgz", + "integrity": "sha512-h5dZWCkuZXN0PxwqaFQLxeln8/LNwQwH9popywmDCFdKfgpi4b/HoMH1lluy6P+30CG9yzzpSpwTCIPNB9T1JA==", + "dependencies": { + "@lukeed/ms": "^2.0.1", + "fastify-plugin": "^4.0.0", + "toad-cache": "^3.3.1" + } + }, "node_modules/@fastify/send": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@fastify/send/-/send-2.1.0.tgz", @@ -989,6 +1005,48 @@ "node": ">=8" } }, + "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/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -1081,6 +1139,12 @@ "undici-types": "~5.26.4" } }, + "node_modules/@types/node-cron": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.11.tgz", + "integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==", + "dev": true + }, "node_modules/@types/nodemailer": { "version": "6.4.14", "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.14.tgz", @@ -1090,6 +1154,12 @@ "@types/node": "*" } }, + "node_modules/@types/otp-generator": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/otp-generator/-/otp-generator-4.0.2.tgz", + "integrity": "sha512-9+qqWzuFb332hXPbLgjUyOXlbcaTQkmkmqQjTduvNuOmPV5fW+iLv70JsVEhdUy0DWi4kY34++HDCaWl6N0AYg==", + "dev": true + }, "node_modules/@types/qs": { "version": "6.9.7", "dev": true, @@ -2009,6 +2079,17 @@ "node": ">=12" } }, + "node_modules/node-cron": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz", + "integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==", + "dependencies": { + "uuid": "8.3.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nodemailer": { "version": "6.9.12", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.12.tgz", @@ -2037,6 +2118,24 @@ "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==" }, + "node_modules/otp-generator": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/otp-generator/-/otp-generator-4.0.1.tgz", + "integrity": "sha512-2TJ52vUftA0+J3eque4wwVtpaL4/NdIXDL0gFWFJFVUAZwAN7+9tltMhL7GCNYaHJtuONoier8Hayyj4HLbSag==", + "engines": { + "node": ">=14.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/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -2591,6 +2690,14 @@ "node": ">=8" } }, + "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/thread-stream": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-2.4.1.tgz", @@ -2600,9 +2707,9 @@ } }, "node_modules/toad-cache": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.3.0.tgz", - "integrity": "sha512-3oDzcogWGHZdkwrHyvJVpPjA7oNzY6ENOV3PsWJY9XYPZ6INo94Yd47s5may1U+nleBPwDhrRiTPMIvKaa3MQg==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz", + "integrity": "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==", "engines": { "node": ">=12" } diff --git a/package.json b/package.json index 266c06c..65c2539 100644 --- a/package.json +++ b/package.json @@ -8,28 +8,34 @@ "start": "npx tsc && node dist/app.js" }, "dependencies": { + "fastify": "^4.26.2", "@fastify/auth": "^4.6.1", "@fastify/cookie": "^9.3.1", "@fastify/swagger": "^8.14.0", "@fastify/swagger-ui": "^3.0.0", + "@fastify/rate-limit": "^9.1.0", + "fastify-type-provider-zod": "^1.1.9", "bcryptjs": "^2.4.3", "cookie-parser": "^1.4.6", "dotenv": "^16.4.5", - "fastify": "^4.26.2", - "fastify-type-provider-zod": "^1.1.9", "jose": "^5.2.3", "morgan": "^1.10.0", "mysql2": "^3.9.2", "nodemailer": "^6.9.12", "sequelize": "^6.37.1", "sharp": "^0.33.2", - "zod": "^3.22.4" + "zod": "^3.22.4", + "node-cron": "^3.0.3", + "otp-generator": "^4.0.1", + "otplib": "^12.0.1" }, "devDependencies": { "@types/bcryptjs": "^2.4.6", "@types/cookie-parser": "^1.4.7", "@types/node": "^20.11.19", "@types/nodemailer": "^6.4.14", + "@types/otp-generator": "^4.0.2", + "@types/node-cron": "^3.0.11", "prettier": "3.2.5", "tsx": "^4.7.1", "typescript": "^5.4.2" diff --git a/src/app.ts b/src/app.ts index fe7169a..45f9e89 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,21 +1,21 @@ import * as dotenv from "dotenv"; -import fs from "fs"; - import Fastify from "fastify"; - import { jsonSchemaTransform, serializerCompiler, validatorCompiler, } from "fastify-type-provider-zod"; -import { ensureValidEnv, env } from "./env"; -import Db from "./model/db"; - +import fs from "fs"; +import cron from "node-cron"; import { isApplicationLoggedIn } from "./auth/isApplicationLoggedIn"; import { isSessionLoggedIn } from "./auth/isSessionLoggedIn"; +import { ensureValidEnv, env } from "./env"; import { BiotopeModel } from "./model/BiotopeModel"; +import { EmailValidationOTPModel } from "./model/EmailValidationOTPModel"; import { MeasurementTypeModel } from "./model/MeasurementTypeModel"; import { UserModel } from "./model/UserModel"; +import Db from "./model/db"; +import { isEmailValidated } from "./auth/isEmailValidated"; // - - - - - Environment variables - - - - - // if (fs.existsSync(".env")) { @@ -41,7 +41,6 @@ declare module "fastify" { } } -// - - - - - Serveur Express - - - - - // (async () => { // - - - - - Database - - - - - // console.log("Connecting to database..."); @@ -118,20 +117,30 @@ declare module "fastify" { instance.auth([isSessionLoggedIn, isApplicationLoggedIn]), ); - await fastify.register(import("./routes/users"), { - prefix: "/users", + await fastify.register(import("./routes/users/me"), { + prefix: "/users/me", }); - await fastify.register(import("./routes/applications"), { - prefix: "/applications", - }); + instance.register(async (instance) => { + instance.addHook("preHandler", instance.auth([isEmailValidated])); - await fastify.register(import("./routes/biotopes/aquariums"), { - prefix: "/aquariums", - }); + await fastify.register(import("./routes/applications"), { + prefix: "/applications", + }); - await fastify.register(import("./routes/biotopes/terrariums"), { - prefix: "/terrariums", + await fastify.register(import("./routes/biotopes/aquariums"), { + prefix: "/aquariums", + }); + + await fastify.register(import("./routes/biotopes/terrariums"), { + prefix: "/terrariums", + }); }); }); + + // - - - - - Setup cron jobs - - - - - // + // Every day at 00:00 + cron.schedule("0 0 * * *", () => { + EmailValidationOTPModel.destroyExpiredTokens(); + }); })(); diff --git a/src/auth/isApplicationLoggedIn.ts b/src/auth/isApplicationLoggedIn.ts index 963636f..85089ee 100644 --- a/src/auth/isApplicationLoggedIn.ts +++ b/src/auth/isApplicationLoggedIn.ts @@ -11,7 +11,7 @@ export const isApplicationLoggedIn = (async (req, res) => { throw new NotLoggedError(); } - const jwtUser = await jwt.verify(token, env.APPLICATION_TOKEN_SECRET!); + const jwtUser = await jwt.verify(token, env.APPLICATION_TOKEN_SECRET); if (!jwtUser.id) { throw new NotLoggedError(); diff --git a/src/auth/isEmailValidated.ts b/src/auth/isEmailValidated.ts new file mode 100644 index 0000000..3687786 --- /dev/null +++ b/src/auth/isEmailValidated.ts @@ -0,0 +1,13 @@ +import { FastifyAuthFunction } from "@fastify/auth"; +import { NotEmailValidatedError } from "../errors/NotEmailValidatedError"; +import { NotLoggedError } from "../errors/NotLoggedError"; + +export const isEmailValidated = (async (req, res) => { + if (!req.user) { + throw new NotLoggedError(); + } + + if (!req.user.verified) { + throw new NotEmailValidatedError(); + } +}) satisfies FastifyAuthFunction; diff --git a/src/dto/user/userCreateDto.ts b/src/dto/user/userCreateDto.ts index bb67fd8..b5ba317 100644 --- a/src/dto/user/userCreateDto.ts +++ b/src/dto/user/userCreateDto.ts @@ -1,9 +1,9 @@ import { z } from "zod"; export const UserCreateDtoSchema = z.object({ - username: z.string(), + username: z.string().min(3).max(50), email: z.string().email(), - password: z.string(), + password: z.string().min(8).max(100), }); export type UserCreateDto = z.infer; diff --git a/src/dto/user/userDto.ts b/src/dto/user/userDto.ts index 195b6e3..f34b6f7 100644 --- a/src/dto/user/userDto.ts +++ b/src/dto/user/userDto.ts @@ -4,6 +4,7 @@ export const UserDtoSchema = z.object({ id: z.string().uuid(), username: z.string(), email: z.string().email(), + verified: z.boolean(), }); export type UserDto = z.infer; diff --git a/src/errors/NotEmailValidatedError.ts b/src/errors/NotEmailValidatedError.ts new file mode 100644 index 0000000..ec64bb0 --- /dev/null +++ b/src/errors/NotEmailValidatedError.ts @@ -0,0 +1 @@ +export class NotEmailValidatedError extends Error {} diff --git a/src/model/EmailValidationOTPModel.ts b/src/model/EmailValidationOTPModel.ts new file mode 100644 index 0000000..b5ae0f9 --- /dev/null +++ b/src/model/EmailValidationOTPModel.ts @@ -0,0 +1,20 @@ +import { InferAttributes, InferCreationAttributes, Model, Op } from "sequelize"; + +export class EmailValidationOTPModel extends Model< + InferAttributes, + InferCreationAttributes +> { + declare email: string; + declare code: string; + declare expiresAt: Date; + + static destroyExpiredTokens() { + return EmailValidationOTPModel.destroy({ + where: { + expiresAt: { + [Op.lt]: new Date(), + }, + }, + }); + } +} diff --git a/src/model/UserModel.ts b/src/model/UserModel.ts index 4203044..3307b5b 100644 --- a/src/model/UserModel.ts +++ b/src/model/UserModel.ts @@ -17,6 +17,7 @@ export class UserModel extends Model< declare username: string; declare email: string; declare password: string; + declare verified: CreationOptional; declare getBiotopeModels: HasManyGetAssociationsMixin; declare createBiotopeModel: HasManyCreateAssociationMixin; diff --git a/src/model/db.ts b/src/model/db.ts index c05c369..21b14f9 100644 --- a/src/model/db.ts +++ b/src/model/db.ts @@ -9,6 +9,7 @@ import { MeasurementTypeModel } from "./MeasurementTypeModel"; import { UserModel } from "./UserModel"; import { UserSessionModel } from "./UserSessionModel"; import { TerrariumModel } from "./TerrariumModel"; +import { EmailValidationOTPModel } from "./EmailValidationOTPModel"; export default class Db { private static sequelize: Sequelize; @@ -50,10 +51,40 @@ export default class Db { type: DataTypes.STRING, allowNull: false, }, + verified: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + }, }, { sequelize, tableName: "users" }, ); + EmailValidationOTPModel.init( + { + email: { + type: DataTypes.STRING, + primaryKey: true, + allowNull: false, + references: { + model: UserModel, + key: "email", + }, + }, + code: { + type: DataTypes.STRING, + allowNull: false, + }, + expiresAt: { + type: DataTypes.DATE, + allowNull: false, + }, + }, + { sequelize, tableName: "email_validation_otp" }, + ); + UserModel.hasOne(EmailValidationOTPModel, { foreignKey: "email" }); + EmailValidationOTPModel.belongsTo(UserModel, { foreignKey: "email" }); + UserSessionModel.init( { id: { diff --git a/src/routes/auth.ts b/src/routes/auth.ts index 66f3f4a..2821e47 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -1,15 +1,14 @@ -import { FastifyPluginAsync } from "fastify"; -import { UserCreateDtoSchema } from "../dto/user/userCreateDto"; -import { UserDtoSchema } from "../dto/user/userDto"; -import { UserModel } from "../model/UserModel"; - import bcrypt from "bcryptjs"; +import { FastifyPluginAsync } from "fastify"; import { ZodTypeProvider } from "fastify-type-provider-zod"; import { z } from "zod"; import MailSender from "../agents/MailSender"; +import { UserCreateDtoSchema } from "../dto/user/userCreateDto"; +import { UserDtoSchema } from "../dto/user/userDto"; +import { env } from "../env"; +import { UserModel } from "../model/UserModel"; import { UserSessionModel } from "../model/UserSessionModel"; import UserTokenUtil from "../utils/UserTokenUtil"; -import { env } from "../env"; export default (async (fastify) => { const instance = fastify.withTypeProvider(); @@ -57,7 +56,6 @@ export default (async (fastify) => { }, }, async function (req, res) { - console.log(req.headers["user-agent"]); const user = await UserModel.findOne({ where: { email: req.body.email, @@ -65,7 +63,7 @@ export default (async (fastify) => { }); if (!user) { - return res.status(404).send(); + return res.status(404).send("User not found"); } const isPasswordValid = await bcrypt.compare( @@ -73,7 +71,7 @@ export default (async (fastify) => { user.password, ); if (!isPasswordValid) { - return res.status(403).send(); + return res.status(403).send("Invalid password"); } const userDto = UserDtoSchema.parse(user); diff --git a/src/routes/users.ts b/src/routes/users.ts deleted file mode 100644 index 5d667e0..0000000 --- a/src/routes/users.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { FastifyPluginAsync } from "fastify"; -import { UserDtoSchema } from "../dto/user/userDto"; -import { UserModel } from "../model/UserModel"; - -import { ZodTypeProvider } from "fastify-type-provider-zod"; -import { z } from "zod"; - -export default (async (fastify) => { - const instance = fastify.withTypeProvider(); - - // TODO: to move in a admin dedicated route - // instance.get( - // "/", - // { - // schema: { - // tags: ["users"], - // description: "Get all users", - // response: { - // 200: UserDtoSchema.array(), - // }, - // }, - // }, - // async function () { - // const users = await UserModel.findAll(); - - // return users.map((user) => UserDtoSchema.parse(user)); - // }, - // ); - - instance.get( - "/:id", - { - schema: { - tags: ["users"], - description: "Get a user", - params: z.object({ - id: z.string().uuid(), - }), - response: { - 200: UserDtoSchema, - }, - }, - }, - async function (req, res) { - const user = await UserModel.findOne({ - where: { - id: req.params.id, - }, - }); - - if (!user) { - return res.status(404); - } - - return UserDtoSchema.parse(user); - }, - ); - - instance.delete( - "/:id", - { - schema: { - tags: ["users"], - description: "Delete a user", - params: z.object({ - id: z.string().uuid(), - }), - }, - }, - async function (req, res) { - const user = await UserModel.findOne({ - where: { - id: req.params.id, - }, - }); - - if (!user) { - return res.status(404); - } - - await user.destroy(); - - return res.status(204).send(); - }, - ); - - instance.get( - "/me", - { - schema: { - tags: ["users"], - description: "Get the current user", - response: { - 200: UserDtoSchema, - }, - }, - }, - async function (req, res) { - return UserDtoSchema.parse(req.user); - }, - ); -}) satisfies FastifyPluginAsync; diff --git a/src/routes/users/me.ts b/src/routes/users/me.ts new file mode 100644 index 0000000..244da95 --- /dev/null +++ b/src/routes/users/me.ts @@ -0,0 +1,191 @@ +import { FastifyPluginAsync } from "fastify"; +import { ZodTypeProvider } from "fastify-type-provider-zod"; +import otpGenerator from "otp-generator"; +import { z } from "zod"; +import MailSender from "../../agents/MailSender"; +import { UserDtoSchema } from "../../dto/user/userDto"; +import { EmailValidationOTPModel } from "../../model/EmailValidationOTPModel"; + +export default (async (fastify) => { + const instance = fastify.withTypeProvider(); + + instance.post( + "/verify-email/send-code", + { + schema: { + tags: ["users"], + description: + "Send a code to the connected user's email to verify it. The code is valid for 5 minutes.", + }, + }, + async function (req, res) { + const userEmail = req.user!.email; + + if (req.user!.verified) { + return res.status(400).send("EMAIL_ALREADY_VERIFIED"); + } + + let oldEmailToken = await EmailValidationOTPModel.findOne({ + where: { + email: userEmail, + }, + }); + + if (oldEmailToken) { + oldEmailToken.destroy(); + } + + const emailToken = await EmailValidationOTPModel.create({ + email: userEmail, + code: otpGenerator.generate(6, { + digits: true, + lowerCaseAlphabets: false, + upperCaseAlphabets: false, + specialChars: false, + }), + expiresAt: new Date(Date.now() + 5 * 60 * 1000), + }); + + MailSender.send( + userEmail, + "Validation de votre adresse email", + `Bonjour,\n\nVoici votre code de validation: ${emailToken.code}\n\nCe code est valable 5 minutes.`, + ); + + return res.status(204).send(); + }, + ); + + instance.post( + "/verify-email/verify-code", + { + schema: { + tags: ["users"], + description: `Verify the user's email with the code sent. Use ${instance.prefix}/verify-email/send-code to send a code.`, + body: z.object({ + code: z.string(), + }), + }, + }, + async function (req, res) { + if (req.user!.verified) { + return res.status(400).send("EMAIL_ALREADY_VERIFIED"); + } + + let emailToken = await EmailValidationOTPModel.findOne({ + where: { + email: req.user!.email, + }, + }); + + if (!emailToken) { + return res.status(403).send("NO_EMAIL_VERIFICATION_CODE"); + } else if (emailToken.expiresAt < new Date()) { + await emailToken.destroy(); + return res.status(403).send("EMAIL_VERIFICATION_CODE_EXPIRED"); + } else if (emailToken.code !== req.body.code) { + return res.status(403).send("INVALID_EMAIL_VERIFICATION_CODE"); + } + + req.user!.verified = true; + await req.user!.save(); + + await emailToken.destroy(); + + return res.status(200).send(); + }, + ); + // TODO: to move in a admin dedicated route + // instance.get( + // "/", + // { + // schema: { + // tags: ["users"], + // description: "Get all users", + // response: { + // 200: UserDtoSchema.array(), + // }, + // }, + // }, + // async function () { + // const users = await UserModel.findAll(); + // + // return users.map((user) => UserDtoSchema.parse(user)); + // }, + // ); + + // TODO: to move in a admin dedicated route + // instance.get( + // "/:id", + // { + // schema: { + // tags: ["users"], + // description: "Get a user", + // params: z.object({ + // id: z.string().uuid(), + // }), + // response: { + // 200: UserDtoSchema, + // }, + // }, + // }, + // async function (req, res) { + // const user = await UserModel.findOne({ + // where: { + // id: req.params.id, + // }, + // }); + // + // if (!user) { + // return res.status(404); + // } + // + // return UserDtoSchema.parse(user); + // }, + // ); + + // TODO: to move in a admin dedicated route + // instance.delete( + // "/:id", + // { + // schema: { + // tags: ["users"], + // description: "Delete a user", + // params: z.object({ + // id: z.string().uuid(), + // }), + // }, + // }, + // async function (req, res) { + // const user = await UserModel.findOne({ + // where: { + // id: req.params.id, + // }, + // }); + // + // if (!user) { + // return res.status(404); + // } + // + // await user.destroy(); + // + // return res.status(204).send(); + // }, + // ); + + instance.get( + "/", + { + schema: { + tags: ["users"], + description: "Get the current user", + response: { + 200: UserDtoSchema, + }, + }, + }, + async function (req, res) { + return UserDtoSchema.parse(req.user); + }, + ); +}) satisfies FastifyPluginAsync; From 226dccf9c9d87a78bc41e2bdd1af6cb42e7ead1e Mon Sep 17 00:00:00 2001 From: Bryan Date: Sat, 9 Mar 2024 00:41:49 +0100 Subject: [PATCH 02/11] [FEAT] Add OTP support --- package-lock.json | 810 +++------------------------------------- src/dto/user/userDto.ts | 1 + src/model/UserModel.ts | 2 + src/model/db.ts | 8 + src/routes/auth.ts | 21 ++ src/routes/users/me.ts | 108 ++++++ 6 files changed, 185 insertions(+), 765 deletions(-) diff --git a/package-lock.json b/package-lock.json index 995304b..d3d2b4a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,271 +41,6 @@ "typescript": "^5.4.2" } }, - "node_modules/@emnapi/runtime": { - "version": "0.45.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-0.45.0.tgz", - "integrity": "sha512-Txumi3td7J4A/xTTwlssKieHKTGl3j4A1tglBx72auZ49YK7ePY6XZricgIg9mnZT4xPfA+UPCUdnhRuEFDL+w==", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz", - "integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.12.tgz", - "integrity": "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz", - "integrity": "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.12.tgz", - "integrity": "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz", - "integrity": "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz", - "integrity": "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz", - "integrity": "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz", - "integrity": "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz", - "integrity": "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz", - "integrity": "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz", - "integrity": "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz", - "integrity": "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==", - "cpu": [ - "loong64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz", - "integrity": "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==", - "cpu": [ - "mips64el" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz", - "integrity": "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz", - "integrity": "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz", - "integrity": "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==", - "cpu": [ - "s390x" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, "node_modules/@esbuild/linux-x64": { "version": "0.19.12", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz", @@ -322,102 +57,6 @@ "node": ">=12" } }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz", - "integrity": "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz", - "integrity": "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz", - "integrity": "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz", - "integrity": "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz", - "integrity": "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz", - "integrity": "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, "node_modules/@fastify/accept-negotiator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-1.1.0.tgz", @@ -494,215 +133,60 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/@fastify/send/-/send-2.1.0.tgz", "integrity": "sha512-yNYiY6sDkexoJR0D8IDy3aRP3+L4wdqCpvx5WP+VtEU58sn7USmKynBzDQex5X42Zzvw2gNzzYgP90UfWShLFA==", - "dependencies": { - "@lukeed/ms": "^2.0.1", - "escape-html": "~1.0.3", - "fast-decode-uri-component": "^1.0.1", - "http-errors": "2.0.0", - "mime": "^3.0.0" - } - }, - "node_modules/@fastify/send/node_modules/mime": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", - "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/@fastify/static": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/@fastify/static/-/static-7.0.1.tgz", - "integrity": "sha512-i1p/nELMknAisNfnjo7yhfoUOdKzA+n92QaMirv2NkZrJ1Wl12v2nyTYlDwPN8XoStMBAnRK/Kx6zKmfrXUPXw==", - "dependencies": { - "@fastify/accept-negotiator": "^1.0.0", - "@fastify/send": "^2.0.0", - "content-disposition": "^0.5.3", - "fastify-plugin": "^4.0.0", - "fastq": "^1.17.0", - "glob": "^10.3.4" - } - }, - "node_modules/@fastify/swagger": { - "version": "8.14.0", - "resolved": "https://registry.npmjs.org/@fastify/swagger/-/swagger-8.14.0.tgz", - "integrity": "sha512-sGiznEb3rl6pKGGUZ+JmfI7ct5cwbTQGo+IjewaTvtzfrshnryu4dZwEsjw0YHABpBA+kCz3kpRaHB7qpa67jg==", - "dependencies": { - "fastify-plugin": "^4.0.0", - "json-schema-resolver": "^2.0.0", - "openapi-types": "^12.0.0", - "rfdc": "^1.3.0", - "yaml": "^2.2.2" - } - }, - "node_modules/@fastify/swagger-ui": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@fastify/swagger-ui/-/swagger-ui-3.0.0.tgz", - "integrity": "sha512-8P5OwHVv6QR4XSE6cW4fsENeMbW4yWWWj6Dz/5tvQN2pwNyTiSWxYpsY3+VP+uiZucNaDrAE2xm11rqytqAocA==", - "dependencies": { - "@fastify/static": "^7.0.0", - "fastify-plugin": "^4.0.0", - "openapi-types": "^12.0.2", - "rfdc": "^1.3.0", - "yaml": "^2.2.2" - } - }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.33.2", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.2.tgz", - "integrity": "sha512-itHBs1rPmsmGF9p4qRe++CzCgd+kFYktnsoR1sbIAfsRMrJZau0Tt1AH9KVnufc2/tU02Gf6Ibujx+15qRE03w==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "glibc": ">=2.26", - "node": "^18.17.0 || ^20.3.0 || >=21.0.0", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.0.1" - } - }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.33.2", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.2.tgz", - "integrity": "sha512-/rK/69Rrp9x5kaWBjVN07KixZanRr+W1OiyKdXcbjQD6KbW+obaTeBBtLUAtbBsnlTTmWthw99xqoOS7SsySDg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "glibc": ">=2.26", - "node": "^18.17.0 || ^20.3.0 || >=21.0.0", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.0.1" - } - }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.1.tgz", - "integrity": "sha512-kQyrSNd6lmBV7O0BUiyu/OEw9yeNGFbQhbxswS1i6rMDwBBSX+e+rPzu3S+MwAiGU3HdLze3PanQ4Xkfemgzcw==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "macos": ">=11", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" + "dependencies": { + "@lukeed/ms": "^2.0.1", + "escape-html": "~1.0.3", + "fast-decode-uri-component": "^1.0.1", + "http-errors": "2.0.0", + "mime": "^3.0.0" } }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.1.tgz", - "integrity": "sha512-eVU/JYLPVjhhrd8Tk6gosl5pVlvsqiFlt50wotCvdkFGf+mDNBJxMh+bvav+Wt3EBnNZWq8Sp2I7XfSjm8siog==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "macos": ">=10.13", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" + "node_modules/@fastify/send/node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "bin": { + "mime": "cli.js" }, - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": ">=10.0.0" } }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.1.tgz", - "integrity": "sha512-FtdMvR4R99FTsD53IA3LxYGghQ82t3yt0ZQ93WMZ2xV3dqrb0E8zq4VHaTOuLEAuA83oDawHV3fd+BsAPadHIQ==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "glibc": ">=2.28", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" + "node_modules/@fastify/static": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@fastify/static/-/static-7.0.1.tgz", + "integrity": "sha512-i1p/nELMknAisNfnjo7yhfoUOdKzA+n92QaMirv2NkZrJ1Wl12v2nyTYlDwPN8XoStMBAnRK/Kx6zKmfrXUPXw==", + "dependencies": { + "@fastify/accept-negotiator": "^1.0.0", + "@fastify/send": "^2.0.0", + "content-disposition": "^0.5.3", + "fastify-plugin": "^4.0.0", + "fastq": "^1.17.0", + "glob": "^10.3.4" } }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.1.tgz", - "integrity": "sha512-bnGG+MJjdX70mAQcSLxgeJco11G+MxTz+ebxlz8Y3dxyeb3Nkl7LgLI0mXupoO+u1wRNx/iRj5yHtzA4sde1yA==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "glibc": ">=2.26", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" + "node_modules/@fastify/swagger": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/@fastify/swagger/-/swagger-8.14.0.tgz", + "integrity": "sha512-sGiznEb3rl6pKGGUZ+JmfI7ct5cwbTQGo+IjewaTvtzfrshnryu4dZwEsjw0YHABpBA+kCz3kpRaHB7qpa67jg==", + "dependencies": { + "fastify-plugin": "^4.0.0", + "json-schema-resolver": "^2.0.0", + "openapi-types": "^12.0.0", + "rfdc": "^1.3.0", + "yaml": "^2.2.2" } }, - "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.1.tgz", - "integrity": "sha512-3+rzfAR1YpMOeA2zZNp+aYEzGNWK4zF3+sdMxuCS3ey9HhDbJ66w6hDSHDMoap32DueFwhhs3vwooAB2MaK4XQ==", - "cpu": [ - "s390x" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "glibc": ">=2.28", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" + "node_modules/@fastify/swagger-ui": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@fastify/swagger-ui/-/swagger-ui-3.0.0.tgz", + "integrity": "sha512-8P5OwHVv6QR4XSE6cW4fsENeMbW4yWWWj6Dz/5tvQN2pwNyTiSWxYpsY3+VP+uiZucNaDrAE2xm11rqytqAocA==", + "dependencies": { + "@fastify/static": "^7.0.0", + "fastify-plugin": "^4.0.0", + "openapi-types": "^12.0.2", + "rfdc": "^1.3.0", + "yaml": "^2.2.2" } }, "node_modules/@img/sharp-libvips-linux-x64": { @@ -726,27 +210,6 @@ "url": "https://opencollective.com/libvips" } }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.1.tgz", - "integrity": "sha512-5aBRcjHDG/T6jwC3Edl3lP8nl9U2Yo8+oTl5drd1dh9Z1EBfzUKAJFUDTDisDjUwc7N4AjnPGfCA3jl3hY8uDg==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "musl": ">=1.2.2", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.1.tgz", @@ -768,81 +231,6 @@ "url": "https://opencollective.com/libvips" } }, - "node_modules/@img/sharp-linux-arm": { - "version": "0.33.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.2.tgz", - "integrity": "sha512-Fndk/4Zq3vAc4G/qyfXASbS3HBZbKrlnKZLEJzPLrXoJuipFNNwTes71+Ki1hwYW5lch26niRYoZFAtZVf3EGA==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "glibc": ">=2.28", - "node": "^18.17.0 || ^20.3.0 || >=21.0.0", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.0.1" - } - }, - "node_modules/@img/sharp-linux-arm64": { - "version": "0.33.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.2.tgz", - "integrity": "sha512-pz0NNo882vVfqJ0yNInuG9YH71smP4gRSdeL09ukC2YLE6ZyZePAlWKEHgAzJGTiOh8Qkaov6mMIMlEhmLdKew==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "glibc": ">=2.26", - "node": "^18.17.0 || ^20.3.0 || >=21.0.0", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.0.1" - } - }, - "node_modules/@img/sharp-linux-s390x": { - "version": "0.33.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.2.tgz", - "integrity": "sha512-MBoInDXDppMfhSzbMmOQtGfloVAflS2rP1qPcUIiITMi36Mm5YR7r0ASND99razjQUpHTzjrU1flO76hKvP5RA==", - "cpu": [ - "s390x" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "glibc": ">=2.28", - "node": "^18.17.0 || ^20.3.0 || >=21.0.0", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.0.1" - } - }, "node_modules/@img/sharp-linux-x64": { "version": "0.33.2", "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.2.tgz", @@ -868,31 +256,6 @@ "@img/sharp-libvips-linux-x64": "1.0.1" } }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.33.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.2.tgz", - "integrity": "sha512-F+0z8JCu/UnMzg8IYW1TMeiViIWBVg7IWP6nE0p5S5EPQxlLd76c8jYemG21X99UzFwgkRo5yz2DS+zbrnxZeA==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "musl": ">=1.2.2", - "node": "^18.17.0 || ^20.3.0 || >=21.0.0", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.0.1" - } - }, "node_modules/@img/sharp-linuxmusl-x64": { "version": "0.33.2", "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.2.tgz", @@ -918,69 +281,6 @@ "@img/sharp-libvips-linuxmusl-x64": "1.0.1" } }, - "node_modules/@img/sharp-wasm32": { - "version": "0.33.2", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.2.tgz", - "integrity": "sha512-fLbTaESVKuQcpm8ffgBD7jLb/CQLcATju/jxtTXR1XCLwbOQt+OL5zPHSDMmp2JZIeq82e18yE0Vv7zh6+6BfQ==", - "cpu": [ - "wasm32" - ], - "optional": true, - "dependencies": { - "@emnapi/runtime": "^0.45.0" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-ia32": { - "version": "0.33.2", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.2.tgz", - "integrity": "sha512-okBpql96hIGuZ4lN3+nsAjGeggxKm7hIRu9zyec0lnfB8E7Z6p95BuRZzDDXZOl2e8UmR4RhYt631i7mfmKU8g==", - "cpu": [ - "ia32" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-x64": { - "version": "0.33.2", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.2.tgz", - "integrity": "sha512-E4magOks77DK47FwHUIGH0RYWSgRBfGdK56kIHSVeB9uIS4pPFr4N2kIVsXdQQo4LzOsENKV5KAhRlRL7eMAdg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -1724,20 +1024,6 @@ "node": ">= 0.6" } }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/generate-function": { "version": "2.3.1", "license": "MIT", @@ -2725,12 +2011,6 @@ "version": "1.0.1", "license": "MIT" }, - "node_modules/tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", - "optional": true - }, "node_modules/tsx": { "version": "4.7.1", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.7.1.tgz", diff --git a/src/dto/user/userDto.ts b/src/dto/user/userDto.ts index f34b6f7..9298867 100644 --- a/src/dto/user/userDto.ts +++ b/src/dto/user/userDto.ts @@ -5,6 +5,7 @@ export const UserDtoSchema = z.object({ username: z.string(), email: z.string().email(), verified: z.boolean(), + totpEnabled: z.boolean(), }); export type UserDto = z.infer; diff --git a/src/model/UserModel.ts b/src/model/UserModel.ts index 3307b5b..a0d1b10 100644 --- a/src/model/UserModel.ts +++ b/src/model/UserModel.ts @@ -18,6 +18,8 @@ export class UserModel extends Model< declare email: string; declare password: string; declare verified: CreationOptional; + declare totpEnabled: CreationOptional; + declare totpSecret?: CreationOptional; declare getBiotopeModels: HasManyGetAssociationsMixin; declare createBiotopeModel: HasManyCreateAssociationMixin; diff --git a/src/model/db.ts b/src/model/db.ts index 21b14f9..093b4aa 100644 --- a/src/model/db.ts +++ b/src/model/db.ts @@ -56,6 +56,14 @@ export default class Db { allowNull: false, defaultValue: false, }, + totpEnabled: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + totpSecret: { + type: DataTypes.STRING, + }, }, { sequelize, tableName: "users" }, ); diff --git a/src/routes/auth.ts b/src/routes/auth.ts index 2821e47..316bb4c 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -9,6 +9,7 @@ import { env } from "../env"; import { UserModel } from "../model/UserModel"; import { UserSessionModel } from "../model/UserSessionModel"; import UserTokenUtil from "../utils/UserTokenUtil"; +import { authenticator } from "otplib"; export default (async (fastify) => { const instance = fastify.withTypeProvider(); @@ -52,6 +53,7 @@ export default (async (fastify) => { body: z.object({ email: z.string().email(), password: z.string(), + otp: z.string().length(6).optional(), }), }, }, @@ -74,6 +76,25 @@ export default (async (fastify) => { return res.status(403).send("Invalid password"); } + if (user.totpEnabled) { + if (!user.totpSecret) { + return res.status(500).send("TOTP secret not found"); + } + + if (!req.body.otp) { + return res.status(403).send("OTP required"); + } + + if ( + !authenticator.verify({ + token: req.body.otp, + secret: user.totpSecret, + }) + ) { + return res.status(403).send("Invalid OTP"); + } + } + const userDto = UserDtoSchema.parse(user); // Send cookies diff --git a/src/routes/users/me.ts b/src/routes/users/me.ts index 244da95..99c1b85 100644 --- a/src/routes/users/me.ts +++ b/src/routes/users/me.ts @@ -1,6 +1,7 @@ import { FastifyPluginAsync } from "fastify"; import { ZodTypeProvider } from "fastify-type-provider-zod"; import otpGenerator from "otp-generator"; +import { authenticator } from "otplib"; import { z } from "zod"; import MailSender from "../../agents/MailSender"; import { UserDtoSchema } from "../../dto/user/userDto"; @@ -95,6 +96,113 @@ export default (async (fastify) => { return res.status(200).send(); }, ); + + instance.post( + "/totp/enable", + { + schema: { + tags: ["users"], + description: `Enable TOTP for the connected user. Will need verify with ${instance.prefix}/totp/enable/verify.`, + }, + }, + async function (req, res) { + if (req.user!.totpEnabled) { + return res.status(400).send("TOTP_ALREADY_ENABLED"); + } + + const secret = authenticator.generateSecret(); + + console.log(authenticator.generate(secret)); + + req.user!.totpSecret = secret; + + await req.user!.save(); + + return res.status(200).send({ + otpUri: authenticator.keyuri( + req.user!.username, + "Aquatracking", + secret, + ), + }); + }, + ); + + instance.post( + "/totp/enable/verify", + { + schema: { + tags: ["users"], + description: `Verify the TOTP code for the connected user. Use ${instance.prefix}/totp/enable to enable TOTP.`, + body: z.object({ + otp: z.string().length(6), + }), + }, + }, + async function (req, res) { + if (!req.user!.totpSecret) { + return res.status(400).send("NO_TOTP_SECRET"); + } + + if (req.user!.totpEnabled) { + return res.status(400).send("TOTP_ALREADY_ENABLED"); + } + + const verified = authenticator.verify({ + token: req.body.otp, + secret: req.user!.totpSecret, + }); + + if (!verified) { + return res.status(403).send("INVALID_TOTP_CODE"); + } + + req.user!.totpEnabled = true; + + await req.user!.save(); + + return res.status(200).send(); + }, + ); + + instance.post( + "/totp/disable", + { + schema: { + tags: ["users"], + description: `Disable TOTP for the connected user.`, + body: z.object({ + otp: z.string().length(6), + }), + }, + }, + async function (req, res) { + if (!req.user!.totpEnabled) { + return res.status(400).send("TOTP_NOT_ENABLED"); + } + + if (!req.user!.totpSecret) { + return res.status(500).send("NO_TOTP_SECRET"); + } + + if ( + !authenticator.verify({ + token: req.body.otp, + secret: req.user!.totpSecret, + }) + ) { + return res.status(403).send("INVALID_TOTP_CODE"); + } + + req.user!.totpSecret = undefined; + req.user!.totpEnabled = false; + + await req.user!.save(); + + return res.status(200).send(); + }, + ); + // TODO: to move in a admin dedicated route // instance.get( // "/", From 03ffc14426f94b83de64a35e75e7f86e8d9fc125 Mon Sep 17 00:00:00 2001 From: Bryan Date: Sat, 9 Mar 2024 11:54:40 +0100 Subject: [PATCH 03/11] [FEAT] Delete account --- src/app.ts | 1 + src/auth/isApplicationLoggedIn.ts | 2 +- src/auth/isSessionLoggedIn.ts | 2 +- src/model/UserModel.ts | 23 ++++++++++- src/model/db.ts | 3 ++ src/routes/auth.ts | 22 ++++++++++ src/routes/users/me.ts | 69 +++++++++++++++++++++++++++++-- 7 files changed, 116 insertions(+), 6 deletions(-) diff --git a/src/app.ts b/src/app.ts index 45f9e89..52fe680 100644 --- a/src/app.ts +++ b/src/app.ts @@ -142,5 +142,6 @@ declare module "fastify" { // Every day at 00:00 cron.schedule("0 0 * * *", () => { EmailValidationOTPModel.destroyExpiredTokens(); + UserModel.destroyExpiredDeletedUsers(); }); })(); diff --git a/src/auth/isApplicationLoggedIn.ts b/src/auth/isApplicationLoggedIn.ts index 85089ee..1ca6dab 100644 --- a/src/auth/isApplicationLoggedIn.ts +++ b/src/auth/isApplicationLoggedIn.ts @@ -26,7 +26,7 @@ export const isApplicationLoggedIn = (async (req, res) => { const user = await application?.getUserModel(); - if (!application || !user) { + if (!application || !user || user.deleteAt) { throw new NotLoggedError(); } diff --git a/src/auth/isSessionLoggedIn.ts b/src/auth/isSessionLoggedIn.ts index b34ad90..1859e85 100644 --- a/src/auth/isSessionLoggedIn.ts +++ b/src/auth/isSessionLoggedIn.ts @@ -22,7 +22,7 @@ export const isSessionLoggedIn = (async (req, res) => { const user = await session?.getUserModel(); - if (!session || !user) { + if (!session || !user || user.deleteAt) { throw new NotLoggedError(); } diff --git a/src/model/UserModel.ts b/src/model/UserModel.ts index a0d1b10..a58ee9f 100644 --- a/src/model/UserModel.ts +++ b/src/model/UserModel.ts @@ -5,9 +5,11 @@ import { InferAttributes, InferCreationAttributes, Model, + Op, } from "sequelize"; import { ApplicationModel } from "./ApplicationModel"; import { BiotopeModel } from "./BiotopeModel"; +import { UserSessionModel } from "./UserSessionModel"; export class UserModel extends Model< InferAttributes, @@ -19,11 +21,30 @@ export class UserModel extends Model< declare password: string; declare verified: CreationOptional; declare totpEnabled: CreationOptional; - declare totpSecret?: CreationOptional; + declare totpSecret?: CreationOptional; + declare deleteAt?: CreationOptional; declare getBiotopeModels: HasManyGetAssociationsMixin; declare createBiotopeModel: HasManyCreateAssociationMixin; declare getApplicationModels: HasManyGetAssociationsMixin; declare createApplicationModel: HasManyCreateAssociationMixin; + + destroyAllSessions() { + return UserSessionModel.destroy({ + where: { + userId: this.id, + }, + }); + } + + static destroyExpiredDeletedUsers() { + return UserModel.destroy({ + where: { + deleteAt: { + [Op.lt]: new Date(), + }, + }, + }); + } } diff --git a/src/model/db.ts b/src/model/db.ts index 093b4aa..46bd90a 100644 --- a/src/model/db.ts +++ b/src/model/db.ts @@ -64,6 +64,9 @@ export default class Db { totpSecret: { type: DataTypes.STRING, }, + deleteAt: { + type: DataTypes.DATE, + }, }, { sequelize, tableName: "users" }, ); diff --git a/src/routes/auth.ts b/src/routes/auth.ts index 316bb4c..4e14f17 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -23,6 +23,7 @@ export default (async (fastify) => { body: UserCreateDtoSchema, response: { 201: UserDtoSchema, + 409: z.string(), }, }, }, @@ -31,6 +32,26 @@ export default (async (fastify) => { return res.status(403).send(); } + const emailExists = await UserModel.findOne({ + where: { + email: req.body.email, + }, + }); + + if (emailExists) { + return res.status(409).send("EMAIL_ALREADY_EXISTS"); + } + + const usernameExists = await UserModel.findOne({ + where: { + username: req.body.username, + }, + }); + + if (usernameExists) { + return res.status(409).send("USERNAME_ALREADY_EXISTS"); + } + const hashPassword = await bcrypt.hash(req.body.password, 10); const user = await UserModel.create({ @@ -61,6 +82,7 @@ export default (async (fastify) => { const user = await UserModel.findOne({ where: { email: req.body.email, + deleteAt: null, }, }); diff --git a/src/routes/users/me.ts b/src/routes/users/me.ts index 99c1b85..7ed3de0 100644 --- a/src/routes/users/me.ts +++ b/src/routes/users/me.ts @@ -1,3 +1,4 @@ +import bcrypt from "bcryptjs"; import { FastifyPluginAsync } from "fastify"; import { ZodTypeProvider } from "fastify-type-provider-zod"; import otpGenerator from "otp-generator"; @@ -103,6 +104,9 @@ export default (async (fastify) => { schema: { tags: ["users"], description: `Enable TOTP for the connected user. Will need verify with ${instance.prefix}/totp/enable/verify.`, + body: z.object({ + password: z.string(), + }), }, }, async function (req, res) { @@ -110,9 +114,16 @@ export default (async (fastify) => { return res.status(400).send("TOTP_ALREADY_ENABLED"); } - const secret = authenticator.generateSecret(); + const isPasswordValid = await bcrypt.compare( + req.body.password, + req.user!.password, + ); - console.log(authenticator.generate(secret)); + if (!isPasswordValid) { + return res.status(403).send("INVALID_PASSWORD"); + } + + const secret = authenticator.generateSecret(); req.user!.totpSecret = secret; @@ -194,7 +205,7 @@ export default (async (fastify) => { return res.status(403).send("INVALID_TOTP_CODE"); } - req.user!.totpSecret = undefined; + req.user!.totpSecret = null; req.user!.totpEnabled = false; await req.user!.save(); @@ -203,6 +214,58 @@ export default (async (fastify) => { }, ); + instance.delete( + "/", + { + schema: { + tags: ["users"], + description: + "Delete the connected user. The data will be definitely lost after 30 days.", + body: z.object({ + password: z.string(), + otp: z.string().length(6).optional(), + }), + }, + }, + async function (req, res) { + const user = req.user!; + + const isPasswordValid = await bcrypt.compare( + req.body.password, + req.user!.password, + ); + if (!isPasswordValid) { + return res.status(403).send("Invalid password"); + } + + if (user.totpEnabled) { + if (!user.totpSecret) { + return res.status(500).send("TOTP secret not found"); + } + + if (!req.body.otp) { + return res.status(403).send("OTP required"); + } + + if ( + !authenticator.verify({ + token: req.body.otp, + secret: user.totpSecret, + }) + ) { + return res.status(403).send("Invalid OTP"); + } + } + + user.deleteAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); + user.destroyAllSessions(); + + await user.save(); + + return res.status(204).send(); + }, + ); + // TODO: to move in a admin dedicated route // instance.get( // "/", From db4615eda25f1dcc64abf4d45bb2489f643f8192 Mon Sep 17 00:00:00 2001 From: Bryan Date: Sat, 9 Mar 2024 18:27:16 +0100 Subject: [PATCH 04/11] [FEAT] Add http error on /auth and /user/me routes --- src/app.ts | 25 +++- src/auth/isApplicationLoggedIn.ts | 8 +- src/auth/isEmailValidated.ts | 8 +- src/auth/isSessionLoggedIn.ts | 6 +- src/errors/ApiError/ApiError.ts | 19 +++ .../ApiError/EmailAlreadyExistApiError.ts | 14 ++ .../ApiError/EmailAlreadyVerifiedApiError.ts | 14 ++ .../ApiError/EmailNotValidatedApiError.ts | 14 ++ .../ExpiredEmailVerificationCodeApiError.ts | 14 ++ .../InvalidEmailVerificationCodeApiError.ts | 14 ++ .../NoEmailVerificationCodeApiError.ts | 14 ++ src/errors/ApiError/NoTOTPSecretApiError.ts | 14 ++ src/errors/ApiError/NotLoggedApiError.ts | 14 ++ src/errors/ApiError/OTPRequiredApiError.ts | 14 ++ .../ApiError/TOTPAlreadyEnabledApiError.ts | 14 ++ src/errors/ApiError/TOTPNotEnabledApiError.ts | 14 ++ src/errors/ApiError/UserNotFoundApiError.ts | 14 ++ .../ApiError/UsernameAlreadyExistApiError.ts | 14 ++ src/errors/ApiError/WrongOTPApiError.ts | 14 ++ src/errors/ApiError/WrongPasswordApiError.ts | 14 ++ src/errors/BadRequestError.ts | 5 - src/errors/EmailAlreadyExistError.ts | 1 - src/errors/NotEmailValidatedError.ts | 1 - src/errors/NotFoundError.ts | 1 - src/errors/NotLoggedError.ts | 1 - src/errors/UsernameAlreadyExistError.ts | 1 - src/errors/WrongPasswordError.ts | 1 - src/model/UserModel.ts | 30 ++++ src/model/db.ts | 1 + src/routes/auth.ts | 61 ++++---- src/routes/users/me.ts | 130 ++++++++++-------- 31 files changed, 395 insertions(+), 114 deletions(-) create mode 100644 src/errors/ApiError/ApiError.ts create mode 100644 src/errors/ApiError/EmailAlreadyExistApiError.ts create mode 100644 src/errors/ApiError/EmailAlreadyVerifiedApiError.ts create mode 100644 src/errors/ApiError/EmailNotValidatedApiError.ts create mode 100644 src/errors/ApiError/ExpiredEmailVerificationCodeApiError.ts create mode 100644 src/errors/ApiError/InvalidEmailVerificationCodeApiError.ts create mode 100644 src/errors/ApiError/NoEmailVerificationCodeApiError.ts create mode 100644 src/errors/ApiError/NoTOTPSecretApiError.ts create mode 100644 src/errors/ApiError/NotLoggedApiError.ts create mode 100644 src/errors/ApiError/OTPRequiredApiError.ts create mode 100644 src/errors/ApiError/TOTPAlreadyEnabledApiError.ts create mode 100644 src/errors/ApiError/TOTPNotEnabledApiError.ts create mode 100644 src/errors/ApiError/UserNotFoundApiError.ts create mode 100644 src/errors/ApiError/UsernameAlreadyExistApiError.ts create mode 100644 src/errors/ApiError/WrongOTPApiError.ts create mode 100644 src/errors/ApiError/WrongPasswordApiError.ts delete mode 100644 src/errors/BadRequestError.ts delete mode 100644 src/errors/EmailAlreadyExistError.ts delete mode 100644 src/errors/NotEmailValidatedError.ts delete mode 100644 src/errors/NotFoundError.ts delete mode 100644 src/errors/NotLoggedError.ts delete mode 100644 src/errors/UsernameAlreadyExistError.ts delete mode 100644 src/errors/WrongPasswordError.ts diff --git a/src/app.ts b/src/app.ts index 52fe680..044b4fc 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,6 +1,7 @@ import * as dotenv from "dotenv"; import Fastify from "fastify"; import { + ZodTypeProvider, jsonSchemaTransform, serializerCompiler, validatorCompiler, @@ -8,14 +9,15 @@ import { import fs from "fs"; import cron from "node-cron"; import { isApplicationLoggedIn } from "./auth/isApplicationLoggedIn"; +import { isEmailValidated } from "./auth/isEmailValidated"; import { isSessionLoggedIn } from "./auth/isSessionLoggedIn"; import { ensureValidEnv, env } from "./env"; +import { ApiError } from "./errors/ApiError/ApiError"; import { BiotopeModel } from "./model/BiotopeModel"; import { EmailValidationOTPModel } from "./model/EmailValidationOTPModel"; import { MeasurementTypeModel } from "./model/MeasurementTypeModel"; import { UserModel } from "./model/UserModel"; import Db from "./model/db"; -import { isEmailValidated } from "./auth/isEmailValidated"; // - - - - - Environment variables - - - - - // if (fs.existsSync(".env")) { @@ -106,6 +108,27 @@ declare module "fastify" { await fastify.register(import("@fastify/cookie")); + // - - - - - Error handling - - - - - // + fastify + .withTypeProvider() + .setErrorHandler((error, request, reply) => { + const finalError = { + statusCode: 500, + error: "Internal Server Error", + code: "INTERNAL_SERVER_ERROR", + data: undefined as unknown, + }; + + if (error instanceof ApiError) { + finalError.statusCode = error.statusCode; + finalError.error = error.error; + finalError.code = error.code; + finalError.data = error.data; + } + + return reply.status(finalError.statusCode).send(finalError); + }); + // - - - - - Routes - - - - - // await fastify.register(import("./routes/auth"), { prefix: "/auth", diff --git a/src/auth/isApplicationLoggedIn.ts b/src/auth/isApplicationLoggedIn.ts index 1ca6dab..0deeb1c 100644 --- a/src/auth/isApplicationLoggedIn.ts +++ b/src/auth/isApplicationLoggedIn.ts @@ -1,6 +1,6 @@ import { FastifyAuthFunction } from "@fastify/auth"; import { env } from "../env"; -import { NotLoggedError } from "../errors/NotLoggedError"; +import { NotLoggedApiError } from "../errors/ApiError/NotLoggedApiError"; import * as jwt from "../jwt"; import { ApplicationModel } from "../model/ApplicationModel"; @@ -8,13 +8,13 @@ export const isApplicationLoggedIn = (async (req, res) => { const token = req.headers["x-api-key"] as string; if (!token) { - throw new NotLoggedError(); + throw new NotLoggedApiError(); } const jwtUser = await jwt.verify(token, env.APPLICATION_TOKEN_SECRET); if (!jwtUser.id) { - throw new NotLoggedError(); + throw new NotLoggedApiError(); } const application = await ApplicationModel.findOne({ @@ -27,7 +27,7 @@ export const isApplicationLoggedIn = (async (req, res) => { const user = await application?.getUserModel(); if (!application || !user || user.deleteAt) { - throw new NotLoggedError(); + throw new NotLoggedApiError(); } req.user = user; diff --git a/src/auth/isEmailValidated.ts b/src/auth/isEmailValidated.ts index 3687786..db993f9 100644 --- a/src/auth/isEmailValidated.ts +++ b/src/auth/isEmailValidated.ts @@ -1,13 +1,13 @@ import { FastifyAuthFunction } from "@fastify/auth"; -import { NotEmailValidatedError } from "../errors/NotEmailValidatedError"; -import { NotLoggedError } from "../errors/NotLoggedError"; +import { EmailNotValidatedApiError } from "../errors/ApiError/EmailNotValidatedApiError"; +import { NotLoggedApiError } from "../errors/ApiError/NotLoggedApiError"; export const isEmailValidated = (async (req, res) => { if (!req.user) { - throw new NotLoggedError(); + throw new NotLoggedApiError(); } if (!req.user.verified) { - throw new NotEmailValidatedError(); + throw new EmailNotValidatedApiError(); } }) satisfies FastifyAuthFunction; diff --git a/src/auth/isSessionLoggedIn.ts b/src/auth/isSessionLoggedIn.ts index 1859e85..aa738af 100644 --- a/src/auth/isSessionLoggedIn.ts +++ b/src/auth/isSessionLoggedIn.ts @@ -1,5 +1,5 @@ import { FastifyAuthFunction } from "@fastify/auth"; -import { NotLoggedError } from "../errors/NotLoggedError"; +import { NotLoggedApiError } from "../errors/ApiError/NotLoggedApiError"; import * as jwt from "../jwt"; import { UserSessionModel } from "../model/UserSessionModel"; import { env } from "../env"; @@ -8,7 +8,7 @@ export const isSessionLoggedIn = (async (req, res) => { const token = req.cookies["session-token"]; if (!token) { - throw new NotLoggedError(); + throw new NotLoggedApiError(); } const jwtUser = await jwt.verify(token, env.ACCESS_TOKEN_SECRET); @@ -23,7 +23,7 @@ export const isSessionLoggedIn = (async (req, res) => { const user = await session?.getUserModel(); if (!session || !user || user.deleteAt) { - throw new NotLoggedError(); + throw new NotLoggedApiError(); } session.lastConnectionDate = new Date(); diff --git a/src/errors/ApiError/ApiError.ts b/src/errors/ApiError/ApiError.ts new file mode 100644 index 0000000..95a9413 --- /dev/null +++ b/src/errors/ApiError/ApiError.ts @@ -0,0 +1,19 @@ +export abstract class ApiError extends Error { + statusCode: number; + error: string; + code: string; + data?: unknown; + + constructor( + statusCode: number, + error: string, + code: string, + data?: unknown, + ) { + super(); + this.statusCode = statusCode; + this.error = error; + this.code = code; + this.data = data; + } +} diff --git a/src/errors/ApiError/EmailAlreadyExistApiError.ts b/src/errors/ApiError/EmailAlreadyExistApiError.ts new file mode 100644 index 0000000..d9ad0e3 --- /dev/null +++ b/src/errors/ApiError/EmailAlreadyExistApiError.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; +import { ApiError } from "./ApiError"; + +export class EmailAlreadyExistApiError extends ApiError { + static readonly schema = z.object({ + statusCode: z.literal(409), + error: z.literal("Conflict"), + code: z.literal("EMAIL_ALREADY_EXIST"), + }); + + constructor() { + super(409, "Conflict", "EMAIL_ALREADY_EXIST"); + } +} diff --git a/src/errors/ApiError/EmailAlreadyVerifiedApiError.ts b/src/errors/ApiError/EmailAlreadyVerifiedApiError.ts new file mode 100644 index 0000000..5c04f03 --- /dev/null +++ b/src/errors/ApiError/EmailAlreadyVerifiedApiError.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; +import { ApiError } from "./ApiError"; + +export class EmailAlreadyVerifiedApiError extends ApiError { + static readonly schema = z.object({ + statusCode: z.literal(400), + error: z.literal("Bad Request"), + code: z.literal("EMAIL_ALREADY_VERIFIED"), + }); + + constructor() { + super(400, "Bad Request", "EMAIL_ALREADY_VERIFIED"); + } +} diff --git a/src/errors/ApiError/EmailNotValidatedApiError.ts b/src/errors/ApiError/EmailNotValidatedApiError.ts new file mode 100644 index 0000000..4b5846e --- /dev/null +++ b/src/errors/ApiError/EmailNotValidatedApiError.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; +import { ApiError } from "./ApiError"; + +export class EmailNotValidatedApiError extends ApiError { + static readonly schema = z.object({ + statusCode: z.literal(403), + error: z.literal("Forbidden"), + code: z.literal("EMAIL_NOT_VALIDATED"), + }); + + constructor() { + super(403, "Forbidden", "EMAIL_NOT_VALIDATED"); + } +} diff --git a/src/errors/ApiError/ExpiredEmailVerificationCodeApiError.ts b/src/errors/ApiError/ExpiredEmailVerificationCodeApiError.ts new file mode 100644 index 0000000..913ed26 --- /dev/null +++ b/src/errors/ApiError/ExpiredEmailVerificationCodeApiError.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; +import { ApiError } from "./ApiError"; + +export class ExpiredEmailVerificationCodeApiError extends ApiError { + static readonly schema = z.object({ + statusCode: z.literal(403), + error: z.literal("Forbidden"), + code: z.literal("EXPIRED_EMAIL_VERIFICATION_CODE"), + }); + + constructor() { + super(403, "Forbidden", "EXPIRED_EMAIL_VERIFICATION_CODE"); + } +} diff --git a/src/errors/ApiError/InvalidEmailVerificationCodeApiError.ts b/src/errors/ApiError/InvalidEmailVerificationCodeApiError.ts new file mode 100644 index 0000000..0b08dfe --- /dev/null +++ b/src/errors/ApiError/InvalidEmailVerificationCodeApiError.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; +import { ApiError } from "./ApiError"; + +export class InvalidEmailVerificationCodeApiError extends ApiError { + static readonly schema = z.object({ + statusCode: z.literal(403), + error: z.literal("Forbidden"), + code: z.literal("INVALID_EMAIL_VERIFICATION_CODE"), + }); + + constructor() { + super(403, "Forbidden", "INVALID_EMAIL_VERIFICATION_CODE"); + } +} diff --git a/src/errors/ApiError/NoEmailVerificationCodeApiError.ts b/src/errors/ApiError/NoEmailVerificationCodeApiError.ts new file mode 100644 index 0000000..f601ee5 --- /dev/null +++ b/src/errors/ApiError/NoEmailVerificationCodeApiError.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; +import { ApiError } from "./ApiError"; + +export class NoEmailVerificationCodeApiError extends ApiError { + static readonly schema = z.object({ + statusCode: z.literal(403), + error: z.literal("Forbidden"), + code: z.literal("NO_EMAIL_VERIFICATION_CODE"), + }); + + constructor() { + super(403, "Forbidden", "NO_EMAIL_VERIFICATION_CODE"); + } +} diff --git a/src/errors/ApiError/NoTOTPSecretApiError.ts b/src/errors/ApiError/NoTOTPSecretApiError.ts new file mode 100644 index 0000000..0c3be93 --- /dev/null +++ b/src/errors/ApiError/NoTOTPSecretApiError.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; +import { ApiError } from "./ApiError"; + +export class NoTOTPSecretApiError extends ApiError { + static readonly schema = z.object({ + statusCode: z.literal(400), + error: z.literal("Bad Request"), + code: z.literal("NO_TOTP_SECRET"), + }); + + constructor() { + super(400, "Bad Request", "NO_TOTP_SECRET"); + } +} diff --git a/src/errors/ApiError/NotLoggedApiError.ts b/src/errors/ApiError/NotLoggedApiError.ts new file mode 100644 index 0000000..89c1bda --- /dev/null +++ b/src/errors/ApiError/NotLoggedApiError.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; +import { ApiError } from "./ApiError"; + +export class NotLoggedApiError extends ApiError { + static readonly schema = z.object({ + statusCode: z.literal(401), + error: z.literal("Unauthorized"), + code: z.literal("NOT_LOGGED"), + }); + + constructor() { + super(401, "Unauthorized", "NOT_LOGGED"); + } +} diff --git a/src/errors/ApiError/OTPRequiredApiError.ts b/src/errors/ApiError/OTPRequiredApiError.ts new file mode 100644 index 0000000..808ee8c --- /dev/null +++ b/src/errors/ApiError/OTPRequiredApiError.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; +import { ApiError } from "./ApiError"; + +export class OTPRequiredApiError extends ApiError { + static readonly schema = z.object({ + statusCode: z.literal(403), + error: z.literal("Forbidden"), + code: z.literal("OTP_REQUIRED"), + }); + + constructor() { + super(403, "Forbidden", "OTP_REQUIRED"); + } +} diff --git a/src/errors/ApiError/TOTPAlreadyEnabledApiError.ts b/src/errors/ApiError/TOTPAlreadyEnabledApiError.ts new file mode 100644 index 0000000..59a129c --- /dev/null +++ b/src/errors/ApiError/TOTPAlreadyEnabledApiError.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; +import { ApiError } from "./ApiError"; + +export class TOTPAlreadyEnabledApiError extends ApiError { + static readonly schema = z.object({ + statusCode: z.literal(400), + error: z.literal("Bad Request"), + code: z.literal("TOTP_ALREADY_ENABLED"), + }); + + constructor() { + super(400, "Bad Request", "TOTP_ALREADY_ENABLED"); + } +} diff --git a/src/errors/ApiError/TOTPNotEnabledApiError.ts b/src/errors/ApiError/TOTPNotEnabledApiError.ts new file mode 100644 index 0000000..28f8137 --- /dev/null +++ b/src/errors/ApiError/TOTPNotEnabledApiError.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; +import { ApiError } from "./ApiError"; + +export class TOTPNotEnabledApiError extends ApiError { + static readonly schema = z.object({ + statusCode: z.literal(400), + error: z.literal("Bad Request"), + code: z.literal("TOTP_NOT_ENABLED"), + }); + + constructor() { + super(400, "Bad Request", "TOTP_NOT_ENABLED"); + } +} diff --git a/src/errors/ApiError/UserNotFoundApiError.ts b/src/errors/ApiError/UserNotFoundApiError.ts new file mode 100644 index 0000000..fe8f687 --- /dev/null +++ b/src/errors/ApiError/UserNotFoundApiError.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; +import { ApiError } from "./ApiError"; + +export class UserNotFoundApiError extends ApiError { + static readonly schema = z.object({ + statusCode: z.literal(404), + error: z.literal("Not Found"), + code: z.literal("USER_NOT_FOUND"), + }); + + constructor() { + super(404, "Not Found", "USER_NOT_FOUND"); + } +} diff --git a/src/errors/ApiError/UsernameAlreadyExistApiError.ts b/src/errors/ApiError/UsernameAlreadyExistApiError.ts new file mode 100644 index 0000000..be9a72a --- /dev/null +++ b/src/errors/ApiError/UsernameAlreadyExistApiError.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; +import { ApiError } from "./ApiError"; + +export class UsernameAlreadyExistApiError extends ApiError { + static readonly schema = z.object({ + statusCode: z.literal(409), + error: z.literal("Conflict"), + code: z.literal("USERNAME_ALREADY_EXIST"), + }); + + constructor() { + super(409, "Conflict", "USERNAME_ALREADY_EXIST"); + } +} diff --git a/src/errors/ApiError/WrongOTPApiError.ts b/src/errors/ApiError/WrongOTPApiError.ts new file mode 100644 index 0000000..51ae994 --- /dev/null +++ b/src/errors/ApiError/WrongOTPApiError.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; +import { ApiError } from "./ApiError"; + +export class WrongOTPApiError extends ApiError { + static readonly schema = z.object({ + statusCode: z.literal(403), + error: z.literal("Forbidden"), + code: z.literal("WRONG_OTP"), + }); + + constructor() { + super(403, "Forbidden", "WRONG_OTP"); + } +} diff --git a/src/errors/ApiError/WrongPasswordApiError.ts b/src/errors/ApiError/WrongPasswordApiError.ts new file mode 100644 index 0000000..fcebe7e --- /dev/null +++ b/src/errors/ApiError/WrongPasswordApiError.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; +import { ApiError } from "./ApiError"; + +export class WrongPasswordApiError extends ApiError { + static readonly schema = z.object({ + statusCode: z.literal(403), + error: z.literal("Forbidden"), + code: z.literal("WRONG_PASSWORD"), + }); + + constructor() { + super(403, "Forbidden", "WRONG_PASSWORD"); + } +} diff --git a/src/errors/BadRequestError.ts b/src/errors/BadRequestError.ts deleted file mode 100644 index f841f3b..0000000 --- a/src/errors/BadRequestError.ts +++ /dev/null @@ -1,5 +0,0 @@ -export default class BadRequestError extends Error { - constructor(message?: string) { - super(message); - } -} diff --git a/src/errors/EmailAlreadyExistError.ts b/src/errors/EmailAlreadyExistError.ts deleted file mode 100644 index e2f5eb0..0000000 --- a/src/errors/EmailAlreadyExistError.ts +++ /dev/null @@ -1 +0,0 @@ -export default class EmailAlreadyExistError extends Error {} diff --git a/src/errors/NotEmailValidatedError.ts b/src/errors/NotEmailValidatedError.ts deleted file mode 100644 index ec64bb0..0000000 --- a/src/errors/NotEmailValidatedError.ts +++ /dev/null @@ -1 +0,0 @@ -export class NotEmailValidatedError extends Error {} diff --git a/src/errors/NotFoundError.ts b/src/errors/NotFoundError.ts deleted file mode 100644 index 1f587e5..0000000 --- a/src/errors/NotFoundError.ts +++ /dev/null @@ -1 +0,0 @@ -export default class NotFoundError extends Error {} diff --git a/src/errors/NotLoggedError.ts b/src/errors/NotLoggedError.ts deleted file mode 100644 index 6b44c73..0000000 --- a/src/errors/NotLoggedError.ts +++ /dev/null @@ -1 +0,0 @@ -export class NotLoggedError extends Error {} diff --git a/src/errors/UsernameAlreadyExistError.ts b/src/errors/UsernameAlreadyExistError.ts deleted file mode 100644 index 2ca27e7..0000000 --- a/src/errors/UsernameAlreadyExistError.ts +++ /dev/null @@ -1 +0,0 @@ -export default class UsernameAlreadyExistError extends Error {} diff --git a/src/errors/WrongPasswordError.ts b/src/errors/WrongPasswordError.ts deleted file mode 100644 index 89d13ae..0000000 --- a/src/errors/WrongPasswordError.ts +++ /dev/null @@ -1 +0,0 @@ -export default class WrongPasswordError extends Error {} diff --git a/src/model/UserModel.ts b/src/model/UserModel.ts index a58ee9f..f11d072 100644 --- a/src/model/UserModel.ts +++ b/src/model/UserModel.ts @@ -1,3 +1,4 @@ +import bcrypt from "bcryptjs"; import { CreationOptional, HasManyCreateAssociationMixin, @@ -10,6 +11,10 @@ import { import { ApplicationModel } from "./ApplicationModel"; import { BiotopeModel } from "./BiotopeModel"; import { UserSessionModel } from "./UserSessionModel"; +import { authenticator } from "otplib"; +import { WrongPasswordApiError } from "../errors/ApiError/WrongPasswordApiError"; +import { OTPRequiredApiError } from "../errors/ApiError/OTPRequiredApiError"; +import { WrongOTPApiError } from "../errors/ApiError/WrongOTPApiError"; export class UserModel extends Model< InferAttributes, @@ -30,6 +35,31 @@ export class UserModel extends Model< declare getApplicationModels: HasManyGetAssociationsMixin; declare createApplicationModel: HasManyCreateAssociationMixin; + async checkPassword(password: string): Promise { + const isPasswordValid = await bcrypt.compare(password, this.password); + if (!isPasswordValid) { + throw new WrongPasswordApiError(); + } + } + + checkOTP(otp?: string): void { + if (!this.totpEnabled) { + return; + } + + if (!this.totpSecret) { + throw new Error("TOTP secret not found"); + } + + if (!otp) { + throw new OTPRequiredApiError(); + } + + if (!authenticator.verify({ token: otp, secret: this.totpSecret })) { + throw new WrongOTPApiError(); + } + } + destroyAllSessions() { return UserSessionModel.destroy({ where: { diff --git a/src/model/db.ts b/src/model/db.ts index 46bd90a..65315b9 100644 --- a/src/model/db.ts +++ b/src/model/db.ts @@ -23,6 +23,7 @@ export default class Db { dialect: "mysql", host: env.MARIADB_HOST, port: env.MARIADB_PORT, + logging: false, }, ); await Db.sequelize.authenticate(); diff --git a/src/routes/auth.ts b/src/routes/auth.ts index 4e14f17..edcdeea 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -6,10 +6,16 @@ import MailSender from "../agents/MailSender"; import { UserCreateDtoSchema } from "../dto/user/userCreateDto"; import { UserDtoSchema } from "../dto/user/userDto"; import { env } from "../env"; +import { EmailAlreadyExistApiError } from "../errors/ApiError/EmailAlreadyExistApiError"; +import { OTPRequiredApiError } from "../errors/ApiError/OTPRequiredApiError"; +import { UserNotFoundApiError } from "../errors/ApiError/UserNotFoundApiError"; +import { UsernameAlreadyExistApiError } from "../errors/ApiError/UsernameAlreadyExistApiError"; +import { WrongOTPApiError } from "../errors/ApiError/WrongOTPApiError"; +import { WrongPasswordApiError } from "../errors/ApiError/WrongPasswordApiError"; import { UserModel } from "../model/UserModel"; import { UserSessionModel } from "../model/UserSessionModel"; import UserTokenUtil from "../utils/UserTokenUtil"; -import { authenticator } from "otplib"; +import { NotLoggedApiError } from "../errors/ApiError/NotLoggedApiError"; export default (async (fastify) => { const instance = fastify.withTypeProvider(); @@ -23,7 +29,10 @@ export default (async (fastify) => { body: UserCreateDtoSchema, response: { 201: UserDtoSchema, - 409: z.string(), + 409: z.union([ + UsernameAlreadyExistApiError.schema, + EmailAlreadyExistApiError.schema, + ]), }, }, }, @@ -39,7 +48,7 @@ export default (async (fastify) => { }); if (emailExists) { - return res.status(409).send("EMAIL_ALREADY_EXISTS"); + throw new EmailAlreadyExistApiError(); } const usernameExists = await UserModel.findOne({ @@ -49,7 +58,7 @@ export default (async (fastify) => { }); if (usernameExists) { - return res.status(409).send("USERNAME_ALREADY_EXISTS"); + throw new UsernameAlreadyExistApiError(); } const hashPassword = await bcrypt.hash(req.body.password, 10); @@ -76,6 +85,15 @@ export default (async (fastify) => { password: z.string(), otp: z.string().length(6).optional(), }), + response: { + 200: UserDtoSchema, + 404: UserNotFoundApiError.schema, + 403: z.union([ + WrongPasswordApiError.schema, + WrongOTPApiError.schema, + OTPRequiredApiError.schema, + ]), + }, }, }, async function (req, res) { @@ -87,35 +105,11 @@ export default (async (fastify) => { }); if (!user) { - return res.status(404).send("User not found"); - } - - const isPasswordValid = await bcrypt.compare( - req.body.password, - user.password, - ); - if (!isPasswordValid) { - return res.status(403).send("Invalid password"); + throw new UserNotFoundApiError(); } - if (user.totpEnabled) { - if (!user.totpSecret) { - return res.status(500).send("TOTP secret not found"); - } - - if (!req.body.otp) { - return res.status(403).send("OTP required"); - } - - if ( - !authenticator.verify({ - token: req.body.otp, - secret: user.totpSecret, - }) - ) { - return res.status(403).send("Invalid OTP"); - } - } + await user.checkPassword(req.body.password); + user.checkOTP(req.body.otp); const userDto = UserDtoSchema.parse(user); @@ -151,6 +145,7 @@ export default (async (fastify) => { description: "Logout a user and delete his session", response: { 204: z.void(), + 401: NotLoggedApiError.schema, }, }, }, @@ -158,7 +153,7 @@ export default (async (fastify) => { const token = req.cookies["session-token"]; if (!token) { - return res.status(401).send(); + throw new NotLoggedApiError(); } const session = await UserSessionModel.findOne({ @@ -168,7 +163,7 @@ export default (async (fastify) => { }); if (!session) { - return res.status(401).send(); + throw new NotLoggedApiError(); } await session.destroy(); diff --git a/src/routes/users/me.ts b/src/routes/users/me.ts index 7ed3de0..3cdfce6 100644 --- a/src/routes/users/me.ts +++ b/src/routes/users/me.ts @@ -7,6 +7,16 @@ import { z } from "zod"; import MailSender from "../../agents/MailSender"; import { UserDtoSchema } from "../../dto/user/userDto"; import { EmailValidationOTPModel } from "../../model/EmailValidationOTPModel"; +import { EmailAlreadyVerifiedApiError } from "../../errors/ApiError/EmailAlreadyVerifiedApiError"; +import { NoEmailVerificationCodeApiError } from "../../errors/ApiError/NoEmailVerificationCodeApiError"; +import { ExpiredEmailVerificationCodeApiError } from "../../errors/ApiError/ExpiredEmailVerificationCodeApiError"; +import { InvalidEmailVerificationCodeApiError } from "../../errors/ApiError/InvalidEmailVerificationCodeApiError"; +import { WrongPasswordApiError } from "../../errors/ApiError/WrongPasswordApiError"; +import { TOTPAlreadyEnabledApiError } from "../../errors/ApiError/TOTPAlreadyEnabledApiError"; +import { NoTOTPSecretApiError } from "../../errors/ApiError/NoTOTPSecretApiError"; +import { WrongOTPApiError } from "../../errors/ApiError/WrongOTPApiError"; +import { TOTPNotEnabledApiError } from "../../errors/ApiError/TOTPNotEnabledApiError"; +import { OTPRequiredApiError } from "../../errors/ApiError/OTPRequiredApiError"; export default (async (fastify) => { const instance = fastify.withTypeProvider(); @@ -18,13 +28,17 @@ export default (async (fastify) => { tags: ["users"], description: "Send a code to the connected user's email to verify it. The code is valid for 5 minutes.", + response: { + 200: z.void(), + 409: EmailAlreadyVerifiedApiError.schema, + }, }, }, async function (req, res) { const userEmail = req.user!.email; if (req.user!.verified) { - return res.status(400).send("EMAIL_ALREADY_VERIFIED"); + throw new EmailAlreadyVerifiedApiError(); } let oldEmailToken = await EmailValidationOTPModel.findOne({ @@ -54,7 +68,7 @@ export default (async (fastify) => { `Bonjour,\n\nVoici votre code de validation: ${emailToken.code}\n\nCe code est valable 5 minutes.`, ); - return res.status(204).send(); + return res.status(200).send(); }, ); @@ -65,13 +79,22 @@ export default (async (fastify) => { tags: ["users"], description: `Verify the user's email with the code sent. Use ${instance.prefix}/verify-email/send-code to send a code.`, body: z.object({ - code: z.string(), + code: z.string().length(6), }), + response: { + 200: z.void(), + 403: z.union([ + NoEmailVerificationCodeApiError.schema, + ExpiredEmailVerificationCodeApiError.schema, + InvalidEmailVerificationCodeApiError.schema, + ]), + 409: EmailAlreadyVerifiedApiError.schema, + }, }, }, async function (req, res) { if (req.user!.verified) { - return res.status(400).send("EMAIL_ALREADY_VERIFIED"); + throw new EmailAlreadyVerifiedApiError(); } let emailToken = await EmailValidationOTPModel.findOne({ @@ -81,12 +104,12 @@ export default (async (fastify) => { }); if (!emailToken) { - return res.status(403).send("NO_EMAIL_VERIFICATION_CODE"); + throw new NoEmailVerificationCodeApiError(); } else if (emailToken.expiresAt < new Date()) { await emailToken.destroy(); - return res.status(403).send("EMAIL_VERIFICATION_CODE_EXPIRED"); + throw new ExpiredEmailVerificationCodeApiError(); } else if (emailToken.code !== req.body.code) { - return res.status(403).send("INVALID_EMAIL_VERIFICATION_CODE"); + throw new InvalidEmailVerificationCodeApiError(); } req.user!.verified = true; @@ -107,21 +130,21 @@ export default (async (fastify) => { body: z.object({ password: z.string(), }), + response: { + 200: z.object({ + otpUri: z.string(), + }), + 400: TOTPAlreadyEnabledApiError.schema, + 403: WrongPasswordApiError.schema, + }, }, }, async function (req, res) { if (req.user!.totpEnabled) { - return res.status(400).send("TOTP_ALREADY_ENABLED"); + throw new TOTPAlreadyEnabledApiError(); } - const isPasswordValid = await bcrypt.compare( - req.body.password, - req.user!.password, - ); - - if (!isPasswordValid) { - return res.status(403).send("INVALID_PASSWORD"); - } + await req.user!.checkPassword(req.body.password); const secret = authenticator.generateSecret(); @@ -148,15 +171,23 @@ export default (async (fastify) => { body: z.object({ otp: z.string().length(6), }), + response: { + 200: z.void(), + 400: z.union([ + NoTOTPSecretApiError.schema, + TOTPAlreadyEnabledApiError.schema, + ]), + 403: WrongOTPApiError.schema, + }, }, }, async function (req, res) { if (!req.user!.totpSecret) { - return res.status(400).send("NO_TOTP_SECRET"); + throw new NoTOTPSecretApiError(); } if (req.user!.totpEnabled) { - return res.status(400).send("TOTP_ALREADY_ENABLED"); + throw new TOTPAlreadyEnabledApiError(); } const verified = authenticator.verify({ @@ -165,7 +196,7 @@ export default (async (fastify) => { }); if (!verified) { - return res.status(403).send("INVALID_TOTP_CODE"); + throw new WrongOTPApiError(); } req.user!.totpEnabled = true; @@ -183,27 +214,26 @@ export default (async (fastify) => { tags: ["users"], description: `Disable TOTP for the connected user.`, body: z.object({ + password: z.string(), otp: z.string().length(6), }), + response: { + 200: z.void(), + 400: TOTPNotEnabledApiError.schema, + 403: z.union([ + WrongPasswordApiError.schema, + WrongOTPApiError.schema, + ]), + }, }, }, async function (req, res) { if (!req.user!.totpEnabled) { - return res.status(400).send("TOTP_NOT_ENABLED"); + throw new TOTPNotEnabledApiError(); } - if (!req.user!.totpSecret) { - return res.status(500).send("NO_TOTP_SECRET"); - } - - if ( - !authenticator.verify({ - token: req.body.otp, - secret: req.user!.totpSecret, - }) - ) { - return res.status(403).send("INVALID_TOTP_CODE"); - } + await req.user!.checkPassword(req.body.password); + req.user!.checkOTP(req.body.otp); req.user!.totpSecret = null; req.user!.totpEnabled = false; @@ -225,37 +255,21 @@ export default (async (fastify) => { password: z.string(), otp: z.string().length(6).optional(), }), + response: { + 204: z.void(), + 403: z.union([ + WrongPasswordApiError.schema, + WrongOTPApiError.schema, + OTPRequiredApiError.schema, + ]), + }, }, }, async function (req, res) { const user = req.user!; - const isPasswordValid = await bcrypt.compare( - req.body.password, - req.user!.password, - ); - if (!isPasswordValid) { - return res.status(403).send("Invalid password"); - } - - if (user.totpEnabled) { - if (!user.totpSecret) { - return res.status(500).send("TOTP secret not found"); - } - - if (!req.body.otp) { - return res.status(403).send("OTP required"); - } - - if ( - !authenticator.verify({ - token: req.body.otp, - secret: user.totpSecret, - }) - ) { - return res.status(403).send("Invalid OTP"); - } - } + await user.checkPassword(req.body.password); + user.checkOTP(req.body.otp); user.deleteAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); user.destroyAllSessions(); From 5def75b5465a61df3814b9fc743e1b33966ac86b Mon Sep 17 00:00:00 2001 From: Bryan Date: Sun, 10 Mar 2024 16:02:07 +0100 Subject: [PATCH 05/11] [FEAT] Add admin routes --- src/app.ts | 15 ++ src/auth/isAdminLoggedIn.ts | 13 ++ src/dto/admin/user/AdminUserDto.ts | 13 ++ src/dto/user/userDto.ts | 1 + src/errors/ApiError/CantDeleteItSelf.ts | 14 ++ src/errors/ApiError/UserNotAdminApiError.ts | 14 ++ src/model/UserModel.ts | 1 + src/model/db.ts | 5 + src/routes/admin/users.ts | 171 ++++++++++++++++++++ src/routes/users/me.ts | 91 +---------- 10 files changed, 253 insertions(+), 85 deletions(-) create mode 100644 src/auth/isAdminLoggedIn.ts create mode 100644 src/dto/admin/user/AdminUserDto.ts create mode 100644 src/errors/ApiError/CantDeleteItSelf.ts create mode 100644 src/errors/ApiError/UserNotAdminApiError.ts create mode 100644 src/routes/admin/users.ts diff --git a/src/app.ts b/src/app.ts index 044b4fc..80e656f 100644 --- a/src/app.ts +++ b/src/app.ts @@ -18,6 +18,7 @@ import { EmailValidationOTPModel } from "./model/EmailValidationOTPModel"; import { MeasurementTypeModel } from "./model/MeasurementTypeModel"; import { UserModel } from "./model/UserModel"; import Db from "./model/db"; +import { isAdminLoggedIn } from "./auth/isAdminLoggedIn"; // - - - - - Environment variables - - - - - // if (fs.existsSync(".env")) { @@ -158,6 +159,20 @@ declare module "fastify" { await fastify.register(import("./routes/biotopes/terrariums"), { prefix: "/terrariums", }); + + instance.register( + async (instance) => { + instance.addHook( + "preHandler", + instance.auth([isAdminLoggedIn]), + ); + + await fastify.register(import("./routes/admin/users"), { + prefix: "/users", + }); + }, + { prefix: "/admin" }, + ); }); }); diff --git a/src/auth/isAdminLoggedIn.ts b/src/auth/isAdminLoggedIn.ts new file mode 100644 index 0000000..b9b4cad --- /dev/null +++ b/src/auth/isAdminLoggedIn.ts @@ -0,0 +1,13 @@ +import { FastifyAuthFunction } from "@fastify/auth"; +import { NotLoggedApiError } from "../errors/ApiError/NotLoggedApiError"; +import { UserNotAdminApiError } from "../errors/ApiError/UserNotAdminApiError"; + +export const isAdminLoggedIn = (async (req, res) => { + if (!req.user) { + throw new NotLoggedApiError(); + } + + if (!req.user.isAdmin) { + throw new UserNotAdminApiError(); + } +}) satisfies FastifyAuthFunction; diff --git a/src/dto/admin/user/AdminUserDto.ts b/src/dto/admin/user/AdminUserDto.ts new file mode 100644 index 0000000..ff1b896 --- /dev/null +++ b/src/dto/admin/user/AdminUserDto.ts @@ -0,0 +1,13 @@ +import { z } from "zod"; + +export const AdminUserDtoSchema = z.object({ + id: z.string().uuid(), + username: z.string(), + email: z.string().email(), + verified: z.boolean(), + totpEnabled: z.boolean(), + isAdmin: z.boolean(), + deleteAt: z.date().nullable(), +}); + +export type UserDto = z.infer; diff --git a/src/dto/user/userDto.ts b/src/dto/user/userDto.ts index 9298867..3dcb1ad 100644 --- a/src/dto/user/userDto.ts +++ b/src/dto/user/userDto.ts @@ -6,6 +6,7 @@ export const UserDtoSchema = z.object({ email: z.string().email(), verified: z.boolean(), totpEnabled: z.boolean(), + isAdmin: z.boolean(), }); export type UserDto = z.infer; diff --git a/src/errors/ApiError/CantDeleteItSelf.ts b/src/errors/ApiError/CantDeleteItSelf.ts new file mode 100644 index 0000000..3566eb4 --- /dev/null +++ b/src/errors/ApiError/CantDeleteItSelf.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; +import { ApiError } from "./ApiError"; + +export class CantDeleteItSelfApiError extends ApiError { + static readonly schema = z.object({ + statusCode: z.literal(400), + error: z.literal("Bad Request"), + code: z.literal("CANT_DELETE_IT_SELF"), + }); + + constructor() { + super(400, "Bad Request", "CANT_DELETE_IT_SELF"); + } +} diff --git a/src/errors/ApiError/UserNotAdminApiError.ts b/src/errors/ApiError/UserNotAdminApiError.ts new file mode 100644 index 0000000..8f0913e --- /dev/null +++ b/src/errors/ApiError/UserNotAdminApiError.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; +import { ApiError } from "./ApiError"; + +export class UserNotAdminApiError extends ApiError { + static readonly schema = z.object({ + statusCode: z.literal(403), + error: z.literal("Forbidden"), + code: z.literal("USER_NOT_ADMIN"), + }); + + constructor() { + super(403, "Forbidden", "USER_NOT_ADMIN"); + } +} diff --git a/src/model/UserModel.ts b/src/model/UserModel.ts index f11d072..f044f68 100644 --- a/src/model/UserModel.ts +++ b/src/model/UserModel.ts @@ -28,6 +28,7 @@ export class UserModel extends Model< declare totpEnabled: CreationOptional; declare totpSecret?: CreationOptional; declare deleteAt?: CreationOptional; + declare isAdmin: CreationOptional; declare getBiotopeModels: HasManyGetAssociationsMixin; declare createBiotopeModel: HasManyCreateAssociationMixin; diff --git a/src/model/db.ts b/src/model/db.ts index 65315b9..fdba2c7 100644 --- a/src/model/db.ts +++ b/src/model/db.ts @@ -68,6 +68,11 @@ export default class Db { deleteAt: { type: DataTypes.DATE, }, + isAdmin: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + }, }, { sequelize, tableName: "users" }, ); diff --git a/src/routes/admin/users.ts b/src/routes/admin/users.ts new file mode 100644 index 0000000..ed85542 --- /dev/null +++ b/src/routes/admin/users.ts @@ -0,0 +1,171 @@ +import bcrypt from "bcryptjs"; +import { FastifyPluginAsync } from "fastify"; +import { ZodTypeProvider } from "fastify-type-provider-zod"; +import { z } from "zod"; +import { AdminUserDtoSchema } from "../../dto/admin/user/AdminUserDto"; +import { UserCreateDtoSchema } from "../../dto/user/userCreateDto"; +import { CantDeleteItSelfApiError } from "../../errors/ApiError/CantDeleteItSelf"; +import { EmailAlreadyExistApiError } from "../../errors/ApiError/EmailAlreadyExistApiError"; +import { OTPRequiredApiError } from "../../errors/ApiError/OTPRequiredApiError"; +import { UserNotFoundApiError } from "../../errors/ApiError/UserNotFoundApiError"; +import { UsernameAlreadyExistApiError } from "../../errors/ApiError/UsernameAlreadyExistApiError"; +import { WrongOTPApiError } from "../../errors/ApiError/WrongOTPApiError"; +import { WrongPasswordApiError } from "../../errors/ApiError/WrongPasswordApiError"; +import { UserModel } from "../../model/UserModel"; + +export default (async (fastify) => { + const instance = fastify.withTypeProvider(); + + instance.get( + "/", + { + schema: { + tags: ["admin", "users"], + description: "Get all users", + response: { + 200: AdminUserDtoSchema.array(), + }, + }, + }, + async function () { + const users = await UserModel.findAll(); + + return users.map((user) => AdminUserDtoSchema.parse(user)); + }, + ); + + instance.get( + "/:id", + { + schema: { + tags: ["admin", "users"], + description: "Get a user", + params: z.object({ + id: z.string().uuid(), + }), + response: { + 200: AdminUserDtoSchema, + 404: UserNotFoundApiError.schema, + }, + }, + }, + async function (req, res) { + const user = await UserModel.findOne({ + where: { + id: req.params.id, + }, + }); + + if (!user) { + throw new UserNotFoundApiError(); + } + + return AdminUserDtoSchema.parse(user); + }, + ); + + instance.delete( + "/:id", + { + schema: { + tags: ["admin", "users"], + description: + "Delete a user. The data will be definitely lost after 30 days.", + params: z.object({ + id: z.string().uuid(), + }), + body: z.object({ + password: z.string(), + otp: z.string().length(6).optional(), + }), + response: { + 204: z.void(), + 400: CantDeleteItSelfApiError.schema, + 404: UserNotFoundApiError.schema, + 403: z.union([ + WrongPasswordApiError.schema, + WrongOTPApiError.schema, + OTPRequiredApiError.schema, + ]), + }, + }, + }, + async function (req, res) { + const connectedUser = req.user!; + + await connectedUser.checkPassword(req.body.password); + connectedUser.checkOTP(req.body.otp); + + const user = await UserModel.findOne({ + where: { + id: req.params.id, + }, + }); + + if (!user) { + throw new UserNotFoundApiError(); + } + + if (user === connectedUser) { + throw new CantDeleteItSelfApiError(); + } + + user.deleteAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); + user.destroyAllSessions(); + + await user.save(); + + return res.status(204).send(); + }, + ); + + instance.post( + "/", + { + schema: { + tags: ["admin", "users"], + description: "Create a user", + body: UserCreateDtoSchema, + response: { + 201: AdminUserDtoSchema, + 409: z.union([ + UsernameAlreadyExistApiError.schema, + EmailAlreadyExistApiError.schema, + ]), + }, + }, + }, + async function (req, res) { + const emailExists = await UserModel.findOne({ + where: { + email: req.body.email, + }, + }); + + const usernameExists = await UserModel.findOne({ + where: { + username: req.body.username, + }, + }); + + if (emailExists) { + throw new EmailAlreadyExistApiError(); + } + + if (usernameExists) { + throw new UsernameAlreadyExistApiError(); + } + + const hashPassword = await bcrypt.hash(req.body.password, 10); + + const user = await UserModel.create({ + username: req.body.username, + email: req.body.email, + password: hashPassword, + verified: true, + }); + + res.status(201).send(AdminUserDtoSchema.parse(user)); + }, + ); +}) satisfies FastifyPluginAsync; diff --git a/src/routes/users/me.ts b/src/routes/users/me.ts index 3cdfce6..353a216 100644 --- a/src/routes/users/me.ts +++ b/src/routes/users/me.ts @@ -1,4 +1,3 @@ -import bcrypt from "bcryptjs"; import { FastifyPluginAsync } from "fastify"; import { ZodTypeProvider } from "fastify-type-provider-zod"; import otpGenerator from "otp-generator"; @@ -6,17 +5,17 @@ import { authenticator } from "otplib"; import { z } from "zod"; import MailSender from "../../agents/MailSender"; import { UserDtoSchema } from "../../dto/user/userDto"; -import { EmailValidationOTPModel } from "../../model/EmailValidationOTPModel"; import { EmailAlreadyVerifiedApiError } from "../../errors/ApiError/EmailAlreadyVerifiedApiError"; -import { NoEmailVerificationCodeApiError } from "../../errors/ApiError/NoEmailVerificationCodeApiError"; import { ExpiredEmailVerificationCodeApiError } from "../../errors/ApiError/ExpiredEmailVerificationCodeApiError"; import { InvalidEmailVerificationCodeApiError } from "../../errors/ApiError/InvalidEmailVerificationCodeApiError"; -import { WrongPasswordApiError } from "../../errors/ApiError/WrongPasswordApiError"; -import { TOTPAlreadyEnabledApiError } from "../../errors/ApiError/TOTPAlreadyEnabledApiError"; +import { NoEmailVerificationCodeApiError } from "../../errors/ApiError/NoEmailVerificationCodeApiError"; import { NoTOTPSecretApiError } from "../../errors/ApiError/NoTOTPSecretApiError"; -import { WrongOTPApiError } from "../../errors/ApiError/WrongOTPApiError"; -import { TOTPNotEnabledApiError } from "../../errors/ApiError/TOTPNotEnabledApiError"; import { OTPRequiredApiError } from "../../errors/ApiError/OTPRequiredApiError"; +import { TOTPAlreadyEnabledApiError } from "../../errors/ApiError/TOTPAlreadyEnabledApiError"; +import { TOTPNotEnabledApiError } from "../../errors/ApiError/TOTPNotEnabledApiError"; +import { WrongOTPApiError } from "../../errors/ApiError/WrongOTPApiError"; +import { WrongPasswordApiError } from "../../errors/ApiError/WrongPasswordApiError"; +import { EmailValidationOTPModel } from "../../model/EmailValidationOTPModel"; export default (async (fastify) => { const instance = fastify.withTypeProvider(); @@ -280,84 +279,6 @@ export default (async (fastify) => { }, ); - // TODO: to move in a admin dedicated route - // instance.get( - // "/", - // { - // schema: { - // tags: ["users"], - // description: "Get all users", - // response: { - // 200: UserDtoSchema.array(), - // }, - // }, - // }, - // async function () { - // const users = await UserModel.findAll(); - // - // return users.map((user) => UserDtoSchema.parse(user)); - // }, - // ); - - // TODO: to move in a admin dedicated route - // instance.get( - // "/:id", - // { - // schema: { - // tags: ["users"], - // description: "Get a user", - // params: z.object({ - // id: z.string().uuid(), - // }), - // response: { - // 200: UserDtoSchema, - // }, - // }, - // }, - // async function (req, res) { - // const user = await UserModel.findOne({ - // where: { - // id: req.params.id, - // }, - // }); - // - // if (!user) { - // return res.status(404); - // } - // - // return UserDtoSchema.parse(user); - // }, - // ); - - // TODO: to move in a admin dedicated route - // instance.delete( - // "/:id", - // { - // schema: { - // tags: ["users"], - // description: "Delete a user", - // params: z.object({ - // id: z.string().uuid(), - // }), - // }, - // }, - // async function (req, res) { - // const user = await UserModel.findOne({ - // where: { - // id: req.params.id, - // }, - // }); - // - // if (!user) { - // return res.status(404); - // } - // - // await user.destroy(); - // - // return res.status(204).send(); - // }, - // ); - instance.get( "/", { From 0db59e72468975114a8c7eddd41e5c0b30b3865d Mon Sep 17 00:00:00 2001 From: Bryan Date: Sun, 10 Mar 2024 17:05:25 +0100 Subject: [PATCH 06/11] [FEAT] Add rate limiting --- src/app.ts | 9 +++++++++ src/routes/auth.ts | 12 ++++++++++++ 2 files changed, 21 insertions(+) diff --git a/src/app.ts b/src/app.ts index 80e656f..241dd27 100644 --- a/src/app.ts +++ b/src/app.ts @@ -109,6 +109,9 @@ declare module "fastify" { await fastify.register(import("@fastify/cookie")); + // - - - - - Rate limiting - - - - - // + await fastify.register(import("@fastify/rate-limit"), {}); + // - - - - - Error handling - - - - - // fastify .withTypeProvider() @@ -127,6 +130,12 @@ declare module "fastify" { finalError.data = error.data; } + if (error.statusCode === 429) { + finalError.statusCode = 429; + finalError.error = "Too Many Requests"; + finalError.code = "TOO_MANY_REQUESTS"; + } + return reply.status(finalError.statusCode).send(finalError); }); diff --git a/src/routes/auth.ts b/src/routes/auth.ts index edcdeea..4f4b22c 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -35,6 +35,12 @@ export default (async (fastify) => { ]), }, }, + config: { + rateLimit: { + max: 10, + timeWindow: "1 minute", + }, + }, }, async function (req, res) { if (!env.REGISTRATION_ENABLED) { @@ -95,6 +101,12 @@ export default (async (fastify) => { ]), }, }, + config: { + rateLimit: { + max: 10, + timeWindow: "5 minute", + }, + }, }, async function (req, res) { const user = await UserModel.findOne({ From ba6e8488c77c672dc889a4bc6f8d0b78123980e8 Mon Sep 17 00:00:00 2001 From: Bryan Date: Mon, 18 Mar 2024 21:57:42 +0100 Subject: [PATCH 07/11] [FEAT] Admin user patch route --- src/dto/admin/user/AdminUserCreateDto.ts | 10 +++ src/dto/admin/user/AdminUserDto.ts | 2 +- src/dto/admin/user/AdminUserUpdateDto.ts | 10 +++ src/routes/admin/users.ts | 79 +++++++++++++++++++++++- 4 files changed, 98 insertions(+), 3 deletions(-) create mode 100644 src/dto/admin/user/AdminUserCreateDto.ts create mode 100644 src/dto/admin/user/AdminUserUpdateDto.ts diff --git a/src/dto/admin/user/AdminUserCreateDto.ts b/src/dto/admin/user/AdminUserCreateDto.ts new file mode 100644 index 0000000..848b5c1 --- /dev/null +++ b/src/dto/admin/user/AdminUserCreateDto.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +export const AdminUserCreateDtoSchema = z.object({ + username: z.string().min(3).max(50), + email: z.string().email(), + password: z.string().min(8).max(100), + isAdmin: z.boolean().optional(), +}); + +export type AdminUserCreateDto = z.infer; diff --git a/src/dto/admin/user/AdminUserDto.ts b/src/dto/admin/user/AdminUserDto.ts index ff1b896..51ed234 100644 --- a/src/dto/admin/user/AdminUserDto.ts +++ b/src/dto/admin/user/AdminUserDto.ts @@ -10,4 +10,4 @@ export const AdminUserDtoSchema = z.object({ deleteAt: z.date().nullable(), }); -export type UserDto = z.infer; +export type AdminUserDto = z.infer; diff --git a/src/dto/admin/user/AdminUserUpdateDto.ts b/src/dto/admin/user/AdminUserUpdateDto.ts new file mode 100644 index 0000000..f5a2dd3 --- /dev/null +++ b/src/dto/admin/user/AdminUserUpdateDto.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +export const AdminUserUpdateDtoSchema = z.object({ + username: z.string().min(3).max(50).optional(), + email: z.string().email().optional(), + password: z.string().min(8).max(100).optional(), + isAdmin: z.boolean().optional(), +}); + +export type AdminUserUpdateDto = z.infer; diff --git a/src/routes/admin/users.ts b/src/routes/admin/users.ts index ed85542..b092216 100644 --- a/src/routes/admin/users.ts +++ b/src/routes/admin/users.ts @@ -2,8 +2,9 @@ import bcrypt from "bcryptjs"; import { FastifyPluginAsync } from "fastify"; import { ZodTypeProvider } from "fastify-type-provider-zod"; import { z } from "zod"; +import { AdminUserCreateDtoSchema } from "../../dto/admin/user/AdminUserCreateDto"; import { AdminUserDtoSchema } from "../../dto/admin/user/AdminUserDto"; -import { UserCreateDtoSchema } from "../../dto/user/userCreateDto"; +import { AdminUserUpdateDtoSchema } from "../../dto/admin/user/AdminUserUpdateDto"; import { CantDeleteItSelfApiError } from "../../errors/ApiError/CantDeleteItSelf"; import { EmailAlreadyExistApiError } from "../../errors/ApiError/EmailAlreadyExistApiError"; import { OTPRequiredApiError } from "../../errors/ApiError/OTPRequiredApiError"; @@ -125,7 +126,7 @@ export default (async (fastify) => { schema: { tags: ["admin", "users"], description: "Create a user", - body: UserCreateDtoSchema, + body: AdminUserCreateDtoSchema, response: { 201: AdminUserDtoSchema, 409: z.union([ @@ -168,4 +169,78 @@ export default (async (fastify) => { res.status(201).send(AdminUserDtoSchema.parse(user)); }, ); + + instance.patch( + "/:id", + { + schema: { + tags: ["admin", "users"], + description: "Update a user", + params: z.object({ + id: z.string().uuid(), + }), + body: AdminUserUpdateDtoSchema, + response: { + 200: AdminUserDtoSchema, + 404: UserNotFoundApiError.schema, + 409: z.union([ + UsernameAlreadyExistApiError.schema, + EmailAlreadyExistApiError.schema, + ]), + }, + }, + }, + async function (req, res) { + const user = await UserModel.findOne({ + where: { + id: req.params.id, + }, + }); + + if (!user) { + throw new UserNotFoundApiError(); + } + + if (req.body.email && req.body.email !== user.email) { + const emailExists = await UserModel.findOne({ + where: { + email: req.body.email, + }, + }); + + if (emailExists) { + throw new EmailAlreadyExistApiError(); + } + + user.email = req.body.email; + } + + if (req.body.username && req.body.username !== user.username) { + const usernameExists = await UserModel.findOne({ + where: { + username: req.body.username, + }, + }); + + if (usernameExists) { + throw new UsernameAlreadyExistApiError(); + } + + user.username = req.body.username; + } + + if (req.body.password) { + const hashPassword = await bcrypt.hash(req.body.password, 10); + user.password = hashPassword; + } + + if (req.body.isAdmin && req.body.isAdmin !== user.isAdmin) { + user.isAdmin = req.body.isAdmin; + } + + await user.save(); + + return AdminUserDtoSchema.parse(user); + }, + ); }) satisfies FastifyPluginAsync; From 854a2c39fe6da92d29f0763565fe12d15d2a560b Mon Sep 17 00:00:00 2001 From: Bryan Date: Tue, 19 Mar 2024 19:46:51 +0100 Subject: [PATCH 08/11] [FEAT] User session management routes --- src/app.ts | 4 +- src/auth/isSessionLoggedIn.ts | 1 + src/dto/userSession/userSessionDto.ts | 11 ++ .../ApiError/NotSessionLoggerUserApiError.ts | 14 ++ .../ApiError/UserSessionNotFoundApiError.ts | 14 ++ src/model/UserModel.ts | 2 + src/routes/users/me.ts | 4 + src/routes/users/me/session.ts | 145 ++++++++++++++++++ 8 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 src/dto/userSession/userSessionDto.ts create mode 100644 src/errors/ApiError/NotSessionLoggerUserApiError.ts create mode 100644 src/errors/ApiError/UserSessionNotFoundApiError.ts create mode 100644 src/routes/users/me/session.ts diff --git a/src/app.ts b/src/app.ts index 241dd27..6efaf0f 100644 --- a/src/app.ts +++ b/src/app.ts @@ -8,6 +8,7 @@ import { } from "fastify-type-provider-zod"; import fs from "fs"; import cron from "node-cron"; +import { isAdminLoggedIn } from "./auth/isAdminLoggedIn"; import { isApplicationLoggedIn } from "./auth/isApplicationLoggedIn"; import { isEmailValidated } from "./auth/isEmailValidated"; import { isSessionLoggedIn } from "./auth/isSessionLoggedIn"; @@ -17,8 +18,8 @@ import { BiotopeModel } from "./model/BiotopeModel"; import { EmailValidationOTPModel } from "./model/EmailValidationOTPModel"; import { MeasurementTypeModel } from "./model/MeasurementTypeModel"; import { UserModel } from "./model/UserModel"; +import { UserSessionModel } from "./model/UserSessionModel"; import Db from "./model/db"; -import { isAdminLoggedIn } from "./auth/isAdminLoggedIn"; // - - - - - Environment variables - - - - - // if (fs.existsSync(".env")) { @@ -39,6 +40,7 @@ ensureValidEnv(); declare module "fastify" { export interface FastifyRequest { user?: UserModel; + session?: UserSessionModel; biotope?: BiotopeModel; measurementType?: MeasurementTypeModel; } diff --git a/src/auth/isSessionLoggedIn.ts b/src/auth/isSessionLoggedIn.ts index aa738af..ea4590d 100644 --- a/src/auth/isSessionLoggedIn.ts +++ b/src/auth/isSessionLoggedIn.ts @@ -31,4 +31,5 @@ export const isSessionLoggedIn = (async (req, res) => { await session.save(); req.user = user; + req.session = session; }) satisfies FastifyAuthFunction; diff --git a/src/dto/userSession/userSessionDto.ts b/src/dto/userSession/userSessionDto.ts new file mode 100644 index 0000000..a20f99d --- /dev/null +++ b/src/dto/userSession/userSessionDto.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +export const UserSessionDtoSchema = z.object({ + id: z.string().uuid(), + name: z.string(), + firstConnectionDate: z.date(), + lastConnectionDate: z.date(), + current: z.boolean().optional(), +}); + +export type UserSessionDto = z.infer; diff --git a/src/errors/ApiError/NotSessionLoggerUserApiError.ts b/src/errors/ApiError/NotSessionLoggerUserApiError.ts new file mode 100644 index 0000000..46a42cf --- /dev/null +++ b/src/errors/ApiError/NotSessionLoggerUserApiError.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; +import { ApiError } from "./ApiError"; + +export class NotSessionLoggerUserApiError extends ApiError { + static readonly schema = z.object({ + statusCode: z.literal(403), + error: z.literal("Forbidden"), + code: z.literal("NOT_SESSION_LOGGER_USER"), + }); + + constructor() { + super(403, "Forbidden", "NOT_SESSION_LOGGER_USER"); + } +} diff --git a/src/errors/ApiError/UserSessionNotFoundApiError.ts b/src/errors/ApiError/UserSessionNotFoundApiError.ts new file mode 100644 index 0000000..ab266fb --- /dev/null +++ b/src/errors/ApiError/UserSessionNotFoundApiError.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; +import { ApiError } from "./ApiError"; + +export class UserSessionNotFoundApiError extends ApiError { + static readonly schema = z.object({ + statusCode: z.literal(404), + error: z.literal("Not Found"), + code: z.literal("USER_SESSION_NOT_FOUND"), + }); + + constructor() { + super(404, "Not Found", "USER_SESSION_NOT_FOUND"); + } +} diff --git a/src/model/UserModel.ts b/src/model/UserModel.ts index f044f68..09a940d 100644 --- a/src/model/UserModel.ts +++ b/src/model/UserModel.ts @@ -36,6 +36,8 @@ export class UserModel extends Model< declare getApplicationModels: HasManyGetAssociationsMixin; declare createApplicationModel: HasManyCreateAssociationMixin; + declare getUserSessionModels: HasManyGetAssociationsMixin; + async checkPassword(password: string): Promise { const isPasswordValid = await bcrypt.compare(password, this.password); if (!isPasswordValid) { diff --git a/src/routes/users/me.ts b/src/routes/users/me.ts index 353a216..da8fc27 100644 --- a/src/routes/users/me.ts +++ b/src/routes/users/me.ts @@ -294,4 +294,8 @@ export default (async (fastify) => { return UserDtoSchema.parse(req.user); }, ); + + instance.register(import("./me/session"), { + prefix: "/session", + }); }) satisfies FastifyPluginAsync; diff --git a/src/routes/users/me/session.ts b/src/routes/users/me/session.ts new file mode 100644 index 0000000..b3140a4 --- /dev/null +++ b/src/routes/users/me/session.ts @@ -0,0 +1,145 @@ +import { FastifyPluginAsync } from "fastify"; +import { ZodTypeProvider } from "fastify-type-provider-zod"; +import { z } from "zod"; +import { UserSessionDtoSchema } from "../../../dto/userSession/userSessionDto"; +import { NotSessionLoggerUserApiError } from "../../../errors/ApiError/NotSessionLoggerUserApiError"; +import { UserSessionNotFoundApiError } from "../../../errors/ApiError/UserSessionNotFoundApiError"; +import { UserSessionModel } from "../../../model/UserSessionModel"; + +export default (async (fastify) => { + const instance = fastify.withTypeProvider(); + + instance.get( + "/", + { + schema: { + tags: ["users", "sessions"], + description: "Get the current user's sessions.", + response: { + 200: UserSessionDtoSchema.array(), + }, + }, + }, + async function (req) { + const sessions = await req.user!.getUserSessionModels(); + + const parsedSessions = sessions.map((session) => { + const parsed = UserSessionDtoSchema.parse(session); + parsed.current = session.id === req.session?.id; + + return parsed; + }); + + return parsedSessions; + }, + ); + + instance.get( + "/:id", + { + schema: { + tags: ["users", "sessions"], + description: "Get a session.", + params: z.object({ + id: z.string().uuid(), + }), + response: { + 200: UserSessionDtoSchema, + 404: UserSessionNotFoundApiError.schema, + }, + }, + }, + async function (req) { + const session = await UserSessionModel.findOne({ + where: { + id: req.params.id, + userId: req.user!.id, + }, + }); + + if (!session) { + throw new UserSessionNotFoundApiError(); + } + + const parsed = UserSessionDtoSchema.parse(session); + parsed.current = session.id === req.session?.id; + + return parsed; + }, + ); + + instance.get( + "/current", + { + schema: { + tags: ["users", "sessions"], + description: "Get the current user's session.", + response: { + 200: UserSessionDtoSchema, + 403: NotSessionLoggerUserApiError.schema, + }, + }, + }, + async function (req) { + if (!req.session) { + throw new NotSessionLoggerUserApiError(); + } + + const parsed = UserSessionDtoSchema.parse(req.session); + parsed.current = true; + + return parsed; + }, + ); + + instance.delete( + "/", + { + schema: { + tags: ["users", "sessions"], + description: "Delete all the current user's sessions.", + response: { + 204: z.void(), + }, + }, + }, + async function (req, res) { + await req.user!.destroyAllSessions(); + + res.status(204).send(); + }, + ); + + instance.delete( + "/:id", + { + schema: { + tags: ["users", "sessions"], + description: "Delete a session.", + params: z.object({ + id: z.string().uuid(), + }), + response: { + 204: z.void(), + 404: UserSessionNotFoundApiError.schema, + }, + }, + }, + async function (req, res) { + const session = await UserSessionModel.findOne({ + where: { + id: req.params.id, + userId: req.user!.id, + }, + }); + + if (!session) { + throw new UserSessionNotFoundApiError(); + } + + await session.destroy(); + + res.status(204).send(); + }, + ); +}) satisfies FastifyPluginAsync; From 180fa81a96123ec53a992074c30a62a6046c2598 Mon Sep 17 00:00:00 2001 From: Bryan Date: Tue, 19 Mar 2024 20:07:25 +0100 Subject: [PATCH 09/11] [FEAT] Set name of session with user agent --- package-lock.json | 30 ++++++++++++++++++++++++++++++ package.json | 18 ++++++++++-------- src/routes/auth.ts | 11 ++++++++++- 3 files changed, 50 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index d3d2b4a..bb7f874 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "otplib": "^12.0.1", "sequelize": "^6.37.1", "sharp": "^0.33.2", + "ua-parser-js": "^1.0.37", "zod": "^3.22.4" }, "devDependencies": { @@ -36,6 +37,7 @@ "@types/node-cron": "^3.0.11", "@types/nodemailer": "^6.4.14", "@types/otp-generator": "^4.0.2", + "@types/ua-parser-js": "^0.7.39", "prettier": "3.2.5", "tsx": "^4.7.1", "typescript": "^5.4.2" @@ -489,6 +491,12 @@ "@types/node": "*" } }, + "node_modules/@types/ua-parser-js": { + "version": "0.7.39", + "resolved": "https://registry.npmjs.org/@types/ua-parser-js/-/ua-parser-js-0.7.39.tgz", + "integrity": "sha512-P/oDfpofrdtF5xw433SPALpdSchtJmY7nsJItf8h3KXqOslkbySh8zq4dSWXH2oTjRvJ5PczVEoCZPow6GicLg==", + "dev": true + }, "node_modules/@types/validator": { "version": "13.11.1", "license": "MIT" @@ -2043,6 +2051,28 @@ "node": ">=14.17" } }, + "node_modules/ua-parser-js": { + "version": "1.0.37", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.37.tgz", + "integrity": "sha512-bhTyI94tZofjo+Dn8SN6Zv8nBDvyXTymAdM3LDI/0IboIUwTu1rEhW7v2TfiVsoYWgkQ4kOVqnI8APUFbIQIFQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "engines": { + "node": "*" + } + }, "node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", diff --git a/package.json b/package.json index 65c2539..c94fac4 100644 --- a/package.json +++ b/package.json @@ -8,34 +8,36 @@ "start": "npx tsc && node dist/app.js" }, "dependencies": { - "fastify": "^4.26.2", "@fastify/auth": "^4.6.1", "@fastify/cookie": "^9.3.1", + "@fastify/rate-limit": "^9.1.0", "@fastify/swagger": "^8.14.0", "@fastify/swagger-ui": "^3.0.0", - "@fastify/rate-limit": "^9.1.0", - "fastify-type-provider-zod": "^1.1.9", "bcryptjs": "^2.4.3", "cookie-parser": "^1.4.6", "dotenv": "^16.4.5", + "fastify": "^4.26.2", + "fastify-type-provider-zod": "^1.1.9", "jose": "^5.2.3", "morgan": "^1.10.0", "mysql2": "^3.9.2", + "node-cron": "^3.0.3", "nodemailer": "^6.9.12", + "otp-generator": "^4.0.1", + "otplib": "^12.0.1", "sequelize": "^6.37.1", "sharp": "^0.33.2", - "zod": "^3.22.4", - "node-cron": "^3.0.3", - "otp-generator": "^4.0.1", - "otplib": "^12.0.1" + "ua-parser-js": "^1.0.37", + "zod": "^3.22.4" }, "devDependencies": { "@types/bcryptjs": "^2.4.6", "@types/cookie-parser": "^1.4.7", "@types/node": "^20.11.19", + "@types/node-cron": "^3.0.11", "@types/nodemailer": "^6.4.14", "@types/otp-generator": "^4.0.2", - "@types/node-cron": "^3.0.11", + "@types/ua-parser-js": "^0.7.39", "prettier": "3.2.5", "tsx": "^4.7.1", "typescript": "^5.4.2" diff --git a/src/routes/auth.ts b/src/routes/auth.ts index 4f4b22c..07ae3e2 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -16,6 +16,7 @@ import { UserModel } from "../model/UserModel"; import { UserSessionModel } from "../model/UserSessionModel"; import UserTokenUtil from "../utils/UserTokenUtil"; import { NotLoggedApiError } from "../errors/ApiError/NotLoggedApiError"; +import { UAParser } from "ua-parser-js"; export default (async (fastify) => { const instance = fastify.withTypeProvider(); @@ -133,8 +134,16 @@ export default (async (fastify) => { httpOnly: true, }); + // Get User Agent + const ua = new UAParser(req.headers["user-agent"]); + const browser = ua.getBrowser(); + const os = ua.getOS(); + UserSessionModel.create({ - name: "Unknown device", + name: + browser.name || os.name + ? `${browser.name ?? "Unknown"} - ${os.name ?? "Unknown"}` + : "Unknown", userId: userDto.id, token: token, }); From 6f0a0fe83a4695a6ca9dc81eccb232c5751ff255 Mon Sep 17 00:00:00 2001 From: Bryan Date: Tue, 19 Mar 2024 20:08:19 +0100 Subject: [PATCH 10/11] [CI] Restore Dependency action on PR --- .github/workflows/dependency.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/dependency.yml b/.github/workflows/dependency.yml index 380f57f..b8d20dd 100644 --- a/.github/workflows/dependency.yml +++ b/.github/workflows/dependency.yml @@ -1,8 +1,5 @@ name: "Dependency Review" -on: - push: - branches: - - main +on: pull_request permissions: contents: read From 96de5cd1f5563bff3e75057346bc56dbc0605c5a Mon Sep 17 00:00:00 2001 From: Bryan Date: Wed, 20 Mar 2024 20:31:10 +0100 Subject: [PATCH 11/11] [FEAT] Global error schema injection --- src/app.ts | 28 ++++++++++++++++++ src/routes/users/me.ts | 1 + src/utils/routeOptionInjection.ts | 49 +++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+) create mode 100644 src/utils/routeOptionInjection.ts diff --git a/src/app.ts b/src/app.ts index 6efaf0f..897c45b 100644 --- a/src/app.ts +++ b/src/app.ts @@ -20,6 +20,10 @@ import { MeasurementTypeModel } from "./model/MeasurementTypeModel"; import { UserModel } from "./model/UserModel"; import { UserSessionModel } from "./model/UserSessionModel"; import Db from "./model/db"; +import { injectSchemaInRouteOption } from "./utils/routeOptionInjection"; +import { NotLoggedApiError } from "./errors/ApiError/NotLoggedApiError"; +import { EmailNotValidatedApiError } from "./errors/ApiError/EmailNotValidatedApiError"; +import { UserNotAdminApiError } from "./errors/ApiError/UserNotAdminApiError"; // - - - - - Environment variables - - - - - // if (fs.existsSync(".env")) { @@ -152,6 +156,14 @@ declare module "fastify" { instance.auth([isSessionLoggedIn, isApplicationLoggedIn]), ); + instance.addHook("onRoute", (routeOptions) => { + injectSchemaInRouteOption( + routeOptions, + 401, + NotLoggedApiError.schema, + ); + }); + await fastify.register(import("./routes/users/me"), { prefix: "/users/me", }); @@ -159,6 +171,14 @@ declare module "fastify" { instance.register(async (instance) => { instance.addHook("preHandler", instance.auth([isEmailValidated])); + instance.addHook("onRoute", (routeOptions) => { + injectSchemaInRouteOption( + routeOptions, + 403, + EmailNotValidatedApiError.schema, + ); + }); + await fastify.register(import("./routes/applications"), { prefix: "/applications", }); @@ -178,6 +198,14 @@ declare module "fastify" { instance.auth([isAdminLoggedIn]), ); + instance.addHook("onRoute", (routeOptions) => { + injectSchemaInRouteOption( + routeOptions, + 403, + UserNotAdminApiError.schema, + ); + }); + await fastify.register(import("./routes/admin/users"), { prefix: "/users", }); diff --git a/src/routes/users/me.ts b/src/routes/users/me.ts index da8fc27..756dc9e 100644 --- a/src/routes/users/me.ts +++ b/src/routes/users/me.ts @@ -256,6 +256,7 @@ export default (async (fastify) => { }), response: { 204: z.void(), + 401: z.union([z.string(), z.number()]), 403: z.union([ WrongPasswordApiError.schema, WrongOTPApiError.schema, diff --git a/src/utils/routeOptionInjection.ts b/src/utils/routeOptionInjection.ts new file mode 100644 index 0000000..2b70752 --- /dev/null +++ b/src/utils/routeOptionInjection.ts @@ -0,0 +1,49 @@ +import { ZodTypeAny, z } from "zod"; + +export function injectSchemaInRouteOption( + routeOptions: any, + statusCode: number, + schema: z.ZodType, +): void { + if (routeOptions.method === "HEAD" || routeOptions.method === "OPTIONS") { + return; + } + + if (!routeOptions.schema) { + routeOptions.schema = {}; + } + + if (!routeOptions.schema.response) { + routeOptions.schema.response = {}; + } + + if (!routeOptions.schema.response[200] && statusCode !== 200) { + routeOptions.schema.response[200] = z.void(); + } + + if ( + !routeOptions.schema.response[statusCode] || + routeOptions.schema.response[statusCode] instanceof z.ZodVoid + ) { + routeOptions.schema.response[statusCode] = schema; + return; + } + + if (!(routeOptions.schema.response[statusCode] instanceof z.ZodType)) { + throw new Error("Not a valid Zod schema in route options."); + } + + if (routeOptions.schema.response[statusCode] instanceof z.ZodUnion) { + routeOptions.schema.response[statusCode] = z.union([ + ...(routeOptions.schema.response[statusCode]._def + .options as readonly [ZodTypeAny, ZodTypeAny, ...ZodTypeAny[]]), + schema, + ]); + return; + } + + routeOptions.schema.response[statusCode] = z.union([ + routeOptions.schema.response[statusCode], + schema, + ]); +}