diff --git a/.env.example b/.env.example index 28dabf9..cdc604d 100644 --- a/.env.example +++ b/.env.example @@ -13,3 +13,7 @@ PREFIX="api/v1/" # Security ADMIN_KEY="admin" +COOKIE_SECRET="cookie" +SECURE_COOKIE="false" +SESSION_EXPIRATION=1209600# In seconds +MAX_SESSIONS=5 diff --git a/package.json b/package.json index 2d2c092..03a173b 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "test:e2e": "jest --config ./test/jest-e2e.json" }, "dependencies": { + "@fastify/cookie": "^9.3.1", "@fastify/helmet": "^11.1.1", "@fastify/static": "^7.0.4", "@nestjs/common": "^10.3.10", @@ -29,6 +30,7 @@ "@nestjs/swagger": "^7.3.1", "@nestjs/throttler": "^5.2.0", "@prisma/client": "5.16.1", + "argon2": "^0.40.3", "axios": "^1.7.2", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", @@ -40,7 +42,8 @@ "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "sharp": "^0.33.4", - "swagger-themes": "^1.4.3" + "swagger-themes": "^1.4.3", + "uuid": "^10.0.0" }, "devDependencies": { "@nestjs/cli": "^10.3.2", @@ -50,6 +53,7 @@ "@types/jsdom": "^21.1.7", "@types/node": "^20.14.9", "@types/supertest": "^6.0.2", + "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^7.14.1", "@typescript-eslint/parser": "^7.14.1", "eslint": "8.57.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d7f933f..ae8ab5b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@fastify/cookie': + specifier: ^9.3.1 + version: 9.3.1 '@fastify/helmet': specifier: ^11.1.1 version: 11.1.1 @@ -38,6 +41,9 @@ importers: '@prisma/client': specifier: 5.16.1 version: 5.16.1(prisma@5.16.1) + argon2: + specifier: ^0.40.3 + version: 0.40.3 axios: specifier: ^1.7.2 version: 1.7.2 @@ -74,6 +80,9 @@ importers: swagger-themes: specifier: ^1.4.3 version: 1.4.3 + uuid: + specifier: ^10.0.0 + version: 10.0.0 devDependencies: '@nestjs/cli': specifier: ^10.3.2 @@ -96,6 +105,9 @@ importers: '@types/supertest': specifier: ^6.0.2 version: 6.0.2 + '@types/uuid': + specifier: ^10.0.0 + version: 10.0.0 '@typescript-eslint/eslint-plugin': specifier: ^7.14.1 version: 7.14.1(@typescript-eslint/parser@7.14.1(eslint@8.57.0)(typescript@5.5.2))(eslint@8.57.0)(typescript@5.5.2) @@ -363,6 +375,9 @@ packages: '@fastify/ajv-compiler@3.6.0': resolution: {integrity: sha512-LwdXQJjmMD+GwLOkP7TVC68qa+pSSogeWWmznRJ/coyTcfe9qA05AHFSe1eZFwK6q+xVRpChnvFUkf1iYaSZsQ==} + '@fastify/cookie@9.3.1': + resolution: {integrity: sha512-h1NAEhB266+ZbZ0e9qUE6NnNR07i7DnNXWG9VbbZ8uC6O/hxHpl+Zoe5sw1yfdZ2U6XhToUGDnzQtWJdCaPwfg==} + '@fastify/cors@9.0.1': resolution: {integrity: sha512-YY9Ho3ovI+QHIL2hW+9X4XqQjXLjJqsU+sMV/xFsxZkE8p3GNnYVFpoOxF7SsP5ZL76gwvbo3V9L+FIekBGU4Q==} @@ -773,6 +788,10 @@ packages: engines: {node: '>=8.0.0', npm: '>=5.0.0'} hasBin: true + '@phc/format@1.0.0': + resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==} + engines: {node: '>=10'} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -892,6 +911,9 @@ packages: '@types/tough-cookie@4.0.5': resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + '@types/uuid@10.0.0': + resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} + '@types/validator@13.12.0': resolution: {integrity: sha512-nH45Lk7oPIJ1RVOF6JgFI6Dy0QpHEzq4QecZhvguxYPDwT8c93prCMqAtiIttm39voZ+DDR+qkNnMpJmMBRqag==} @@ -1112,6 +1134,10 @@ packages: arg@4.1.3: resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} + argon2@0.40.3: + resolution: {integrity: sha512-FrSmz4VeM91jwFvvjsQv9GYp6o/kARWoYKjbjDB2U5io1H3e5X67PYGclFDeQff6UXIhUd4aHR3mxCdBbMMuQw==} + engines: {node: '>=16.17.0'} + argparse@1.0.10: resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} @@ -1355,6 +1381,10 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-signature@1.2.1: + resolution: {integrity: sha512-78KWk9T26NhzXtuL26cIJ8/qNHANyJ/ZYrmEXFzUmhZdjpBv+DlWlOANRTGBt48YcyslsLrj0bMLFTmXvLRCOw==} + engines: {node: '>=6.6.0'} + cookie@0.6.0: resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} engines: {node: '>= 0.6'} @@ -2395,6 +2425,10 @@ packages: node-abort-controller@3.1.1: resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} + node-addon-api@8.0.0: + resolution: {integrity: sha512-ipO7rsHEBqa9STO5C5T10fj732ml+5kLN1cAG8/jdHd56ldQeGj3Q7+scUS+VHK/qy1zLEwC4wMK5+yM0btPvw==} + engines: {node: ^18 || ^20 || >= 21} + node-emoji@1.11.0: resolution: {integrity: sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==} @@ -2407,6 +2441,10 @@ packages: encoding: optional: true + node-gyp-build@4.8.1: + resolution: {integrity: sha512-OSs33Z9yWr148JZcbZd5WiAXhh/n9z8TxQcdMhIOlpN9AhWpLfvVFO73+m77bBABQMaY9XSvIa+qk0jlI7Gcaw==} + hasBin: true + node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} @@ -3130,6 +3168,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@10.0.0: + resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + hasBin: true + uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} hasBin: true @@ -3558,6 +3600,11 @@ snapshots: ajv-formats: 2.1.1(ajv@8.16.0) fast-uri: 2.4.0 + '@fastify/cookie@9.3.1': + dependencies: + cookie-signature: 1.2.1 + fastify-plugin: 4.5.1 + '@fastify/cors@9.0.1': dependencies: fastify-plugin: 4.5.1 @@ -4074,6 +4121,8 @@ snapshots: transitivePeerDependencies: - encoding + '@phc/format@1.0.0': {} + '@pkgjs/parseargs@0.11.0': optional: true @@ -4207,6 +4256,8 @@ snapshots: '@types/tough-cookie@4.0.5': {} + '@types/uuid@10.0.0': {} + '@types/validator@13.12.0': {} '@types/yargs-parser@21.0.3': {} @@ -4470,6 +4521,12 @@ snapshots: arg@4.1.3: {} + argon2@0.40.3: + dependencies: + '@phc/format': 1.0.0 + node-addon-api: 8.0.0 + node-gyp-build: 4.8.1 + argparse@1.0.10: dependencies: sprintf-js: 1.0.3 @@ -4740,6 +4797,8 @@ snapshots: convert-source-map@2.0.0: {} + cookie-signature@1.2.1: {} + cookie@0.6.0: {} cookiejar@2.1.4: {} @@ -5992,6 +6051,8 @@ snapshots: node-abort-controller@3.1.1: {} + node-addon-api@8.0.0: {} + node-emoji@1.11.0: dependencies: lodash: 4.17.21 @@ -6000,6 +6061,8 @@ snapshots: dependencies: whatwg-url: 5.0.0 + node-gyp-build@4.8.1: {} + node-int64@0.4.0: {} node-releases@2.0.14: {} @@ -6682,6 +6745,8 @@ snapshots: util-deprecate@1.0.2: {} + uuid@10.0.0: {} + uuid@9.0.1: {} v8-compile-cache-lib@3.0.1: {} diff --git a/prisma/migrations/20240701221340_users/migration.sql b/prisma/migrations/20240701221340_users/migration.sql new file mode 100644 index 0000000..b52362a --- /dev/null +++ b/prisma/migrations/20240701221340_users/migration.sql @@ -0,0 +1,52 @@ +-- CreateTable +CREATE TABLE "users" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "email" TEXT NOT NULL, + "username" TEXT NOT NULL, + "password" TEXT NOT NULL, + "avatar_id" INTEGER, + "type_id" INTEGER NOT NULL, + "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "users_avatar_id_fkey" FOREIGN KEY ("avatar_id") REFERENCES "images" ("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "users_type_id_fkey" FOREIGN KEY ("type_id") REFERENCES "user_types" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "user_types" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "name" TEXT NOT NULL +); + +-- CreateTable +CREATE TABLE "sessions" ( + "uuid" TEXT NOT NULL PRIMARY KEY, + "user_id" INTEGER NOT NULL, + "user_agent_sum" TEXT NOT NULL, + "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "favorites" ( + "user_id" INTEGER NOT NULL, + "webtoon_id" INTEGER NOT NULL, + + PRIMARY KEY ("user_id", "webtoon_id"), + CONSTRAINT "favorites_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "favorites_webtoon_id_fkey" FOREIGN KEY ("webtoon_id") REFERENCES "webtoons" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "progress" ( + "user_id" INTEGER NOT NULL, + "episode_id" INTEGER NOT NULL, + "progress" INTEGER NOT NULL DEFAULT 0, + + PRIMARY KEY ("user_id", "episode_id"), + CONSTRAINT "progress_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "progress_episode_id_fkey" FOREIGN KEY ("episode_id") REFERENCES "episodes" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "users_email_key" ON "users"("email"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2a82b21..5385102 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -13,6 +13,63 @@ datasource db { url = "file:./database.db" } +model Users { + id Int @id @default(autoincrement()) + email String @unique + username String + password String + avatar_id Int? + avatar Images? @relation(fields: [avatar_id], references: [id]) + type_id Int + type UserTypes @relation(fields: [type_id], references: [id]) + created_at DateTime @default(now()) + updated_at DateTime @default(now()) @updatedAt + sessions Sessions[] + favorites Favorites[] + progress Progress[] + + @@map("users") +} + +model UserTypes { + id Int @id @default(autoincrement()) + name String + user Users[] + + @@map("user_types") +} + +model Sessions { + uuid String @id + user_id Int + user Users @relation(fields: [user_id], references: [id], onDelete: Cascade) + user_agent_sum String + created_at DateTime @default(now()) + + @@map("sessions") +} + +model Favorites { + user_id Int + user Users @relation(fields: [user_id], references: [id], onDelete: Cascade) + webtoon_id Int + webtoon Webtoons @relation(fields: [webtoon_id], references: [id], onDelete: Cascade) + + @@id([user_id, webtoon_id]) + @@map("favorites") +} + +model Progress { + user_id Int + user Users @relation(fields: [user_id], references: [id], onDelete: Cascade) + episode_id Int + episode Episodes @relation(fields: [episode_id], references: [id], onDelete: Cascade) + progress Int @default(0) + + @@id([user_id, episode_id]) + @@map("progress") +} + model ImageTypes { id Int @id @default(autoincrement()) name String @@ -32,6 +89,7 @@ model Images { mobile_banner Webtoons? @relation("mobile_banner") episodes Episodes[] episode_images EpisodeImages[] + users Users[] @@map("images") } @@ -71,6 +129,7 @@ model Webtoons { updated_at DateTime @default(now()) @updatedAt genres WebtoonGenres[] episodes Episodes[] + favorites Favorites[] @@unique([title, author, language]) @@map("webtoons") @@ -86,6 +145,7 @@ model Episodes { thumbnail Images @relation(fields: [thumbnail_id], references: [id]) created_at DateTime @default(now()) episode_images EpisodeImages[] + progress Progress[] @@unique([webtoon_id, number]) @@map("episodes") diff --git a/prisma/seed.ts b/prisma/seed.ts index c7664ae..97dedce 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -2,18 +2,36 @@ import {PrismaClient} from "@prisma/client"; import * as dotenv from "dotenv"; import WebtoonGenres from "./../src/modules/webtoon/webtoon/models/enums/webtoon-genres"; import ImageTypes from "./../src/modules/webtoon/webtoon/models/enums/image-types"; +import UserTypes from "../src/modules/user/models/enums/user-types"; +import {CipherService} from "../src/modules/misc/cipher.service"; dotenv.config(); // initialize Prisma Client const prisma = new PrismaClient(); +const cipherService = new CipherService(); async function main(){ - const webtoon_genres_values = Object.values(WebtoonGenres).map(value => ({name: value})); - await seed(prisma.genres, webtoon_genres_values); + const webtoonGenresValues = Object.values(WebtoonGenres).map(value => ({name: value})); + await seed(prisma.genres, webtoonGenresValues); - const image_types_values = Object.values(ImageTypes).map(value => ({name: value})); - await seed(prisma.imageTypes, image_types_values); + const imageTypesValues = Object.values(ImageTypes).map(value => ({name: value})); + await seed(prisma.imageTypes, imageTypesValues); + + const userTypesValues = Object.values(UserTypes).map(value => ({name: value})); + await seed(prisma.userTypes, userTypesValues); + + await prisma.users.upsert({ + where: {id: 1}, + update: {}, + create: { + id: 1, + email: "root@example.org", + password: await cipherService.hash("root"), + username: "root", + type_id: 1, + }, + }); } async function seed(table: any, data: any[]){ diff --git a/src/app.module.ts b/src/app.module.ts index 1007c83..bce224b 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -9,6 +9,7 @@ import {MigrationModule} from "./modules/webtoon/migration/migration.module"; import {ScheduleModule} from "@nestjs/schedule"; import {UpdateModule} from "./modules/webtoon/update/update.module"; import {ImageModule} from "./modules/webtoon/image/image.module"; +import {UserModule} from "./modules/user/user.module"; @Module({ imports: [ @@ -24,6 +25,7 @@ import {ImageModule} from "./modules/webtoon/image/image.module"; MigrationModule, UpdateModule, ImageModule, + UserModule, ], controllers: [], providers: [ diff --git a/src/main.ts b/src/main.ts index d769c12..aa37408 100644 --- a/src/main.ts +++ b/src/main.ts @@ -12,6 +12,7 @@ import {SwaggerTheme, SwaggerThemeNameEnum} from "swagger-themes"; import {LoggerMiddleware} from "./common/middlewares/logger.middleware"; import {Logger} from "@nestjs/common"; import {CustomValidationPipe} from "./common/pipes/custom-validation.pipe"; +import fastifyCookie from "@fastify/cookie"; dotenv.config(); @@ -89,6 +90,9 @@ async function loadServer(server: NestFastifyApplication, serv crossOriginOpenerPolicy: false, crossOriginResourcePolicy: false, }); + await server.register(fastifyCookie, { + secret: process.env.COOKIE_SECRET, + }); // Swagger const config = new DocumentBuilder() @@ -96,6 +100,7 @@ async function loadServer(server: NestFastifyApplication, serv .setDescription("Documentation for the Phoenix API") .setVersion(process.env.npm_package_version) .addBearerAuth() + .addCookieAuth("session") .build(); const document = SwaggerModule.createDocument(server, config); const theme = new SwaggerTheme(); diff --git a/src/modules/misc/cipher.service.ts b/src/modules/misc/cipher.service.ts new file mode 100644 index 0000000..9164eec --- /dev/null +++ b/src/modules/misc/cipher.service.ts @@ -0,0 +1,113 @@ +import {Injectable} from "@nestjs/common"; +import * as crypto from "crypto"; +import * as argon2 from "argon2"; + + +@Injectable() +export class CipherService{ + // Hash functions + getSum(content: string | Buffer): string{ + if(!content) content = ""; + return crypto.createHash("sha256").update(content).digest("hex"); + } + + async hash(content: string | Buffer, cost = 10){ + if(!content) content = ""; + return await argon2.hash(content, { + type: argon2.argon2id, + timeCost: cost + }); + } + + async compareHash(hash: string, content: string | Buffer){ + if(!hash) return false; + if(!content) content = ""; + return await argon2.verify(hash, content); + } + + // Symmetric functions + cipherSymmetric(content: string, encryptionKey: string | Buffer, timeCost = 200000){ + if(!content) content = ""; + const salt = crypto.randomBytes(32); + const key = crypto.pbkdf2Sync(encryptionKey, salt, timeCost, 64, "sha512"); + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv("aes-256-cbc", key.subarray(0, 32), iv); + let encrypted = cipher.update(content, "utf-8", "hex"); + encrypted += cipher.final("hex"); + const hmac = crypto.createHmac("sha256", key.subarray(32)); + hmac.update(`${salt.toString("hex")}:${iv.toString("hex")}:${encrypted}`); + const digest = hmac.digest("hex"); + return `${salt.toString("hex")}:${iv.toString("hex")}:${encrypted}:${digest}`; + } + + decipherSymmetric(encryptedContent: string, encryptionKey: string | Buffer, timeCost = 200000){ + const [saltString, ivString, encryptedString, digest] = encryptedContent.split(":"); + const salt = Buffer.from(saltString, "hex"); + const key = crypto.pbkdf2Sync(encryptionKey, salt, timeCost, 64, "sha512"); + const iv = Buffer.from(ivString, "hex"); + const hmac = crypto.createHmac("sha256", key.subarray(32)); + hmac.update(`${saltString}:${ivString}:${encryptedString}`); + const calculatedDigest = hmac.digest("hex"); + if (calculatedDigest !== digest) + throw new Error("Integrity check failed"); + const decipher = crypto.createDecipheriv("aes-256-cbc", key.subarray(0, 32), iv); + let decrypted = decipher.update(encryptedString, "hex", "utf-8"); + decrypted += decipher.final("utf-8"); + return decrypted; + } + + // Asymmetric functions + generateKeyPair(modulusLength = 4096, privateEncryptionKey = null){ + if(!privateEncryptionKey) + console.warn("No private encryption key provided, the private key will not be encrypted"); + let options = undefined; + if(privateEncryptionKey){ + options = { + cipher: "aes-256-cbc", + passphrase: privateEncryptionKey + }; + } + return crypto.generateKeyPairSync("rsa", { + modulusLength: modulusLength, + publicKeyEncoding: { + type: "spki", + format: "pem" + }, + privateKeyEncoding: { + type: "pkcs8", + format: "pem", + ...options + } + }); + } + + cipherAsymmetric(content: string, publicKey: string | Buffer){ + if(!content) content = ""; + const buffer = Buffer.from(content, "utf-8"); + const encrypted = crypto.publicEncrypt({ + key: publicKey, + padding: crypto.constants.RSA_PKCS1_OAEP_PADDING + }, buffer); + return encrypted.toString("base64"); + } + + decipherAsymmetric(encryptedContent: string, privateKey: string | Buffer, privateEncryptionKey = undefined){ + const buffer = Buffer.from(encryptedContent, "base64"); + if(!privateEncryptionKey) + return crypto.privateDecrypt({ + key: privateKey, + padding: crypto.constants.RSA_PKCS1_OAEP_PADDING + }, buffer).toString("utf-8"); + else + return crypto.privateDecrypt({ + key: privateKey, + padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, + passphrase: privateEncryptionKey + }, buffer).toString("utf-8"); + } + + // Secret functions + generateSecret(length = 32){ + return crypto.randomBytes(length).toString("hex"); + } +} diff --git a/src/modules/misc/misc.module.ts b/src/modules/misc/misc.module.ts index 17a0b2c..7846376 100644 --- a/src/modules/misc/misc.module.ts +++ b/src/modules/misc/misc.module.ts @@ -2,11 +2,12 @@ import {Module} from "@nestjs/common"; import {MiscService} from "./misc.service"; import {PrismaService} from "./prisma.service"; import {VersionController} from "./version.controller"; +import {CipherService} from "./cipher.service"; @Module({ imports: [], controllers: [VersionController], - providers: [MiscService, PrismaService], - exports: [MiscService, PrismaService], + providers: [MiscService, PrismaService, CipherService], + exports: [MiscService, PrismaService, CipherService], }) export class MiscModule{} diff --git a/src/modules/task/session-cleaning.task.ts b/src/modules/task/session-cleaning.task.ts new file mode 100644 index 0000000..198da69 --- /dev/null +++ b/src/modules/task/session-cleaning.task.ts @@ -0,0 +1,20 @@ +import {Injectable, Logger} from "@nestjs/common"; +import {Cron} from "@nestjs/schedule"; +import {AuthService} from "../user/auth.service"; + +@Injectable() +export class SessionCleaningTask{ + + private readonly logger = new Logger(SessionCleaningTask.name); + + constructor( + private readonly authService: AuthService, + ){} + + @Cron("0 0 0 * * *") + async handleCron(){ + // Called every day at 00:00 + const count = await this.authService.cleanSessions(); + this.logger.debug(`Cleaned ${count} sessions`); + } +} diff --git a/src/modules/task/task.module.ts b/src/modules/task/task.module.ts index 006d95b..52b0c9c 100644 --- a/src/modules/task/task.module.ts +++ b/src/modules/task/task.module.ts @@ -1,9 +1,11 @@ import {Module} from "@nestjs/common"; import {WebtoonUpdateTask} from "./webtoon_update.task"; import {WebtoonModule} from "../webtoon/webtoon/webtoon.module"; +import {SessionCleaningTask} from "./session-cleaning.task"; +import {UserModule} from "../user/user.module"; @Module({ - providers: [WebtoonUpdateTask], - imports: [WebtoonModule] + providers: [WebtoonUpdateTask, SessionCleaningTask], + imports: [WebtoonModule, UserModule], }) export class TaskModule{} diff --git a/src/modules/user/auth.controller.ts b/src/modules/user/auth.controller.ts new file mode 100644 index 0000000..2745908 --- /dev/null +++ b/src/modules/user/auth.controller.ts @@ -0,0 +1,66 @@ +import {Body, Controller, HttpCode, Post, Req, Res, UseGuards} from "@nestjs/common"; +import {ApiResponse, ApiTags} from "@nestjs/swagger"; +import {AuthService} from "./auth.service"; +import {LoginDto} from "./models/dto/login.dto"; +import {FastifyReply, FastifyRequest} from "fastify"; +import {ConfigService} from "@nestjs/config"; +import {HttpStatusCode} from "axios"; +import {AuthGuard} from "./guard/auth.guard"; + + +@Controller("auth") +@ApiTags("Auth") +export class AuthController{ + + constructor( + private readonly authService: AuthService, + private readonly configService: ConfigService, + ){} + + @Post("login") + @HttpCode(HttpStatusCode.NoContent) + @ApiResponse({status: HttpStatusCode.NoContent, description: "Login successful"}) + @ApiResponse({status: HttpStatusCode.NotFound, description: "User not found"}) + @ApiResponse({status: HttpStatusCode.Unauthorized, description: "Invalid password"}) + @ApiResponse({status: HttpStatusCode.BadRequest, description: "Invalid email format"}) + async login(@Body() body: LoginDto, @Req() request: FastifyRequest, @Res({passthrough: true}) res: FastifyReply){ + const userAgent = request.headers["user-agent"]; + const sessionUUID = await this.authService.loginUser(body.email, body.password, userAgent); + res.setCookie("session", sessionUUID, { + httpOnly: true, + sameSite: "strict", + secure: this.configService.get("SECURE_COOKIE") === "true", + path: "/" + this.configService.get("PREFIX"), + }); + } + + @Post("logout") + @UseGuards(AuthGuard) + @HttpCode(HttpStatusCode.NoContent) + @ApiResponse({status: HttpStatusCode.NoContent, description: "Logout successful"}) + async logout(@Req() request: any, @Res({passthrough: true}) res: FastifyReply){ + const sessionUUID = request.cookies.session; + await this.authService.logoutUser(request.user.id, sessionUUID); + res.clearCookie("session", { + httpOnly: true, + sameSite: "strict", + secure: this.configService.get("SECURE_COOKIE") === "true", + path: "/" + this.configService.get("PREFIX"), + }); + } + + @Post("logout/all") + @UseGuards(AuthGuard) + @HttpCode(HttpStatusCode.NoContent) + @ApiResponse({status: HttpStatusCode.NoContent, description: "Logout successful"}) + async logoutAll(@Req() request: any, @Res({passthrough: true}) res: FastifyReply){ + await this.authService.logoutAllUser(request.user.id); + res.clearCookie("session", { + httpOnly: true, + sameSite: "strict", + secure: this.configService.get("SECURE_COOKIE") === "true", + path: "/" + this.configService.get("PREFIX"), + }); + } + +} diff --git a/src/modules/user/auth.service.ts b/src/modules/user/auth.service.ts new file mode 100644 index 0000000..d1d468c --- /dev/null +++ b/src/modules/user/auth.service.ts @@ -0,0 +1,130 @@ +import {Injectable, NotFoundException, UnauthorizedException} from "@nestjs/common"; +import {PrismaService} from "../misc/prisma.service"; +import {CipherService} from "../misc/cipher.service"; +import * as uuid from "uuid"; +import {ConfigService} from "@nestjs/config"; +import {UserEntity} from "./models/entities/user.entity"; + + +@Injectable() +export class AuthService{ + + constructor( + private readonly prismaService: PrismaService, + private readonly cipherService: CipherService, + private readonly configService: ConfigService, + ){} + + async loginUser(email: string, password: string, userAgent: string): Promise{ + const user = await this.prismaService.users.findUnique({ + where: { + email, + }, + }); + if(!user) + throw new NotFoundException("User not found"); + if(!await this.cipherService.compareHash(user.password, password)) + throw new UnauthorizedException("Invalid password"); + // Check if user has more than the maximum number of sessions + const userSessions = await this.prismaService.sessions.findMany({ + where: { + user_id: user.id, + }, + select: { + uuid: true, + } + }); + if(userSessions.length >= this.configService.get("MAX_SESSIONS")){ + await this.prismaService.sessions.delete({ + where: { + uuid: userSessions[0].uuid, + }, + }); + } + // Create a new session + const sessionUUID = uuid.v7(); + const userAgentSum = this.cipherService.getSum(userAgent); + await this.prismaService.sessions.create({ + data: { + uuid: sessionUUID, + user_id: user.id, + user_agent_sum: userAgentSum, + }, + }); + return sessionUUID; + } + + async verifySession(sessionUUID: string, userAgent: string): Promise{ + const userAgentSum = this.cipherService.getSum(userAgent); + const session = await this.prismaService.sessions.findUnique({ + where: { + uuid: sessionUUID, + } + }); + if(!session) + throw new UnauthorizedException("Invalid session"); + if(session.user_agent_sum !== userAgentSum){ + await this.prismaService.sessions.delete({ + where: { + uuid: sessionUUID, + }, + }); + throw new UnauthorizedException("Invalid session"); + } + const sessionExpiration = parseInt(this.configService.get("SESSION_EXPIRATION")) * 1000; + if(session.created_at.getTime() + sessionExpiration < Date.now()){ + await this.prismaService.sessions.delete({ + where: { + uuid: sessionUUID, + }, + }); + throw new UnauthorizedException("Session expired"); + } + const user = await this.prismaService.users.findUnique({ + where: { + id: session.user_id, + }, + include: { + type: true, + avatar: true, + } + }); + return new UserEntity( + user.id, + user.email, + user.username, + user.avatar?.sum, + user.type.name, + user.created_at, + user.updated_at, + ); + } + + async cleanSessions(): Promise{ + const {count} = await this.prismaService.sessions.deleteMany({ + where: { + created_at: { + lt: new Date(Date.now() - parseInt(this.configService.get("SESSION_EXPIRATION")) * 1000), + }, + }, + }); + return count; + } + + async logoutUser(userId: number, sessionUUID: string){ + await this.prismaService.sessions.delete({ + where: { + uuid: sessionUUID, + user_id: userId, + }, + }); + } + + async logoutAllUser(userId: number){ + await this.prismaService.sessions.deleteMany({ + where: { + user_id: userId, + }, + }); + } +} diff --git a/src/modules/user/guard/admin.guard.ts b/src/modules/user/guard/admin.guard.ts new file mode 100644 index 0000000..0624c3e --- /dev/null +++ b/src/modules/user/guard/admin.guard.ts @@ -0,0 +1,15 @@ +import {CanActivate, ExecutionContext, Injectable, UnauthorizedException} from "@nestjs/common"; +import UserTypes from "../models/enums/user-types"; + +@Injectable() +export class AdminGuard implements CanActivate{ + + async canActivate(context: ExecutionContext): Promise{ + const request = context.switchToHttp().getRequest(); + if(!request.user) + throw new UnauthorizedException("No user provided"); + if(request.user.type !== UserTypes.ADMIN) + throw new UnauthorizedException("User is not an admin"); + return true; + } +} diff --git a/src/modules/user/guard/auth.guard.ts b/src/modules/user/guard/auth.guard.ts new file mode 100644 index 0000000..97dab27 --- /dev/null +++ b/src/modules/user/guard/auth.guard.ts @@ -0,0 +1,23 @@ +import {CanActivate, ExecutionContext, Injectable, UnauthorizedException} from "@nestjs/common"; +import {AuthService} from "../auth.service"; + + +@Injectable() +export class AuthGuard implements CanActivate{ + constructor( + private readonly authService: AuthService, + ){} + + async canActivate(context: ExecutionContext): Promise{ + const request = context.switchToHttp().getRequest(); + const sessionUUID = request.cookies.session; + if(!sessionUUID) + throw new UnauthorizedException("No session provided"); + const userAgent = request.headers["user-agent"]; + const user = await this.authService.verifySession(sessionUUID, userAgent); + if(!user) + throw new UnauthorizedException("Invalid session"); + request.user = user; + return true; + } +} diff --git a/src/modules/user/models/dto/login.dto.ts b/src/modules/user/models/dto/login.dto.ts new file mode 100644 index 0000000..0af1ebb --- /dev/null +++ b/src/modules/user/models/dto/login.dto.ts @@ -0,0 +1,14 @@ +import {ApiProperty} from "@nestjs/swagger"; +import {IsEmail, IsNotEmpty, IsString} from "class-validator"; + +export class LoginDto{ + @ApiProperty() + @IsNotEmpty() + @IsString() + @IsEmail() + email: string; + @ApiProperty() + @IsNotEmpty() + @IsString() + password: string; +} diff --git a/src/modules/user/models/entities/user.entity.ts b/src/modules/user/models/entities/user.entity.ts new file mode 100644 index 0000000..c269052 --- /dev/null +++ b/src/modules/user/models/entities/user.entity.ts @@ -0,0 +1,37 @@ +import {ApiProperty} from "@nestjs/swagger"; + + +export class UserEntity{ + @ApiProperty() + id: number; + @ApiProperty() + email: string; + @ApiProperty() + username: string; + @ApiProperty() + avatar_sum: string; + @ApiProperty() + type: string; + @ApiProperty() + created_at: Date; + @ApiProperty() + updated_at: Date; + + constructor( + id: number, + email: string, + username: string, + avatar_sum: string, + type: string, + created_at: Date, + updated_at: Date + ){ + this.id = id; + this.email = email; + this.username = username; + this.avatar_sum = avatar_sum; + this.type = type; + this.created_at = created_at; + this.updated_at = updated_at; + } +} diff --git a/src/modules/user/models/enums/user-types.ts b/src/modules/user/models/enums/user-types.ts new file mode 100644 index 0000000..63ca430 --- /dev/null +++ b/src/modules/user/models/enums/user-types.ts @@ -0,0 +1,5 @@ +enum UserTypes{ + ADMIN = "ADMIN", + USER = "USER", +} +export default UserTypes; diff --git a/src/modules/user/user.controller.ts b/src/modules/user/user.controller.ts new file mode 100644 index 0000000..8027b2a --- /dev/null +++ b/src/modules/user/user.controller.ts @@ -0,0 +1,25 @@ +import {Controller, Get, Req, UseGuards} from "@nestjs/common"; +import {ApiCookieAuth, ApiResponse, ApiTags} from "@nestjs/swagger"; +import {UserService} from "./user.service"; +import {AuthGuard} from "./guard/auth.guard"; +import {HttpStatusCode} from "axios"; +import {UserEntity} from "./models/entities/user.entity"; + +@Controller("user") +@ApiTags("User") +export class UserController{ + + constructor( + private readonly userService: UserService, + ){} + + @Get("me") + @UseGuards(AuthGuard) + @ApiCookieAuth() + @ApiResponse({status: HttpStatusCode.Ok, description: "Returns the user's information", type: UserEntity}) + @ApiResponse({status: HttpStatusCode.Unauthorized, description: "Invalid session"}) + @ApiResponse({status: HttpStatusCode.NotFound, description: "User not found"}) + async getMe(@Req() request: any){ + return request.user; + } +} diff --git a/src/modules/user/user.module.ts b/src/modules/user/user.module.ts new file mode 100644 index 0000000..e35a754 --- /dev/null +++ b/src/modules/user/user.module.ts @@ -0,0 +1,16 @@ +import {Module} from "@nestjs/common"; +import {UserController} from "./user.controller"; +import {UserService} from "./user.service"; +import {MiscModule} from "../misc/misc.module"; +import {AuthController} from "./auth.controller"; +import {AuthService} from "./auth.service"; +import {AuthGuard} from "./guard/auth.guard"; + + +@Module({ + imports: [MiscModule], + controllers: [UserController, AuthController], + providers: [UserService, AuthService, AuthGuard], + exports: [AuthService], +}) +export class UserModule{} diff --git a/src/modules/user/user.service.ts b/src/modules/user/user.service.ts new file mode 100644 index 0000000..4248091 --- /dev/null +++ b/src/modules/user/user.service.ts @@ -0,0 +1,35 @@ +import {Injectable, NotFoundException} from "@nestjs/common"; +import {PrismaService} from "../misc/prisma.service"; +import {UserEntity} from "./models/entities/user.entity"; + + +@Injectable() +export class UserService{ + + constructor( + private readonly prismaService: PrismaService, + ){} + + async getMe(userId: number): Promise{ + const user = await this.prismaService.users.findUnique({ + where: { + id: userId, + }, + include: { + type: true, + avatar: true, + } + }); + if(!user) + throw new NotFoundException("User not found"); + return new UserEntity( + user.id, + user.email, + user.username, + user.avatar?.sum, + user.type.name, + user.created_at, + user.updated_at, + ); + } +} diff --git a/src/modules/webtoon/admin/admin.controller.ts b/src/modules/webtoon/admin/admin.controller.ts index c942d9c..7d446ff 100644 --- a/src/modules/webtoon/admin/admin.controller.ts +++ b/src/modules/webtoon/admin/admin.controller.ts @@ -4,12 +4,13 @@ import {DownloadManagerService} from "../webtoon/download-manager.service"; import {AddWebtoonToQueueDto} from "./models/dto/add-webtoon-to-queue.dto"; import {HttpStatusCode} from "axios"; import CachedWebtoonModel from "../webtoon/models/models/cached-webtoon.model"; -import {AdminGuard} from "./guard/admin.guard"; +import {AdminGuard} from "../../user/guard/admin.guard"; +import {AuthGuard} from "../../user/guard/auth.guard"; @Controller("admin") @ApiTags("Admin") -@UseGuards(AdminGuard) +@UseGuards(AuthGuard, AdminGuard) export class AdminController{ constructor( diff --git a/src/modules/webtoon/admin/admin.module.ts b/src/modules/webtoon/admin/admin.module.ts index 13274fc..2080e49 100644 --- a/src/modules/webtoon/admin/admin.module.ts +++ b/src/modules/webtoon/admin/admin.module.ts @@ -1,9 +1,10 @@ import {Module} from "@nestjs/common"; import {AdminController} from "./admin.controller"; import {WebtoonModule} from "../webtoon/webtoon.module"; +import {UserModule} from "../../user/user.module"; @Module({ - imports: [WebtoonModule], + imports: [WebtoonModule, UserModule], controllers: [AdminController], providers: [], }) diff --git a/src/modules/webtoon/admin/guard/admin.guard.ts b/src/modules/webtoon/admin/guard/admin.guard.ts deleted file mode 100644 index 8780901..0000000 --- a/src/modules/webtoon/admin/guard/admin.guard.ts +++ /dev/null @@ -1,20 +0,0 @@ -import {CanActivate, ExecutionContext, Injectable, UnauthorizedException} from "@nestjs/common"; -import {ConfigService} from "@nestjs/config"; - -@Injectable() -export class AdminGuard implements CanActivate{ - constructor( - private readonly configService: ConfigService, - ){} - - async canActivate(context: ExecutionContext): Promise{ - const adminKey = this.configService.get("ADMIN_KEY"); - const request = context.switchToHttp().getRequest(); - const token = request.headers.authorization?.split(" ")[1]; - if(!token) - throw new UnauthorizedException("No token provided"); - if(token !== adminKey) - throw new UnauthorizedException("Invalid token"); - return true; - } -} diff --git a/src/modules/webtoon/migration/migration.controller.ts b/src/modules/webtoon/migration/migration.controller.ts index 9a63b6c..dbc41d5 100644 --- a/src/modules/webtoon/migration/migration.controller.ts +++ b/src/modules/webtoon/migration/migration.controller.ts @@ -5,12 +5,13 @@ import {ReadStream} from "fs"; import {ChunkNumberDto} from "../../../common/models/dto/chunk-number.dto"; import MigrationInfosResponse from "./models/responses/migration-infos.response"; import MigrateFromDto from "./models/dto/migrate-from.dto"; -import {AdminGuard} from "../admin/guard/admin.guard"; import {HttpStatusCode} from "axios"; +import {AuthGuard} from "../../user/guard/auth.guard"; +import {AdminGuard} from "../../user/guard/admin.guard"; @Controller("migration") @ApiTags("Migration") -@UseGuards(AdminGuard) +@UseGuards(AuthGuard, AdminGuard) export class MigrationController{ constructor( private readonly migrationService: MigrationService diff --git a/src/modules/webtoon/migration/migration.module.ts b/src/modules/webtoon/migration/migration.module.ts index 86696f3..a49c19c 100644 --- a/src/modules/webtoon/migration/migration.module.ts +++ b/src/modules/webtoon/migration/migration.module.ts @@ -3,10 +3,11 @@ import {MigrationController} from "./migration.controller"; import {MigrationService} from "./migration.service"; import {WebtoonModule} from "../webtoon/webtoon.module"; import {MiscModule} from "../../misc/misc.module"; +import {UserModule} from "../../user/user.module"; @Module({ providers: [MigrationService], controllers: [MigrationController], - imports: [WebtoonModule, MiscModule] + imports: [WebtoonModule, MiscModule, UserModule] }) export class MigrationModule{} diff --git a/src/modules/webtoon/update/update.controller.ts b/src/modules/webtoon/update/update.controller.ts index e59fe3d..706a04a 100644 --- a/src/modules/webtoon/update/update.controller.ts +++ b/src/modules/webtoon/update/update.controller.ts @@ -1,13 +1,14 @@ import {Controller, Logger, Post, UseGuards} from "@nestjs/common"; import {UpdateService} from "./update.service"; import {ApiBearerAuth, ApiResponse, ApiTags} from "@nestjs/swagger"; -import {AdminGuard} from "../admin/guard/admin.guard"; import {HttpStatusCode} from "axios"; +import {AuthGuard} from "../../user/guard/auth.guard"; +import {AdminGuard} from "../../user/guard/admin.guard"; @Controller("update") @ApiTags("Update") -@UseGuards(AdminGuard) +@UseGuards(AuthGuard, AdminGuard) export class UpdateController{ private readonly logger = new Logger(UpdateController.name); diff --git a/src/modules/webtoon/update/update.module.ts b/src/modules/webtoon/update/update.module.ts index 14b8b99..532fa20 100644 --- a/src/modules/webtoon/update/update.module.ts +++ b/src/modules/webtoon/update/update.module.ts @@ -3,11 +3,12 @@ import {UpdateService} from "./update.service"; import {UpdateController} from "./update.controller"; import {MiscModule} from "../../misc/misc.module"; import {WebtoonModule} from "../webtoon/webtoon.module"; +import {UserModule} from "../../user/user.module"; @Module({ providers: [UpdateService], controllers: [UpdateController], - imports: [MiscModule, WebtoonModule] + imports: [MiscModule, WebtoonModule, UserModule] }) export class UpdateModule{}