feat: implement oauth2
This commit is contained in:
parent
76093fb5e8
commit
dd486229c4
13
.env.example
13
.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
|
||||
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
|
@ -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",
|
||||
|
@ -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")
|
||||
|
@ -27,7 +27,7 @@ import { AppController } from "./app.controller";
|
||||
}),
|
||||
}),
|
||||
PrismaModule.forRoot({
|
||||
isGlobal: true
|
||||
isGlobal: true,
|
||||
}),
|
||||
UserModule,
|
||||
AuthModule,
|
||||
|
@ -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
6
src/interfaces/oauth2.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export interface Oauth2Profile {
|
||||
id?: string;
|
||||
sub?: string;
|
||||
|
||||
email: string;
|
||||
}
|
@ -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);
|
||||
|
@ -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 {}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
5
src/modules/auth/guards/oauth2.guard.ts
Normal file
5
src/modules/auth/guards/oauth2.guard.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { AuthGuard } from "@nestjs/passport";
|
||||
|
||||
@Injectable()
|
||||
export class Oauth2AuthGuard extends AuthGuard("oauth2") {}
|
@ -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 });
|
||||
}
|
||||
}
|
79
src/modules/auth/strategy/oauth2strategy.ts
Normal file
79
src/modules/auth/strategy/oauth2strategy.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
@ -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[];
|
||||
}
|
||||
|
@ -5,4 +5,8 @@ export class CreateUserDTO {
|
||||
@IsString()
|
||||
@ApiProperty()
|
||||
username: string;
|
||||
|
||||
@IsString()
|
||||
@ApiProperty()
|
||||
providerId: string;
|
||||
}
|
||||
|
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
@ -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(),
|
||||
});
|
||||
|
@ -24,8 +24,6 @@
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"forceConsistentCasingInFileNames": false,
|
||||
"noFallthroughCasesInSwitch": false,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true
|
||||
"noFallthroughCasesInSwitch": false
|
||||
}
|
||||
}
|
||||
|
40
yarn.lock
40
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==
|
||||
|
Loading…
Reference in New Issue
Block a user