From 7e5e88a4fd8e9ab571a41d9086d22ec5df9f66a1 Mon Sep 17 00:00:00 2001 From: Mykola Balielov Date: Tue, 23 Apr 2024 23:03:54 +0300 Subject: [PATCH] add google auth --- .env.example | 7 + .gitignore | 3 + package-lock.json | 581 +++++++++++++++++- package.json | 10 +- prisma/schema.prisma | 52 +- src/config/app.config.ts | 33 +- src/config/ngrok.config.ts | 16 + src/config/social.config.ts | 19 + src/constants/errors.constants.ts | 3 + src/filters/oauth2.exception.filter.ts | 13 + src/filters/oauth2.exception.ts | 8 + src/main.ts | 53 +- src/modules/app/app.module.ts | 20 +- src/modules/auth/auth-social.service.ts | 64 ++ src/modules/auth/auth.controller.ts | 2 +- src/modules/auth/auth.module.ts | 18 +- .../auth/controllers/google.controller.ts | 39 ++ .../decorators/google-oauth2.decorator.ts | 28 + .../decorators/social-redirect.decorator.ts | 11 + src/modules/auth/{ => guards}/auth.guard.ts | 2 +- src/modules/auth/guards/google.guard.ts | 50 ++ .../auth/{ => guards}/skip-auth.guard.ts | 0 .../auth/guards/social-redirect.guard.ts | 34 + .../social-redirect.interceptor.ts | 20 + .../auth/interfaces/social-user.interface.ts | 14 + src/modules/auth/social.repository.ts | 83 +++ .../auth/strategies/google.strategy.ts | 33 + src/modules/auth/token.service.ts | 4 + src/modules/casl/access.guard.ts | 16 +- src/modules/health/health.controller.ts | 2 +- 30 files changed, 1190 insertions(+), 48 deletions(-) create mode 100644 .env.example create mode 100644 src/config/ngrok.config.ts create mode 100644 src/config/social.config.ts create mode 100644 src/filters/oauth2.exception.filter.ts create mode 100644 src/filters/oauth2.exception.ts create mode 100644 src/modules/auth/auth-social.service.ts create mode 100644 src/modules/auth/controllers/google.controller.ts create mode 100644 src/modules/auth/decorators/google-oauth2.decorator.ts create mode 100644 src/modules/auth/decorators/social-redirect.decorator.ts rename src/modules/auth/{ => guards}/auth.guard.ts (96%) create mode 100644 src/modules/auth/guards/google.guard.ts rename src/modules/auth/{ => guards}/skip-auth.guard.ts (100%) create mode 100644 src/modules/auth/guards/social-redirect.guard.ts create mode 100644 src/modules/auth/interceptors/social-redirect.interceptor.ts create mode 100644 src/modules/auth/interfaces/social-user.interface.ts create mode 100644 src/modules/auth/social.repository.ts create mode 100644 src/modules/auth/strategies/google.strategy.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c2a7240 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +DATABASE_URL=mongodb://localhost:30000,localhost:30001,localhost:30002/starter?replicaSet=rs0 +NGROK_DOMAIN= +NGROK_AUTHTOKEN= +NGROK_ENABLE=false +NODE_ENV=development +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= diff --git a/.gitignore b/.gitignore index 17fe629..cbcad0a 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ lerna-debug.log* !.vscode/extensions.json .adminjs + +# env +.env diff --git a/package-lock.json b/package-lock.json index 4f52cca..3fb4774 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,11 +16,13 @@ "@nestjs/common": "10.0.2", "@nestjs/config": "3.0.0", "@nestjs/core": "10.0.2", - "@nestjs/jwt": "10.1.0", + "@nestjs/jwt": "^10.1.0", + "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "10.0.2", "@nestjs/swagger": "7.0.2", "@nestjs/terminus": "10.0.1", "@nestjs/throttler": "4.1.0", + "@ngrok/ngrok": "^1.2.0", "@nodeteam/nestjs-pipes": "1.2.5", "@nodeteam/nestjs-prisma-pagination": "1.0.6", "@prisma/client": "4.15.0", @@ -35,6 +37,10 @@ "express-basic-auth": "1.2.1", "jest-mock-extended": "3.0.5", "module-alias": "2.2.3", + "passport": "^0.7.0", + "passport-google-oauth2": "^0.2.0", + "passport-jwt": "^4.0.1", + "passport-local": "^1.0.0", "reflect-metadata": "0.1.13", "rxjs": "7.8.1", "sqs-consumer": "7.2.0", @@ -49,6 +55,8 @@ "@types/express": "4.17.17", "@types/jest": "29.5.2", "@types/node": "20.3.1", + "@types/passport-google-oauth2": "^0.1.8", + "@types/passport-jwt": "^4.0.1", "@types/supertest": "2.0.12", "@typescript-eslint/eslint-plugin": "5.60.0", "@typescript-eslint/parser": "5.60.0", @@ -3345,6 +3353,15 @@ } } }, + "node_modules/@nestjs/passport": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-10.0.3.tgz", + "integrity": "sha512-znJ9Y4S8ZDVY+j4doWAJ8EuuVO7SkQN3yOBmzxbGaXbvcSwFDAdGJ+OMCg52NdzIO4tQoN4pYKx8W6M0ArfFRQ==", + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", + "passport": "^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0" + } + }, "node_modules/@nestjs/platform-express": { "version": "10.0.2", "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.0.2.tgz", @@ -3521,6 +3538,221 @@ "reflect-metadata": "^0.1.13" } }, + "node_modules/@ngrok/ngrok": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok/-/ngrok-1.2.0.tgz", + "integrity": "sha512-KOM7lHTKzsQ8IQewgigOGynJKHQRt3z0lHyEgK63H2wq9tI47tWlxWYmrQoqWLcvPgP2JA24nXWeMf/ZE2BaZg==", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@ngrok/ngrok-android-arm-eabi": "1.2.0", + "@ngrok/ngrok-android-arm64": "1.2.0", + "@ngrok/ngrok-darwin-arm64": "1.2.0", + "@ngrok/ngrok-darwin-universal": "1.2.0", + "@ngrok/ngrok-darwin-x64": "1.2.0", + "@ngrok/ngrok-freebsd-x64": "1.2.0", + "@ngrok/ngrok-linux-arm-gnueabihf": "1.2.0", + "@ngrok/ngrok-linux-arm64-gnu": "1.2.0", + "@ngrok/ngrok-linux-arm64-musl": "1.2.0", + "@ngrok/ngrok-linux-x64-gnu": "1.2.0", + "@ngrok/ngrok-linux-x64-musl": "1.2.0", + "@ngrok/ngrok-win32-ia32-msvc": "1.2.0", + "@ngrok/ngrok-win32-x64-msvc": "1.2.0" + } + }, + "node_modules/@ngrok/ngrok-android-arm-eabi": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-android-arm-eabi/-/ngrok-android-arm-eabi-1.2.0.tgz", + "integrity": "sha512-eGSZ443VQDfLLCef7E2GDX8oRYp+Fahr7nGeNY7nHu6bMEiYA92VpFOlpO9iF6HsyK/94NJiN3CbLJ7aMUHmJA==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-android-arm64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-android-arm64/-/ngrok-android-arm64-1.2.0.tgz", + "integrity": "sha512-nwGcgK4OkFKNxEVu4Q4R8LR15OUvfESJIQ3gS5frMwa0r/FdHKNc5rmUfGSFhGGg+4ttiTzlEKMHW2tEp12FYQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-darwin-arm64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-darwin-arm64/-/ngrok-darwin-arm64-1.2.0.tgz", + "integrity": "sha512-Df0U42wYty4UnYB9Bm+bo3akIVe0lOX/ejDSCrPABK0Cf65184ZcqUu9kBabXQRag5rTq4uG7TzCGhaMXV44RQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-darwin-universal": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-darwin-universal/-/ngrok-darwin-universal-1.2.0.tgz", + "integrity": "sha512-z5srpqZjQKomLBsY/b2Oc44V4BqIqE7Tn3YdsN3ucZg4yH9aFTHLiD2Ioubb/8u48mRvwsT9FqFTGlswWA8dew==", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-darwin-x64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-darwin-x64/-/ngrok-darwin-x64-1.2.0.tgz", + "integrity": "sha512-EkH2qFoXyaZkLn2Nw5NvK1F6T3332xzTVXeJMF/oLme2K567rRHmFUfN9mUORm1snHO+Ct/uW9Tciz6+hlyTLg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-freebsd-x64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-freebsd-x64/-/ngrok-freebsd-x64-1.2.0.tgz", + "integrity": "sha512-mwD1QBKsWV7J8IGYCsLvPvk6yoMpwlNsCt7d/nkrw2AQvx0UJgZw+q3YW93Ii7r392DtvmzDDO3jxlx/Gl0mXQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-linux-arm-gnueabihf": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-linux-arm-gnueabihf/-/ngrok-linux-arm-gnueabihf-1.2.0.tgz", + "integrity": "sha512-yvDEgCSL/EAAHV5EuAjULKZ8/P/i5UCN31oyp8x3hPIpTl2Lq9GfPfoAIvpM8segsbkXwlFPIA7FjMbNweQa2A==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-linux-arm64-gnu": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-linux-arm64-gnu/-/ngrok-linux-arm64-gnu-1.2.0.tgz", + "integrity": "sha512-fEVMO7OiLTHWRDBSCvXtLhZ8C9l9aCUBduYXqCtIRx0unczEX7/jpAAD0rq32eioNfywj30s0RZRusYdrPbDTQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-linux-arm64-musl": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-linux-arm64-musl/-/ngrok-linux-arm64-musl-1.2.0.tgz", + "integrity": "sha512-3P6YRcID3A8J+uOEf6JIUFbEnaS4SM2dDxqYgjkoo5Qjjn/V3tZdMpbGzs/R6ly9eZ8vPQNI6A4mtHGmfHjaTA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-linux-x64-gnu": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-linux-x64-gnu/-/ngrok-linux-x64-gnu-1.2.0.tgz", + "integrity": "sha512-geiZ4rTzTySIbLyxEkIV7aPdJ2hr3cKIGv1nN7uFzJPHPwxwRYLctyXHOrjbNLaKUcoEzuyqHVhu2/qUL9FZDg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-linux-x64-musl": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-linux-x64-musl/-/ngrok-linux-x64-musl-1.2.0.tgz", + "integrity": "sha512-s4nfAUuCM2X4T+Z9RWzD3NbhEnzeTrpsQq51RNQ9JEjN6guab3FdT/Bn9nXkULJdZOPvqguHg878KmTwi8t3mA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-win32-ia32-msvc": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-win32-ia32-msvc/-/ngrok-win32-ia32-msvc-1.2.0.tgz", + "integrity": "sha512-R4ovlTz22AuP7trn8CnzXqDrxFuStpx2657AMDSop/3yhS1hCxTqycRSQwXqhJisV5midaJY/e7YwNBmYnbUIA==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-win32-x64-msvc": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-win32-x64-msvc/-/ngrok-win32-x64-msvc-1.2.0.tgz", + "integrity": "sha512-A5sIaZ3pXLWZO5ft0o4oyNWg6itCAaZ4Mln8Y/sMsx4AjAcaHqBod1J2We2jt5Et52sr5JRaDwBokz84ZUNi6A==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -4438,6 +4670,44 @@ "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==", "dev": true }, + "node_modules/@types/passport": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.16.tgz", + "integrity": "sha512-FD0qD5hbPWQzaM0wHUnJ/T0BBCJBxCeemtnCwc/ThhTg3x9jfrAcRUmj5Dopza+MfFS9acTe3wk7rcVnRIp/0A==", + "dev": true, + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/passport-google-oauth2": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@types/passport-google-oauth2/-/passport-google-oauth2-0.1.8.tgz", + "integrity": "sha512-0lgGOVbGNTw7NcmrPh3pRwtGJXf1XZG7FZkYtM5DuQ8HJjHzi5mMIq28x7v/q5NNDIrFvhjE0CnyTv7hn4DIjg==", + "dev": true, + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/passport-jwt": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-4.0.1.tgz", + "integrity": "sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==", + "dev": true, + "dependencies": { + "@types/jsonwebtoken": "*", + "@types/passport-strategy": "*" + } + }, + "node_modules/@types/passport-strategy": { + "version": "0.2.38", + "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz", + "integrity": "sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==", + "dev": true, + "dependencies": { + "@types/express": "*", + "@types/passport": "*" + } + }, "node_modules/@types/prettier": { "version": "2.7.3", "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.3.tgz", @@ -5362,6 +5632,14 @@ } ] }, + "node_modules/base64url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/basic-auth": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", @@ -10260,6 +10538,11 @@ "set-blocking": "^2.0.0" } }, + "node_modules/oauth": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.0.tgz", + "integrity": "sha512-1orQ9MT1vHFGQxhuy7E/0gECD3fd2fCC+PIX+/jgmU/gI3EpRocXtmtvxCO5x3WZ443FLTLFWNDjl5MPJf9u+Q==" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -10496,6 +10779,78 @@ "node": ">= 0.8" } }, + "node_modules/passport": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", + "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", + "dependencies": { + "passport-strategy": "1.x.x", + "pause": "0.0.1", + "utils-merge": "^1.0.1" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-google-oauth2": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/passport-google-oauth2/-/passport-google-oauth2-0.2.0.tgz", + "integrity": "sha512-62EdPtbfVdc55nIXi0p1WOa/fFMM8v/M8uQGnbcXA4OexZWCnfsEi3wo2buag+Is5oqpuHzOtI64JpHk0Xi5RQ==", + "dependencies": { + "passport-oauth2": "^1.1.2" + } + }, + "node_modules/passport-jwt": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz", + "integrity": "sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==", + "dependencies": { + "jsonwebtoken": "^9.0.0", + "passport-strategy": "^1.0.0" + } + }, + "node_modules/passport-local": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-local/-/passport-local-1.0.0.tgz", + "integrity": "sha512-9wCE6qKznvf9mQYYbgJ3sVOHmCWoUNMVFoZzNoznmISbhnNNPhN9xfY3sLmScHMetEJeoY7CXwfhCe7argfQow==", + "dependencies": { + "passport-strategy": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/passport-oauth2": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz", + "integrity": "sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==", + "dependencies": { + "base64url": "3.x.x", + "oauth": "0.10.x", + "passport-strategy": "1.x.x", + "uid2": "0.0.x", + "utils-merge": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -10564,6 +10919,11 @@ "node": ">=8" } }, + "node_modules/pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" + }, "node_modules/peek-readable": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-5.0.0.tgz", @@ -12625,6 +12985,11 @@ "node": ">=8" } }, + "node_modules/uid2": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz", + "integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==" + }, "node_modules/universalify": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", @@ -15648,6 +16013,12 @@ "integrity": "sha512-JyIOY1OE4x2t8Z2ImWLQJnHVqLCBHJZXm6RqcOG3+4rIsqMQxm5smHbb7H5C6tLAdDcRYd/fG2gYK1J1GiqKLw==", "requires": {} }, + "@nestjs/passport": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-10.0.3.tgz", + "integrity": "sha512-znJ9Y4S8ZDVY+j4doWAJ8EuuVO7SkQN3yOBmzxbGaXbvcSwFDAdGJ+OMCg52NdzIO4tQoN4pYKx8W6M0ArfFRQ==", + "requires": {} + }, "@nestjs/platform-express": { "version": "10.0.2", "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.0.2.tgz", @@ -15711,6 +16082,104 @@ "md5": "^2.2.1" } }, + "@ngrok/ngrok": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok/-/ngrok-1.2.0.tgz", + "integrity": "sha512-KOM7lHTKzsQ8IQewgigOGynJKHQRt3z0lHyEgK63H2wq9tI47tWlxWYmrQoqWLcvPgP2JA24nXWeMf/ZE2BaZg==", + "requires": { + "@ngrok/ngrok-android-arm-eabi": "1.2.0", + "@ngrok/ngrok-android-arm64": "1.2.0", + "@ngrok/ngrok-darwin-arm64": "1.2.0", + "@ngrok/ngrok-darwin-universal": "1.2.0", + "@ngrok/ngrok-darwin-x64": "1.2.0", + "@ngrok/ngrok-freebsd-x64": "1.2.0", + "@ngrok/ngrok-linux-arm-gnueabihf": "1.2.0", + "@ngrok/ngrok-linux-arm64-gnu": "1.2.0", + "@ngrok/ngrok-linux-arm64-musl": "1.2.0", + "@ngrok/ngrok-linux-x64-gnu": "1.2.0", + "@ngrok/ngrok-linux-x64-musl": "1.2.0", + "@ngrok/ngrok-win32-ia32-msvc": "1.2.0", + "@ngrok/ngrok-win32-x64-msvc": "1.2.0" + } + }, + "@ngrok/ngrok-android-arm-eabi": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-android-arm-eabi/-/ngrok-android-arm-eabi-1.2.0.tgz", + "integrity": "sha512-eGSZ443VQDfLLCef7E2GDX8oRYp+Fahr7nGeNY7nHu6bMEiYA92VpFOlpO9iF6HsyK/94NJiN3CbLJ7aMUHmJA==", + "optional": true + }, + "@ngrok/ngrok-android-arm64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-android-arm64/-/ngrok-android-arm64-1.2.0.tgz", + "integrity": "sha512-nwGcgK4OkFKNxEVu4Q4R8LR15OUvfESJIQ3gS5frMwa0r/FdHKNc5rmUfGSFhGGg+4ttiTzlEKMHW2tEp12FYQ==", + "optional": true + }, + "@ngrok/ngrok-darwin-arm64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-darwin-arm64/-/ngrok-darwin-arm64-1.2.0.tgz", + "integrity": "sha512-Df0U42wYty4UnYB9Bm+bo3akIVe0lOX/ejDSCrPABK0Cf65184ZcqUu9kBabXQRag5rTq4uG7TzCGhaMXV44RQ==", + "optional": true + }, + "@ngrok/ngrok-darwin-universal": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-darwin-universal/-/ngrok-darwin-universal-1.2.0.tgz", + "integrity": "sha512-z5srpqZjQKomLBsY/b2Oc44V4BqIqE7Tn3YdsN3ucZg4yH9aFTHLiD2Ioubb/8u48mRvwsT9FqFTGlswWA8dew==", + "optional": true + }, + "@ngrok/ngrok-darwin-x64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-darwin-x64/-/ngrok-darwin-x64-1.2.0.tgz", + "integrity": "sha512-EkH2qFoXyaZkLn2Nw5NvK1F6T3332xzTVXeJMF/oLme2K567rRHmFUfN9mUORm1snHO+Ct/uW9Tciz6+hlyTLg==", + "optional": true + }, + "@ngrok/ngrok-freebsd-x64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-freebsd-x64/-/ngrok-freebsd-x64-1.2.0.tgz", + "integrity": "sha512-mwD1QBKsWV7J8IGYCsLvPvk6yoMpwlNsCt7d/nkrw2AQvx0UJgZw+q3YW93Ii7r392DtvmzDDO3jxlx/Gl0mXQ==", + "optional": true + }, + "@ngrok/ngrok-linux-arm-gnueabihf": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-linux-arm-gnueabihf/-/ngrok-linux-arm-gnueabihf-1.2.0.tgz", + "integrity": "sha512-yvDEgCSL/EAAHV5EuAjULKZ8/P/i5UCN31oyp8x3hPIpTl2Lq9GfPfoAIvpM8segsbkXwlFPIA7FjMbNweQa2A==", + "optional": true + }, + "@ngrok/ngrok-linux-arm64-gnu": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-linux-arm64-gnu/-/ngrok-linux-arm64-gnu-1.2.0.tgz", + "integrity": "sha512-fEVMO7OiLTHWRDBSCvXtLhZ8C9l9aCUBduYXqCtIRx0unczEX7/jpAAD0rq32eioNfywj30s0RZRusYdrPbDTQ==", + "optional": true + }, + "@ngrok/ngrok-linux-arm64-musl": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-linux-arm64-musl/-/ngrok-linux-arm64-musl-1.2.0.tgz", + "integrity": "sha512-3P6YRcID3A8J+uOEf6JIUFbEnaS4SM2dDxqYgjkoo5Qjjn/V3tZdMpbGzs/R6ly9eZ8vPQNI6A4mtHGmfHjaTA==", + "optional": true + }, + "@ngrok/ngrok-linux-x64-gnu": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-linux-x64-gnu/-/ngrok-linux-x64-gnu-1.2.0.tgz", + "integrity": "sha512-geiZ4rTzTySIbLyxEkIV7aPdJ2hr3cKIGv1nN7uFzJPHPwxwRYLctyXHOrjbNLaKUcoEzuyqHVhu2/qUL9FZDg==", + "optional": true + }, + "@ngrok/ngrok-linux-x64-musl": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-linux-x64-musl/-/ngrok-linux-x64-musl-1.2.0.tgz", + "integrity": "sha512-s4nfAUuCM2X4T+Z9RWzD3NbhEnzeTrpsQq51RNQ9JEjN6guab3FdT/Bn9nXkULJdZOPvqguHg878KmTwi8t3mA==", + "optional": true + }, + "@ngrok/ngrok-win32-ia32-msvc": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-win32-ia32-msvc/-/ngrok-win32-ia32-msvc-1.2.0.tgz", + "integrity": "sha512-R4ovlTz22AuP7trn8CnzXqDrxFuStpx2657AMDSop/3yhS1hCxTqycRSQwXqhJisV5midaJY/e7YwNBmYnbUIA==", + "optional": true + }, + "@ngrok/ngrok-win32-x64-msvc": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-win32-x64-msvc/-/ngrok-win32-x64-msvc-1.2.0.tgz", + "integrity": "sha512-A5sIaZ3pXLWZO5ft0o4oyNWg6itCAaZ4Mln8Y/sMsx4AjAcaHqBod1J2We2jt5Et52sr5JRaDwBokz84ZUNi6A==", + "optional": true + }, "@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -16310,6 +16779,44 @@ "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==", "dev": true }, + "@types/passport": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.16.tgz", + "integrity": "sha512-FD0qD5hbPWQzaM0wHUnJ/T0BBCJBxCeemtnCwc/ThhTg3x9jfrAcRUmj5Dopza+MfFS9acTe3wk7rcVnRIp/0A==", + "dev": true, + "requires": { + "@types/express": "*" + } + }, + "@types/passport-google-oauth2": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@types/passport-google-oauth2/-/passport-google-oauth2-0.1.8.tgz", + "integrity": "sha512-0lgGOVbGNTw7NcmrPh3pRwtGJXf1XZG7FZkYtM5DuQ8HJjHzi5mMIq28x7v/q5NNDIrFvhjE0CnyTv7hn4DIjg==", + "dev": true, + "requires": { + "@types/express": "*" + } + }, + "@types/passport-jwt": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-4.0.1.tgz", + "integrity": "sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==", + "dev": true, + "requires": { + "@types/jsonwebtoken": "*", + "@types/passport-strategy": "*" + } + }, + "@types/passport-strategy": { + "version": "0.2.38", + "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz", + "integrity": "sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==", + "dev": true, + "requires": { + "@types/express": "*", + "@types/passport": "*" + } + }, "@types/prettier": { "version": "2.7.3", "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.3.tgz", @@ -17007,6 +17514,11 @@ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", "dev": true }, + "base64url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==" + }, "basic-auth": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", @@ -20647,6 +21159,11 @@ "set-blocking": "^2.0.0" } }, + "oauth": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.0.tgz", + "integrity": "sha512-1orQ9MT1vHFGQxhuy7E/0gECD3fd2fCC+PIX+/jgmU/gI3EpRocXtmtvxCO5x3WZ443FLTLFWNDjl5MPJf9u+Q==" + }, "object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -20805,6 +21322,58 @@ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" }, + "passport": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", + "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", + "requires": { + "passport-strategy": "1.x.x", + "pause": "0.0.1", + "utils-merge": "^1.0.1" + } + }, + "passport-google-oauth2": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/passport-google-oauth2/-/passport-google-oauth2-0.2.0.tgz", + "integrity": "sha512-62EdPtbfVdc55nIXi0p1WOa/fFMM8v/M8uQGnbcXA4OexZWCnfsEi3wo2buag+Is5oqpuHzOtI64JpHk0Xi5RQ==", + "requires": { + "passport-oauth2": "^1.1.2" + } + }, + "passport-jwt": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz", + "integrity": "sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==", + "requires": { + "jsonwebtoken": "^9.0.0", + "passport-strategy": "^1.0.0" + } + }, + "passport-local": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-local/-/passport-local-1.0.0.tgz", + "integrity": "sha512-9wCE6qKznvf9mQYYbgJ3sVOHmCWoUNMVFoZzNoznmISbhnNNPhN9xfY3sLmScHMetEJeoY7CXwfhCe7argfQow==", + "requires": { + "passport-strategy": "1.x.x" + } + }, + "passport-oauth2": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz", + "integrity": "sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==", + "requires": { + "base64url": "3.x.x", + "oauth": "0.10.x", + "passport-strategy": "1.x.x", + "uid2": "0.0.x", + "utils-merge": "1.x.x" + } + }, + "passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==" + }, "path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -20854,6 +21423,11 @@ "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", "dev": true }, + "pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" + }, "peek-readable": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-5.0.0.tgz", @@ -22328,6 +22902,11 @@ "@lukeed/csprng": "^1.0.0" } }, + "uid2": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz", + "integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==" + }, "universalify": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", diff --git a/package.json b/package.json index ddde6ce..8885862 100644 --- a/package.json +++ b/package.json @@ -43,11 +43,13 @@ "@nestjs/common": "10.0.2", "@nestjs/config": "3.0.0", "@nestjs/core": "10.0.2", - "@nestjs/jwt": "10.1.0", + "@nestjs/jwt": "^10.1.0", + "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "10.0.2", "@nestjs/swagger": "7.0.2", "@nestjs/terminus": "10.0.1", "@nestjs/throttler": "4.1.0", + "@ngrok/ngrok": "^1.2.0", "@nodeteam/nestjs-pipes": "1.2.5", "@nodeteam/nestjs-prisma-pagination": "1.0.6", "@prisma/client": "4.15.0", @@ -62,6 +64,10 @@ "express-basic-auth": "1.2.1", "jest-mock-extended": "3.0.5", "module-alias": "2.2.3", + "passport": "^0.7.0", + "passport-google-oauth2": "^0.2.0", + "passport-jwt": "^4.0.1", + "passport-local": "^1.0.0", "reflect-metadata": "0.1.13", "rxjs": "7.8.1", "sqs-consumer": "7.2.0", @@ -76,6 +82,8 @@ "@types/express": "4.17.17", "@types/jest": "29.5.2", "@types/node": "20.3.1", + "@types/passport-google-oauth2": "^0.1.8", + "@types/passport-jwt": "^4.0.1", "@types/supertest": "2.0.12", "@typescript-eslint/eslint-plugin": "5.60.0", "@typescript-eslint/parser": "5.60.0", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 445c74a..a622968 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -8,26 +8,46 @@ datasource db { } model User { - id String @id @default(auto()) @map("_id") @db.ObjectId - email String @unique - phone String? - firstName String? - lastName String? - password String - roles Roles[] @default([customer]) - createdAt DateTime @default(now()) - updatedAt DateTime @default(now()) + id String @id @default(auto()) @map("_id") @db.ObjectId + email String @unique + phone String? + firstName String? + lastName String? + password String + roles Roles[] @default([customer]) + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) + socialAccount SocialAccount[] +} + +model SocialAccount { + id String @id @default(auto()) @map("_id") @db.ObjectId + userId String @db.ObjectId + user User @relation(fields: [userId], references: [id]) + provider String + email String? + name String? + picture String? + providerId String + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) } model TokenWhiteList { - id String @id @default(auto()) @map("_id") @db.ObjectId - userId String - accessToken String? - refreshToken String? + id String @id @default(auto()) @map("_id") @db.ObjectId + userId String + accessToken String? + refreshToken String? refreshTokenId String? - expiredAt DateTime - createdAt DateTime @default(now()) - updatedAt DateTime @default(now()) + expiredAt DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) +} + +enum SocialProviders { + google + facebook + twitter } enum Roles { diff --git a/src/config/app.config.ts b/src/config/app.config.ts index bfe56da..9703363 100644 --- a/src/config/app.config.ts +++ b/src/config/app.config.ts @@ -1,5 +1,7 @@ import { registerAs } from '@nestjs/config'; import * as path from 'path'; +import { NgrokConfig } from '@config/ngrok.config'; +import * as process from 'node:process'; function parseLogLevel(level: string | undefined): string[] { if (!level) { @@ -13,13 +15,24 @@ function parseLogLevel(level: string | undefined): string[] { return level.split(','); } -export default registerAs('app', () => ({ - port: process.env.APP_PORT || 3000, - baseUrl: process.env.BASE_URL || 'http://localhost:3000', - loggerLevel: parseLogLevel( - process.env.APP_LOGGER_LEVEL || 'log,error,warn,debug,verbose', - ), - env: process.env.NODE_ENV || 'dev', - // eslint-disable-next-line global-require,@typescript-eslint/no-var-requires - version: require(path.join(process.cwd(), 'package.json')).version, -})); +export type AppConfig = { + readonly port: number; + readonly baseUrl: string; + readonly loggerLevel: string[]; + readonly env: string; + readonly version: string; +}; + +export default registerAs( + 'app', + (): AppConfig => ({ + port: Number(process.env.APP_PORT) || 3000, + baseUrl: process.env.BASE_URL || 'http://localhost:3000', + loggerLevel: parseLogLevel( + process.env.APP_LOGGER_LEVEL || 'log,error,warn,debug,verbose', + ), + env: process.env.NODE_ENV || 'dev', + // eslint-disable-next-line global-require,@typescript-eslint/no-var-requires + version: require(path.join(process.cwd(), 'package.json')).version, + }), +); diff --git a/src/config/ngrok.config.ts b/src/config/ngrok.config.ts new file mode 100644 index 0000000..01e5575 --- /dev/null +++ b/src/config/ngrok.config.ts @@ -0,0 +1,16 @@ +import { registerAs } from '@nestjs/config'; + +export type NgrokConfig = { + readonly domain: string; + readonly authToken: string; + readonly isEnable: string | undefined | boolean; +}; + +export default registerAs( + 'ngrok', + (): NgrokConfig => ({ + domain: process.env.NGROK_DOMAIN!, + authToken: process.env.NGROK_AUTHTOKEN!, + isEnable: process.env.NGROK_ENABLE, + }), +); diff --git a/src/config/social.config.ts b/src/config/social.config.ts new file mode 100644 index 0000000..f1c6d87 --- /dev/null +++ b/src/config/social.config.ts @@ -0,0 +1,19 @@ +import { registerAs } from '@nestjs/config'; +import * as process from 'node:process'; + +export type SocialConfig = { + readonly google: { + readonly clientId: string; + readonly secret: string; + }; +}; + +export default registerAs( + 'social', + (): SocialConfig => ({ + google: { + clientId: process.env.GOOGLE_CLIENT_ID! || '...', + secret: process.env.GOOGLE_CLIENT_SECRET! || '...', + }, + }), +); diff --git a/src/constants/errors.constants.ts b/src/constants/errors.constants.ts index ab724b5..aedfd83 100644 --- a/src/constants/errors.constants.ts +++ b/src/constants/errors.constants.ts @@ -31,6 +31,7 @@ export const PHOTO_NOT_FOUND = '404029: Photo not found'; export const REGION_NOT_FOUND = '404030: Region not found'; export const PERMISSIONS_NOT_FOUND = '404031: Permissions not found'; export const INVITE_IS_INVALID = '404032: Invite is invalid'; +export const SOCIAL_ACCOUNT_NOT_FOUND = '404033: Social account not found'; export const UNAUTHORIZED_RESOURCE = '401000: Unauthorized resource'; export const INVALID_CREDENTIALS = '401001: Invalid credentials'; @@ -125,3 +126,5 @@ export const RATE_LIMIT_EXCEEDED = '429000: Rate limit exceeded'; export const VALIDATION_ERROR = '422000: Validation error'; export const INTERNAL_SERVER_ERROR = '500000: Internal server error'; +export const OAUTH_INVALID_RESPONSE = '500001: OAuth invalid response'; +export const OAUTH_INVALID_STATE = '500002: OAuth invalid state'; diff --git a/src/filters/oauth2.exception.filter.ts b/src/filters/oauth2.exception.filter.ts new file mode 100644 index 0000000..50f483f --- /dev/null +++ b/src/filters/oauth2.exception.filter.ts @@ -0,0 +1,13 @@ +import { ExceptionFilter, Catch, ArgumentsHost } from '@nestjs/common'; +import { Response } from 'express'; +import { Oauth2Exception } from '@filters/oauth2.exception'; + +@Catch(Oauth2Exception) +export class Oauth2ExceptionFilter implements ExceptionFilter { + catch(exception: Oauth2Exception, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + + response.send(exception.message); + } +} diff --git a/src/filters/oauth2.exception.ts b/src/filters/oauth2.exception.ts new file mode 100644 index 0000000..0db182f --- /dev/null +++ b/src/filters/oauth2.exception.ts @@ -0,0 +1,8 @@ +export class Oauth2Exception extends Error { + message; + + constructor(message: any) { + super(); + this.message = message; + } +} diff --git a/src/main.ts b/src/main.ts index 3bfaed1..0ea9034 100644 --- a/src/main.ts +++ b/src/main.ts @@ -20,8 +20,14 @@ import { ThrottlerExceptionsFilter } from '@filters/throttler-exception.filter'; import { TransformInterceptor } from '@interceptors/transform.interceptor'; import { AccessExceptionFilter } from '@filters/access-exception.filter'; import { NotFoundExceptionFilter } from '@filters/not-found-exception.filter'; - -async function bootstrap(): Promise<{ port: number }> { +import { NgrokConfig } from '@config/ngrok.config'; +import { Listener } from '@ngrok/ngrok'; +import { AppConfig } from '@config/app.config'; + +async function bootstrap(): Promise<{ + appConfig: AppConfig; + ngrokConfig: NgrokConfig; +}> { /** * Create NestJS application */ @@ -43,6 +49,23 @@ async function bootstrap(): Promise<{ port: number }> { app.useLogger(options); } + { + /** + * Enable CORS + */ + + // TODO: we need to change it on stag and prod + const options = { + origin: '*', + methods: 'GET,HEAD,PUT,PATCH,POST,DELETE', + preflightContinue: false, + optionsSuccessStatus: 204, + credentials: true, + }; + + app.enableCors(options); + } + { /** * ValidationPipe options @@ -141,9 +164,29 @@ async function bootstrap(): Promise<{ port: number }> { await app.listen(appConfig.port); - return appConfig; + return { + appConfig, + ngrokConfig: configService.get('ngrok') as NgrokConfig, + }; } +bootstrap().then(async ({ appConfig, ngrokConfig }): Promise => { + if ( + appConfig.env === 'development' && + ngrokConfig.domain && + ngrokConfig.isEnable === 'true' + ) { + const ngrok = await import('@ngrok/ngrok'); + + const listener: Listener = await ngrok.forward({ + port: appConfig.port, + domain: ngrokConfig.domain, + authtoken: ngrokConfig.authToken, + }); -bootstrap().then((appConfig) => { - Logger.log(`Running in http://localhost:${appConfig.port}`, 'Bootstrap'); + Logger.log(`Ngrok ingress established at: ${listener.url()}`, 'Ngrok'); + Logger.log(`Docs at: ${listener.url()}/docs`, 'Swagger'); + } else { + Logger.log(`Running at ${appConfig.baseUrl}`, 'Bootstrap'); + Logger.log(`Docs at ${appConfig.baseUrl}/docs`, 'Swagger'); + } }); diff --git a/src/modules/app/app.module.ts b/src/modules/app/app.module.ts index 94dd6e0..803facf 100644 --- a/src/modules/app/app.module.ts +++ b/src/modules/app/app.module.ts @@ -11,19 +11,31 @@ import jwtConfig from '@config/jwt.config'; import { CaslModule } from '@modules/casl'; import { Roles } from '@modules/app/app.roles'; import { APP_GUARD } from '@nestjs/core'; -import { AuthGuard } from '@modules/auth/auth.guard'; +import { AuthGuard } from '@modules/auth/guards/auth.guard'; import { JwtModule, JwtService } from '@nestjs/jwt'; import s3Config from '@config/s3.config'; import sqsConfig from '@config/sqs.config'; import { TokenService } from '@modules/auth/token.service'; import { TokenRepository } from '@modules/auth/token.repository'; +import ngrokConfig from '@config/ngrok.config'; +import { GoogleStrategy } from '@modules/auth/strategies/google.strategy'; +import socialConfig from '@config/social.config'; +import { GoogleGuard } from '@modules/auth/guards/google.guard'; @Module({ controllers: [], imports: [ ConfigModule.forRoot({ isGlobal: true, - load: [appConfig, swaggerConfig, jwtConfig, s3Config, sqsConfig], + load: [ + appConfig, + swaggerConfig, + jwtConfig, + s3Config, + sqsConfig, + ngrokConfig, + socialConfig, + ], }), PrismaModule.forRoot({ isGlobal: true, @@ -50,6 +62,10 @@ import { TokenRepository } from '@modules/auth/token.repository'; provide: APP_GUARD, useClass: AuthGuard, }, + { + provide: 'GoogleGuard', + useClass: GoogleGuard, + }, ], }) export class AppModule {} diff --git a/src/modules/auth/auth-social.service.ts b/src/modules/auth/auth-social.service.ts new file mode 100644 index 0000000..73d2e2e --- /dev/null +++ b/src/modules/auth/auth-social.service.ts @@ -0,0 +1,64 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { UserService } from '@modules/user/user.service'; +import { SocialRepository } from '@modules/auth/social.repository'; +import { TokenService } from '@modules/auth/token.service'; +import { ConfigService } from '@nestjs/config'; +import { AuthService } from '@modules/auth/auth.service'; +import { SOCIAL_ACCOUNT_NOT_FOUND } from '@constants/errors.constants'; +import AccessRefreshTokens = Auth.AccessRefreshTokens; +import { ISocialUser } from '@modules/auth/interfaces/social-user.interface'; + +@Injectable() +export class AuthSocialService { + constructor( + private readonly configService: ConfigService, + private readonly tokenService: TokenService, + private readonly userService: UserService, + private readonly socialRepository: SocialRepository, + private readonly authService: AuthService, + ) {} + + async signUpAGoogle( + socialUserData: ISocialUser, + ): Promise { + const socialUser = await this.socialRepository.getSocialAccountByProviderId( + socialUserData.providerId, + ); + + if (socialUser) { + return this.tokenService.sign({ id: socialUser.userId }); + } + + const user = await this.authService.singUp({ + email: socialUserData.email, + firstName: socialUserData.name, + password: '', + lastName: '', + }); + + await this.socialRepository.create({ + provider: 'google', + providerId: socialUserData.providerId, + name: socialUserData.name, + user: { + connect: { + id: user.id, + }, + }, + }); + + return this.tokenService.sign({ id: user.id }); + } + + async signIn(user: ISocialUser): Promise { + const socialUser = await this.socialRepository.getSocialAccountByProviderId( + user.providerId, + ); + + if (!socialUser) { + throw new NotFoundException(SOCIAL_ACCOUNT_NOT_FOUND); + } + + return this.tokenService.sign({ id: socialUser.userId }); + } +} diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index 173ab69..9feb0e5 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -7,7 +7,7 @@ import { User } from '@prisma/client'; import Serialize from '@decorators/serialize.decorator'; import UserBaseEntity from '@modules/user/entities/user-base.entity'; import { SignInDto } from '@modules/auth/dto/sign-in.dto'; -import { SkipAuth } from '@modules/auth/skip-auth.guard'; +import { SkipAuth } from '@modules/auth/guards/skip-auth.guard'; import RefreshTokenDto from '@modules/auth/dto/refresh-token.dto'; import { AccessGuard, diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index c172b50..c6827c4 100644 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -6,10 +6,24 @@ import { TokenService } from '@modules/auth/token.service'; import { TokenRepository } from '@modules/auth/token.repository'; import { CaslModule } from '@modules/casl'; import { permissions } from '@modules/auth/auth.permissions'; +import { GoogleController } from '@modules/auth/controllers/google.controller'; +import { AuthSocialService } from '@modules/auth/auth-social.service'; +import { GoogleStrategy } from '@modules/auth/strategies/google.strategy'; +import { UserService } from '@modules/user/user.service'; +import { SocialRepository } from '@modules/auth/social.repository'; @Module({ imports: [CaslModule.forFeature({ permissions })], - controllers: [AuthController], - providers: [AuthService, TokenService, UserRepository, TokenRepository], + controllers: [AuthController, GoogleController], + providers: [ + GoogleStrategy, + AuthService, + TokenService, + UserRepository, + TokenRepository, + AuthSocialService, + UserService, + SocialRepository, + ], }) export class AuthModule {} diff --git a/src/modules/auth/controllers/google.controller.ts b/src/modules/auth/controllers/google.controller.ts new file mode 100644 index 0000000..9003371 --- /dev/null +++ b/src/modules/auth/controllers/google.controller.ts @@ -0,0 +1,39 @@ +import { Controller, Query, Request } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { ISocialUser } from '@modules/auth/interfaces/social-user.interface'; +import { SocialRedirect } from '@modules/auth/decorators/social-redirect.decorator'; +import { GoogleOAuth2 } from '@modules/auth/decorators/google-oauth2.decorator'; +import { AuthSocialService } from '@modules/auth/auth-social.service'; +import { SkipAuth } from '@modules/auth/guards/skip-auth.guard'; + +@ApiTags('Auth-Social-Google') +@Controller('auth-google') +export class GoogleController { + constructor(protected readonly authSocialService: AuthSocialService) {} + + @SkipAuth() + @GoogleOAuth2('sign-in', 'Sign-in user.') + signIn() { + return 'OK'; + } + + @SkipAuth() + @GoogleOAuth2('sign-up', 'Sign-up user.') + signUp() { + return 'OK'; + } + + @SkipAuth() + @SocialRedirect('sign-in') + @GoogleOAuth2() + signInRedirect(@Request() { user }: { user: ISocialUser }) { + return this.authSocialService.signIn(user); + } + + @SkipAuth() + @SocialRedirect('sign-up') + @GoogleOAuth2() + signUpRedirect(@Request() { user }: { user: ISocialUser }) { + return this.authSocialService.signUpAGoogle(user); + } +} diff --git a/src/modules/auth/decorators/google-oauth2.decorator.ts b/src/modules/auth/decorators/google-oauth2.decorator.ts new file mode 100644 index 0000000..ccf3f2d --- /dev/null +++ b/src/modules/auth/decorators/google-oauth2.decorator.ts @@ -0,0 +1,28 @@ +import { + UseGuards, + applyDecorators, + Get, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { ApiOkResponse, ApiOperation } from '@nestjs/swagger'; +import { GoogleGuard } from '@modules/auth/guards/google.guard'; + +export const GoogleOAuth2 = (path?: string, summary?: string) => { + const decorators = []; + + if (path) { + decorators.push(Get(path)); + } + + if (summary) { + decorators.push(ApiOperation({ summary })); + } + decorators.push( + HttpCode(HttpStatus.OK), + ApiOkResponse({ description: 'Redirect' }), + UseGuards(GoogleGuard), + ); + + return applyDecorators(...decorators); +}; diff --git a/src/modules/auth/decorators/social-redirect.decorator.ts b/src/modules/auth/decorators/social-redirect.decorator.ts new file mode 100644 index 0000000..a0e703f --- /dev/null +++ b/src/modules/auth/decorators/social-redirect.decorator.ts @@ -0,0 +1,11 @@ +import { UseGuards, applyDecorators, UseFilters, Get } from '@nestjs/common'; +import { SocialRedirectGuard } from '@modules/auth/guards/social-redirect.guard'; +import { Oauth2ExceptionFilter } from '@filters/oauth2.exception.filter'; + +export const SocialRedirect = (path: string) => { + return applyDecorators( + Get(`${path}-redirect`), + UseGuards(SocialRedirectGuard), + UseFilters(new Oauth2ExceptionFilter()), + ); +}; diff --git a/src/modules/auth/auth.guard.ts b/src/modules/auth/guards/auth.guard.ts similarity index 96% rename from src/modules/auth/auth.guard.ts rename to src/modules/auth/guards/auth.guard.ts index b45a1b3..5aea7ef 100644 --- a/src/modules/auth/auth.guard.ts +++ b/src/modules/auth/guards/auth.guard.ts @@ -8,7 +8,7 @@ import { JwtService } from '@nestjs/jwt'; import { Request } from 'express'; import { ConfigService } from '@nestjs/config'; import { Reflector } from '@nestjs/core'; -import { IS_SKIP_AUTH_KEY } from '@modules/auth/skip-auth.guard'; +import { IS_SKIP_AUTH_KEY } from '@modules/auth/guards/skip-auth.guard'; import { TokenService } from '@modules/auth/token.service'; @Injectable() diff --git a/src/modules/auth/guards/google.guard.ts b/src/modules/auth/guards/google.guard.ts new file mode 100644 index 0000000..843e7db --- /dev/null +++ b/src/modules/auth/guards/google.guard.ts @@ -0,0 +1,50 @@ +import { URL } from 'url'; +import { ExecutionContext, Injectable } from '@nestjs/common'; +import { AuthGuard, IAuthModuleOptions } from '@nestjs/passport'; +import { Reflector } from '@nestjs/core'; +import { Request } from 'express'; +import { TokenService } from '@modules/auth/token.service'; + +export function getOAuthStatePayload(request: Request) { + const { userId } = request.query; + + return { + ...(userId && { userId: parseInt((userId as string) || '', 10) }), + }; +} + +export function buildCallbackUrl(context: ExecutionContext) { + const req = context.switchToHttp().getRequest(); + const { originalUrl, port } = req; + const portSuffix = port ? `:${port}` : ''; + const callbackURL = new URL( + `https://${req.get('host')}${portSuffix}${originalUrl}`, + ); + + callbackURL.search = ''; + + return callbackURL.href.includes('redirect') + ? callbackURL.href + : `${callbackURL.href}-redirect`; +} + +@Injectable() +export class GoogleGuard extends AuthGuard('google') { + constructor( + private readonly reflector: Reflector, + private readonly tokenService: TokenService, + ) { + super(); + } + + async getAuthenticateOptions( + context: ExecutionContext, + ): Promise { + return { + state: this.tokenService.createJwtAccessToken({ + payload: getOAuthStatePayload(context.switchToHttp().getRequest()), + }), + callbackURL: buildCallbackUrl(context), + }; + } +} diff --git a/src/modules/auth/skip-auth.guard.ts b/src/modules/auth/guards/skip-auth.guard.ts similarity index 100% rename from src/modules/auth/skip-auth.guard.ts rename to src/modules/auth/guards/skip-auth.guard.ts diff --git a/src/modules/auth/guards/social-redirect.guard.ts b/src/modules/auth/guards/social-redirect.guard.ts new file mode 100644 index 0000000..81c325c --- /dev/null +++ b/src/modules/auth/guards/social-redirect.guard.ts @@ -0,0 +1,34 @@ +import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; +import { + OAUTH_INVALID_RESPONSE, + OAUTH_INVALID_STATE, +} from '@constants/errors.constants'; +import { Oauth2Exception } from '@filters/oauth2.exception'; +import { AuthService } from '@modules/auth/auth.service'; +import { TokenService } from '@modules/auth/token.service'; + +@Injectable() +export class SocialRedirectGuard implements CanActivate { + constructor( + private readonly authService: AuthService, + private readonly tokenService: TokenService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + + const { state } = request.query; + + if (!state) { + throw new Oauth2Exception({ error: OAUTH_INVALID_RESPONSE }); + } + + const decodedState = this.tokenService.decodeJwtToken(state); + + if (!decodedState) { + throw new Oauth2Exception({ error: OAUTH_INVALID_STATE }); + } + + return true; + } +} diff --git a/src/modules/auth/interceptors/social-redirect.interceptor.ts b/src/modules/auth/interceptors/social-redirect.interceptor.ts new file mode 100644 index 0000000..d338c27 --- /dev/null +++ b/src/modules/auth/interceptors/social-redirect.interceptor.ts @@ -0,0 +1,20 @@ +import { Observable } from 'rxjs'; +import { + ExecutionContext, + Injectable, + NestInterceptor, + CallHandler, +} from '@nestjs/common'; +import { map } from 'rxjs/operators'; + +@Injectable() +export class SocialRedirectInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler): Observable { + return next.handle().pipe( + map(async (auth: any) => { + const response = context.switchToHttp().getResponse(); + response.send(auth); + }), + ); + } +} diff --git a/src/modules/auth/interfaces/social-user.interface.ts b/src/modules/auth/interfaces/social-user.interface.ts new file mode 100644 index 0000000..55d9988 --- /dev/null +++ b/src/modules/auth/interfaces/social-user.interface.ts @@ -0,0 +1,14 @@ +export type SocialType = 'google' | 'facebook' | 'twitter' | 'apple'; + +export interface ISocialUser { + type: SocialType; + providerId: string; + email?: string; + emailVerified: boolean; + name?: string; + tokens?: { + accessToken: string; + refreshToken?: string; + tokenSecret?: string; + }; +} diff --git a/src/modules/auth/social.repository.ts b/src/modules/auth/social.repository.ts new file mode 100644 index 0000000..61e8dc4 --- /dev/null +++ b/src/modules/auth/social.repository.ts @@ -0,0 +1,83 @@ +import { PrismaService } from '@providers/prisma'; +import { Injectable } from '@nestjs/common'; +import { paginator } from '@nodeteam/nestjs-prisma-pagination'; +import { PaginatorTypes } from '@nodeteam/nestjs-prisma-pagination'; +import { Prisma, SocialAccount } from '@prisma/client'; + +@Injectable() +export class SocialRepository { + private readonly paginate: PaginatorTypes.PaginateFunction; + + constructor(private prisma: PrismaService) { + /** + * @desc Create a paginate function + * @param model + * @param options + * @returns Promise> + */ + this.paginate = paginator({ + page: 1, + perPage: 10, + }); + } + + findById(id: string): Promise { + return this.prisma.socialAccount.findUnique({ + where: { id }, + }); + } + + /** + * @desc Find a social account + * @param params Prisma.SocialAccountFindFirstArgs + * @returns Promise + * If the social account is not found, return null + */ + async findOne( + params: Prisma.SocialAccountFindFirstArgs, + ): Promise { + return this.prisma.socialAccount.findFirst(params); + } + + /** + * @desc Create a new social account + * @param data Prisma.SocialAccountCreateInput + * @returns Promise + */ + async create(data: Prisma.SocialAccountCreateInput): Promise { + return this.prisma.socialAccount.create({ + data, + }); + } + + /** + * @desc find all social accounts + * @param where Prisma.SocialAccountWhereInput + * @param orderBy Prisma.SocialAccountOrderByWithRelationInput + * @returns Promise> + */ + async findAll( + where: Prisma.SocialAccountWhereInput, + orderBy: Prisma.SocialAccountOrderByWithRelationInput, + ): Promise> { + return this.paginate(this.prisma.socialAccount, { + where, + orderBy, + }); + } + + /** + * @desc Get social account by provider id + * @param providerId Prisma.SocialAccountWhereUniqueInput + * @returns Promise + */ + async getSocialAccountByProviderId( + providerId: string, + ): Promise { + return this.prisma.socialAccount.findFirst({ + where: { + providerId, + }, + }); + } +} diff --git a/src/modules/auth/strategies/google.strategy.ts b/src/modules/auth/strategies/google.strategy.ts new file mode 100644 index 0000000..3c99c83 --- /dev/null +++ b/src/modules/auth/strategies/google.strategy.ts @@ -0,0 +1,33 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PassportStrategy } from '@nestjs/passport'; +import { Strategy, VerifyCallback } from 'passport-google-oauth2'; + +@Injectable() +export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { + constructor(private readonly configService: ConfigService) { + super({ + clientID: configService.get('social.google.clientId'), + clientSecret: configService.get('social.google.secret'), + scope: ['profile', 'email'], + }); + } + + async validate( + _accessToken: string, + _refreshToken: string, + profile: any, + done: VerifyCallback, + ): Promise { + const { id, name, emails, photos } = profile; + + const user = { + provider: 'google', + providerId: id, + email: emails[0].value, + name: `${name.givenName} ${name.familyName}`, + picture: photos[0].value, + }; + done(null, user); + } +} diff --git a/src/modules/auth/token.service.ts b/src/modules/auth/token.service.ts index f8c1904..4660a63 100644 --- a/src/modules/auth/token.service.ts +++ b/src/modules/auth/token.service.ts @@ -129,4 +129,8 @@ export class TokenService { secret: this.configService.get('jwt.refreshToken'), }); } + + decodeJwtToken(token: string): string | object { + return this.jwtService.decode(token); + } } diff --git a/src/modules/casl/access.guard.ts b/src/modules/casl/access.guard.ts index f6ccc49..86d8a96 100644 --- a/src/modules/casl/access.guard.ts +++ b/src/modules/casl/access.guard.ts @@ -1,14 +1,14 @@ import { CanActivate, Injectable, ExecutionContext } from '@nestjs/common'; import { ModuleRef, Reflector } from '@nestjs/core'; -import { AccessService } from './access.service'; -import { CaslConfig } from './casl.config'; -import { CASL_META_ABILITY } from './casl.constants'; -import { AbilityMetadata } from './interfaces/ability-metadata.interface'; -import { subjectHookFactory } from './factories/subject-hook.factory'; -import { userHookFactory } from './factories/user-hook.factory'; -import { RequestProxy } from './proxies/request.proxy'; -import { ContextProxy } from './proxies/context.proxy'; +import { AccessService } from '@modules/casl/access.service'; +import { CaslConfig } from '@modules/casl/casl.config'; +import { CASL_META_ABILITY } from '@modules/casl/casl.constants'; +import { AbilityMetadata } from '@modules/casl/interfaces/ability-metadata.interface'; +import { subjectHookFactory } from '@modules/casl/factories/subject-hook.factory'; +import { userHookFactory } from '@modules/casl/factories/user-hook.factory'; +import { RequestProxy } from '@modules/casl/proxies/request.proxy'; +import { ContextProxy } from '@modules/casl/proxies/context.proxy'; @Injectable() export class AccessGuard implements CanActivate { diff --git a/src/modules/health/health.controller.ts b/src/modules/health/health.controller.ts index 3f82ad2..4396101 100644 --- a/src/modules/health/health.controller.ts +++ b/src/modules/health/health.controller.ts @@ -6,7 +6,7 @@ import { DiskHealthIndicator, HealthCheckResult, } from '@nestjs/terminus'; -import { SkipAuth } from '@modules/auth/skip-auth.guard'; +import { SkipAuth } from '@modules/auth/guards/skip-auth.guard'; @Controller('health') export default class HealthController {