feat: implement oauth2

This commit is contained in:
M1000 2024-12-06 01:00:32 +01:00
parent 76093fb5e8
commit dd486229c4
18 changed files with 181 additions and 123 deletions

View File

@ -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
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

View File

@ -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"
}
}
}

View File

@ -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
}
}

View File

@ -27,7 +27,7 @@ import { AppController } from "./app.controller";
}),
}),
PrismaModule.forRoot({
isGlobal: true
isGlobal: true,
}),
UserModule,
AuthModule,

View File

@ -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,
},
});

6
src/interfaces/oauth2.ts Normal file
View File

@ -0,0 +1,6 @@
export interface Oauth2Profile {
id?: string;
sub?: string;
email: string;
}

View File

@ -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<string>(
"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<string>(
"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);

View File

@ -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 {}

View File

@ -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;
}
}

View File

@ -0,0 +1,5 @@
import { Injectable } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
@Injectable()
export class Oauth2AuthGuard extends AuthGuard("oauth2") {}

View File

@ -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<string>("oauth2.discord.clientId"),
clientSecret: configService.get<string>(
"oauth2.discord.clientSecret",
),
callbackURL: configService.get<string>(
"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 });
}
}

View File

@ -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,
};
}
}

View File

@ -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[];
}

View File

@ -5,4 +5,8 @@ export class CreateUserDTO {
@IsString()
@ApiProperty()
username: string;
@IsString()
@ApiProperty()
providerId: string;
}

View File

@ -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,
},
});
}

View File

@ -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(),
});

View File

@ -24,8 +24,6 @@
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false,
"noUnusedLocals": true,
"noUnusedParameters": true
"noFallthroughCasesInSwitch": false
}
}

View File

@ -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==