From dd486229c49e529e453759fc42d5dc65164099c6 Mon Sep 17 00:00:00 2001 From: M1000 Date: Fri, 6 Dec 2024 01:00:32 +0100 Subject: [PATCH] feat: implement oauth2 --- .env.example | 13 ++- package.json | 6 +- prisma/schema.prisma | 11 +-- src/app.module.ts | 2 +- src/config/env.ts | 12 +-- src/interfaces/oauth2.ts | 6 ++ src/modules/auth/auth.controller.ts | 46 ++++++----- src/modules/auth/auth.module.ts | 4 +- src/modules/auth/guards/discord.guard.ts | 13 --- src/modules/auth/guards/oauth2.guard.ts | 5 ++ src/modules/auth/strategy/discord.strategy.ts | 40 ---------- src/modules/auth/strategy/oauth2strategy.ts | 79 +++++++++++++++++++ src/modules/user/dto/bulk-delete-user.dto.ts | 2 + src/modules/user/dto/create-user.dto.ts | 4 + src/modules/user/user.service.ts | 9 ++- src/validations/env.validation.ts | 8 +- tsconfig.json | 4 +- yarn.lock | 40 +++++----- 18 files changed, 181 insertions(+), 123 deletions(-) create mode 100644 src/interfaces/oauth2.ts delete mode 100644 src/modules/auth/guards/discord.guard.ts create mode 100644 src/modules/auth/guards/oauth2.guard.ts delete mode 100644 src/modules/auth/strategy/discord.strategy.ts create mode 100644 src/modules/auth/strategy/oauth2strategy.ts diff --git a/.env.example b/.env.example index 03df36a..6553f30 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,13 @@ JWT_EXPIRES_IN=1h REFRESH_JWT_SECRET= REFRESH_JWT_EXPIRES_IN=7d -DISCORD_CLIENT_ID= -DISCORD_CLIENT_SECRET= -DISCORD_CALLBACK_URL=http://localhost:3000/auth/discord/callback \ No newline at end of file +OAUTH2_CLIENT_ID= +OAUTH2_CLIENT_SECRET= +OAUTH2_TOKEN_URL= +OAUTH2_PROFILE_URL= +OAUTH2_AUTHORIZATION_URL= +OAUTH2_SCOPES=openid email profile + +OAUTH2_CALLBACK_URL=http://localhost:3000/auth/callback + +PORT=3000 \ No newline at end of file diff --git a/package.json b/package.json index 4a16506..55613f7 100644 --- a/package.json +++ b/package.json @@ -37,8 +37,8 @@ "joi": "^17.13.3", "nestjs-prisma": "^0.23.0", "passport": "^0.7.0", - "passport-discord": "^0.1.4", "passport-jwt": "^4.0.1", + "passport-oauth2": "^1.8.0", "prisma": "^6.0.1", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", @@ -49,8 +49,8 @@ "@nestjs/schematics": "^10.0.0", "@types/express": "^5.0.0", "@types/node": "^20.3.1", - "@types/passport-discord": "^0.1.14", "@types/passport-jwt": "^4.0.1", + "@types/passport-oauth2": "^1.4.17", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "eslint": "^9.0.0", @@ -67,4 +67,4 @@ "prisma": { "seed": "ts-node prisma/seed.ts" } -} \ No newline at end of file +} diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6c89d78..1ed81da 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -8,10 +8,11 @@ datasource db { } model User { - id String @id @default(cuid()) - username String? @unique - role Role @default(STUDENT) - createdAt DateTime @default(now()) + id String @id @default(cuid()) + username String? @unique + role Role @default(STUDENT) + createdAt DateTime @default(now()) + providerId String @unique Class Class[] SentMessages UserMessage[] @relation("SentMessages") @@ -95,4 +96,4 @@ model RoomSurveyAnswerUser { enum Role { STUDENT ADMIN -} +} \ No newline at end of file diff --git a/src/app.module.ts b/src/app.module.ts index de60341..34a559c 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -27,7 +27,7 @@ import { AppController } from "./app.controller"; }), }), PrismaModule.forRoot({ - isGlobal: true + isGlobal: true, }), UserModule, AuthModule, diff --git a/src/config/env.ts b/src/config/env.ts index cdac288..9fec3e3 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -8,10 +8,12 @@ export default () => ({ }, }, oauth2: { - discord: { - clientId: process.env.DISCORD_CLIENT_ID, - clientSecret: process.env.DISCORD_CLIENT_SECRET, - callbackUrl: process.env.DISCORD_CALLBACK_URL, - }, + authorizationURL: process.env.OAUTH2_AUTHORIZATION_URL, + tokenURL: process.env.OAUTH2_TOKEN_URL, + clientID: process.env.OAUTH2_CLIENT_ID, + clientSecret: process.env.OAUTH2_CLIENT_SECRET, + callbackURL: process.env.OAUTH2_CALLBACK_URL, + profileURL: process.env.OAUTH2_PROFILE_URL, + scopes: process.env.OAUTH2_SCOPES, }, }); diff --git a/src/interfaces/oauth2.ts b/src/interfaces/oauth2.ts new file mode 100644 index 0000000..b95a94a --- /dev/null +++ b/src/interfaces/oauth2.ts @@ -0,0 +1,6 @@ +export interface Oauth2Profile { + id?: string; + sub?: string; + + email: string; +} diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index 76f9b16..975ff7c 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -1,49 +1,47 @@ import { Controller, Get, Req, Res, UseGuards } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; import { ApiBearerAuth, ApiOkResponse } from "@nestjs/swagger"; -import { URLSearchParams } from "node:url"; - +import { ConfigService } from "@nestjs/config"; import { AuthService } from "./auth.service"; -import { DiscordAuthGuard } from "./guards/discord.guard"; +import { Oauth2AuthGuard } from "./guards/oauth2.guard"; import { RefreshJwtAuthGuard } from "./guards/refresh.guard"; @Controller("auth") export class AuthController { constructor( - private readonly configService: ConfigService, private readonly authService: AuthService, + private readonly configService: ConfigService, ) {} - @Get("providers") + @Get("login") @ApiOkResponse({ - description: "List of OAuth2 providers", + description: "Redirect to login page", example: { - discord: { url: "https://discord.com/oauth2/authorize?..." }, - keycloak: { url: "https://keycloak.com/oauth2/authorize?..." }, + url: "http://localhost:3000/auth/login", }, }) - getProviders() { - const discordOauth2Params = new URLSearchParams({ - client_id: this.configService.get( - "oauth2.discord.clientId", - ), + async login() { + const authorizationURL = this.configService.get( + "oauth2.authorizationURL", + ); + const clientID = this.configService.get("oauth2.clientID"); + const callbackURL = this.configService.get("oauth2.callbackURL"); + const scopes = this.configService.get("oauth2.scopes"); + + const searchParams = new URLSearchParams({ response_type: "code", - redirect_uri: this.configService.get( - "oauth2.discord.callbackUrl", - ), - scope: "identify email", + client_id: clientID, + redirect_uri: callbackURL, + scope: scopes, }); return { - discord: { - url: `https://discord.com/oauth2/authorize?${discordOauth2Params.toString()}`, - }, + url: `${authorizationURL}?${searchParams.toString()}`, }; } - @Get("discord/callback") - @UseGuards(DiscordAuthGuard) + @Get("callback") + @UseGuards(Oauth2AuthGuard) @ApiOkResponse({ description: "Return JWT token", example: { @@ -51,7 +49,7 @@ export class AuthController { refreshtoken: "Hello World!", }, }) - callbackDiscord(@Req() req, @Res() res) { + callback(@Req() req, @Res() res) { const { user } = req; res.send(user); diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index 568b49a..0cc7a77 100644 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -4,13 +4,13 @@ import { AuthService } from "./auth.service"; import { UserModule } from "@Modules/user/user.module"; -import { DiscordStrategy } from "./strategy/discord.strategy"; +import { Oauth2Strategy } from "./strategy/oauth2strategy"; import { JWTStrategy } from "./strategy/jwt.strategy"; import { RefreshJWTStrategy } from "./strategy/refresh.strategy"; @Module({ imports: [UserModule], controllers: [AuthController], - providers: [AuthService, DiscordStrategy, JWTStrategy, RefreshJWTStrategy], + providers: [AuthService, Oauth2Strategy, JWTStrategy, RefreshJWTStrategy], }) export class AuthModule {} diff --git a/src/modules/auth/guards/discord.guard.ts b/src/modules/auth/guards/discord.guard.ts deleted file mode 100644 index 5fbd0e4..0000000 --- a/src/modules/auth/guards/discord.guard.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Injectable, UnauthorizedException } from "@nestjs/common"; -import { AuthGuard } from "@nestjs/passport"; - -@Injectable() -export class DiscordAuthGuard extends AuthGuard("discord") { - handleRequest(err, user, info) { - if (err || !user) { - const errorMessage = info?.message || "Authentication failed"; - throw new UnauthorizedException(errorMessage); - } - return user; - } -} diff --git a/src/modules/auth/guards/oauth2.guard.ts b/src/modules/auth/guards/oauth2.guard.ts new file mode 100644 index 0000000..90f5586 --- /dev/null +++ b/src/modules/auth/guards/oauth2.guard.ts @@ -0,0 +1,5 @@ +import { Injectable } from "@nestjs/common"; +import { AuthGuard } from "@nestjs/passport"; + +@Injectable() +export class Oauth2AuthGuard extends AuthGuard("oauth2") {} diff --git a/src/modules/auth/strategy/discord.strategy.ts b/src/modules/auth/strategy/discord.strategy.ts deleted file mode 100644 index c3c0503..0000000 --- a/src/modules/auth/strategy/discord.strategy.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Injectable } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import { PassportStrategy } from "@nestjs/passport"; -import { Profile, Strategy } from "passport-discord"; -import { AuthService } from "../auth.service"; - -@Injectable() -export class DiscordStrategy extends PassportStrategy(Strategy, "discord") { - configService: ConfigService; - - constructor( - private readonly authService: AuthService, - configService: ConfigService, - ) { - super({ - clientID: configService.get("oauth2.discord.clientId"), - clientSecret: configService.get( - "oauth2.discord.clientSecret", - ), - callbackURL: configService.get( - "oauth2.discord.callbackUrl", - ), - scope: ["identify", "email"], - }); - - this.configService = configService; - } - - async validate( - _accessToken: string, - _refreshToken: string, - profile: Profile, - done: Function, - ) { - const accessToken = this.authService.accessToken({ id: profile.id }); - const refreshToken = this.authService.refreshToken({ id: profile.id }); - - done(null, { accessToken, refreshToken }); - } -} diff --git a/src/modules/auth/strategy/oauth2strategy.ts b/src/modules/auth/strategy/oauth2strategy.ts new file mode 100644 index 0000000..8e55f8d --- /dev/null +++ b/src/modules/auth/strategy/oauth2strategy.ts @@ -0,0 +1,79 @@ +import { Oauth2Profile } from "@/interfaces/oauth2"; +import { UserService } from "@/modules/user/user.service"; +import { Injectable, UnauthorizedException } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { PassportStrategy } from "@nestjs/passport"; +import axios from "axios"; +import { Strategy } from "passport-oauth2"; +import { AuthService } from "../auth.service"; + +@Injectable() +export class Oauth2Strategy extends PassportStrategy(Strategy, "oauth2") { + configService: ConfigService; + + constructor( + // @ts-ignore + private readonly authService: AuthService, + private readonly userSerivce: UserService, + configService: ConfigService, + ) { + super({ + authorizationURL: configService.get("oauth2.authorizationURL"), + tokenURL: configService.get("oauth2.tokenURL"), + clientID: configService.get("oauth2.clientID"), + clientSecret: configService.get("oauth2.clientSecret"), + callbackURL: configService.get("oauth2.callbackURL"), + scope: configService.get("oauth2.scopes"), + }); + + this.configService = configService; + } + + userProfile( + accessToken: string, + done: (err?: unknown, profile?: Oauth2Profile) => void, + ): void { + try { + axios + .get(this.configService.get("oauth2.profileURL"), { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + .then((response) => { + const { data } = response; + done(null, data); + }) + .catch((error) => { + done(error); + }); + } catch (error) { + done(error); + } + } + + async validate( + _accessToken: string, + _refreshToken: string, + profile: Oauth2Profile, + ): Promise<{ accessToken: string; refreshToken: string }> { + const user = await this.userSerivce.findOne({ + providerId: profile.sub ?? profile.id, + }); + + if (!user) throw new UnauthorizedException("User not found"); + + const accessToken = this.authService.accessToken({ + id: user.id, + }); + + const refreshToken = this.authService.refreshToken({ + id: user.id, + }); + + return { + accessToken, + refreshToken, + }; + } +} diff --git a/src/modules/user/dto/bulk-delete-user.dto.ts b/src/modules/user/dto/bulk-delete-user.dto.ts index 4c57c2f..9208612 100644 --- a/src/modules/user/dto/bulk-delete-user.dto.ts +++ b/src/modules/user/dto/bulk-delete-user.dto.ts @@ -1,3 +1,4 @@ +import { ApiProperty } from "@nestjs/swagger"; import { ArrayMaxSize, ArrayMinSize, IsArray, IsString } from "class-validator"; export class BulkDeleteUserDTO { @@ -5,5 +6,6 @@ export class BulkDeleteUserDTO { @IsString({ each: true }) @ArrayMinSize(1) @ArrayMaxSize(10) + @ApiProperty() ids: string[]; } diff --git a/src/modules/user/dto/create-user.dto.ts b/src/modules/user/dto/create-user.dto.ts index d667abd..1bc3068 100644 --- a/src/modules/user/dto/create-user.dto.ts +++ b/src/modules/user/dto/create-user.dto.ts @@ -5,4 +5,8 @@ export class CreateUserDTO { @IsString() @ApiProperty() username: string; + + @IsString() + @ApiProperty() + providerId: string; } diff --git a/src/modules/user/user.service.ts b/src/modules/user/user.service.ts index d1d1399..3498586 100644 --- a/src/modules/user/user.service.ts +++ b/src/modules/user/user.service.ts @@ -1,6 +1,6 @@ import { Injectable, NotFoundException } from "@nestjs/common"; -import { PrismaService } from "nestjs-prisma"; import { Prisma } from "@prisma/client"; +import { PrismaService } from "nestjs-prisma"; import { CreateUserDTO } from "./dto/create-user.dto"; import { UpdateUserDTO } from "./dto/update-user.dto"; @@ -31,10 +31,17 @@ export class UserService { }); } + async findOne(where: Prisma.UserWhereInput) { + return await this.prisma.user.findFirst({ + where, + }); + } + async create(createUserInput: CreateUserDTO) { return await this.prisma.user.create({ data: { username: createUserInput.username, + providerId: createUserInput.providerId, }, }); } diff --git a/src/validations/env.validation.ts b/src/validations/env.validation.ts index 4f7594b..46b734f 100644 --- a/src/validations/env.validation.ts +++ b/src/validations/env.validation.ts @@ -9,9 +9,11 @@ export const envValidation = Joi.object({ REFRESH_JWT_SECRET: Joi.string().required(), REFRESH_JWT_EXPIRES_IN: Joi.string().required(), - DISCORD_CLIENT_ID: Joi.string().required(), - DISCORD_CLIENT_SECRET: Joi.string().required(), - DISCORD_CALLBACK_URL: Joi.string().required(), + OAUTH2_AUTHORIZATION_URL: Joi.string().uri().required(), + OAUTH2_TOKEN_URL: Joi.string().uri().required(), + OAUTH2_CLIENT_ID: Joi.string().required(), + OAUTH2_CLIENT_SECRET: Joi.string().required(), + OAUTH2_CALLBACK_URL: Joi.string().required(), PORT: Joi.number().optional(), }); diff --git a/tsconfig.json b/tsconfig.json index b62c852..cde634b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,8 +24,6 @@ "noImplicitAny": false, "strictBindCallApply": false, "forceConsistentCasingInFileNames": false, - "noFallthroughCasesInSwitch": false, - "noUnusedLocals": true, - "noUnusedParameters": true + "noFallthroughCasesInSwitch": false } } diff --git a/yarn.lock b/yarn.lock index 97f8258..557fb42 100644 --- a/yarn.lock +++ b/yarn.lock @@ -640,15 +640,6 @@ dependencies: "@types/node" "*" -"@types/passport-discord@^0.1.14": - version "0.1.14" - resolved "https://registry.yarnpkg.com/@types/passport-discord/-/passport-discord-0.1.14.tgz#a6a8866f88932cc7499cfa325b9f47a4a390e283" - integrity sha512-JE7Wbtr4bqqV9poWAbwB+aeVkd3/TM6wgGTtn4Ym6KPLJlJju73BEIH3uS+EeR+D7tY3lP1MtUpJPbxC86PXzA== - dependencies: - "@types/express" "*" - "@types/passport" "*" - "@types/passport-oauth2" "*" - "@types/passport-jwt@^4.0.1": version "4.0.1" resolved "https://registry.yarnpkg.com/@types/passport-jwt/-/passport-jwt-4.0.1.tgz#080fbe934fb9f6954fb88ec4cdf4bb2cc7c4d435" @@ -657,7 +648,7 @@ "@types/jsonwebtoken" "*" "@types/passport-strategy" "*" -"@types/passport-oauth2@*": +"@types/passport-oauth2@^1.4.17": version "1.4.17" resolved "https://registry.yarnpkg.com/@types/passport-oauth2/-/passport-oauth2-1.4.17.tgz#d5d54339d44f6883d03e69dc0cc0e2114067abb4" integrity sha512-ODiAHvso6JcWJ6ZkHHroVp05EHGhqQN533PtFNBkg8Fy5mERDqsr030AX81M0D69ZcaMvhF92SRckEk2B0HYYg== @@ -2748,13 +2739,6 @@ parseurl@~1.3.3: resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== -passport-discord@^0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/passport-discord/-/passport-discord-0.1.4.tgz#9265be11952cdd54d77c47eaae352834444cf0f6" - integrity sha512-VJWPYqSOmh7SaCLw/C+k1ZqCzJnn2frrmQRx1YrcPJ3MQ+Oa31XclbbmqFICSvl8xv3Fqd6YWQ4H4p1MpIN9rA== - dependencies: - passport-oauth2 "^1.5.0" - passport-jwt@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/passport-jwt/-/passport-jwt-4.0.1.tgz#c443795eff322c38d173faa0a3c481479646ec3d" @@ -2763,7 +2747,7 @@ passport-jwt@^4.0.1: jsonwebtoken "^9.0.0" passport-strategy "^1.0.0" -passport-oauth2@^1.5.0: +passport-oauth2@^1.8.0: version "1.8.0" resolved "https://registry.yarnpkg.com/passport-oauth2/-/passport-oauth2-1.8.0.tgz#55725771d160f09bbb191828d5e3d559eee079c8" integrity sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA== @@ -3203,7 +3187,16 @@ streamsearch@^1.1.0: resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764" integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg== -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -3235,7 +3228,14 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==