diff --git a/violet-server/package-lock.json b/violet-server/package-lock.json index 9badb27c2..822c44981 100644 --- a/violet-server/package-lock.json +++ b/violet-server/package-lock.json @@ -28,6 +28,7 @@ "joi": "^17.11.0", "mysql2": "^3.11.0", "nest-winston": "^1.9.4", + "passport-discord": "^0.1.4", "passport-jwt": "^4.0.1", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1", @@ -3339,6 +3340,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/big-integer": { "version": "1.6.52", "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", @@ -8401,6 +8410,11 @@ "node": "^12.13.0 || ^14.15.0 || >=16.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", @@ -8682,6 +8696,14 @@ "url": "https://github.com/sponsors/jaredhanson" } }, + "node_modules/passport-discord": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/passport-discord/-/passport-discord-0.1.4.tgz", + "integrity": "sha512-VJWPYqSOmh7SaCLw/C+k1ZqCzJnn2frrmQRx1YrcPJ3MQ+Oa31XclbbmqFICSvl8xv3Fqd6YWQ4H4p1MpIN9rA==", + "dependencies": { + "passport-oauth2": "^1.5.0" + } + }, "node_modules/passport-jwt": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz", @@ -8691,6 +8713,25 @@ "passport-strategy": "^1.0.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", @@ -11020,6 +11061,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/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", @@ -14104,6 +14150,11 @@ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" }, + "base64url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==" + }, "big-integer": { "version": "1.6.52", "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", @@ -17939,6 +17990,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", @@ -18140,6 +18196,14 @@ "utils-merge": "^1.0.1" } }, + "passport-discord": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/passport-discord/-/passport-discord-0.1.4.tgz", + "integrity": "sha512-VJWPYqSOmh7SaCLw/C+k1ZqCzJnn2frrmQRx1YrcPJ3MQ+Oa31XclbbmqFICSvl8xv3Fqd6YWQ4H4p1MpIN9rA==", + "requires": { + "passport-oauth2": "^1.5.0" + } + }, "passport-jwt": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz", @@ -18149,6 +18213,18 @@ "passport-strategy": "^1.0.0" } }, + "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", @@ -19772,6 +19848,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==" + }, "undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", @@ -19910,6 +19991,7 @@ "joi": "^17.11.0", "mysql2": "^3.11.0", "nest-winston": "^1.9.4", + "passport-discord": "^0.1.4", "passport-jwt": "^4.0.1", "prettier": "^3.0.0", "reflect-metadata": "^0.1.13", @@ -22425,6 +22507,11 @@ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" }, + "base64url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==" + }, "big-integer": { "version": "1.6.52", "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", @@ -26260,6 +26347,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", @@ -26461,6 +26553,14 @@ "utils-merge": "^1.0.1" } }, + "passport-discord": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/passport-discord/-/passport-discord-0.1.4.tgz", + "integrity": "sha512-VJWPYqSOmh7SaCLw/C+k1ZqCzJnn2frrmQRx1YrcPJ3MQ+Oa31XclbbmqFICSvl8xv3Fqd6YWQ4H4p1MpIN9rA==", + "requires": { + "passport-oauth2": "^1.5.0" + } + }, "passport-jwt": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz", @@ -26470,6 +26570,18 @@ "passport-strategy": "^1.0.0" } }, + "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", @@ -28093,6 +28205,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==" + }, "undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", diff --git a/violet-server/package.json b/violet-server/package.json index 4dc00c09c..6b4728255 100644 --- a/violet-server/package.json +++ b/violet-server/package.json @@ -39,6 +39,7 @@ "joi": "^17.11.0", "mysql2": "^3.11.0", "nest-winston": "^1.9.4", + "passport-discord": "^0.1.4", "passport-jwt": "^4.0.1", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1", diff --git a/violet-server/src/auth/auth.controller.ts b/violet-server/src/auth/auth.controller.ts index a97261858..32be6784e 100644 --- a/violet-server/src/auth/auth.controller.ts +++ b/violet-server/src/auth/auth.controller.ts @@ -21,6 +21,7 @@ import { Request, Response } from 'express'; import { ResLoginUser } from './dtos/res-login-user.dto'; import { plainToClass } from 'class-transformer'; import { HmacAuthGuard } from './guards/hmac.guard'; +import { DiscordAuthGuard } from './guards/discord.guard'; @ApiTags('auth') @Controller('auth') @@ -104,4 +105,21 @@ export class AuthController { res.clearCookie('refresh-expires'); res.send(); } + + @Get('discord') + @Post('discord') + @UseGuards(DiscordAuthGuard) + @ApiOperation({ summary: 'Login From Discord' }) + logInDiscord() { + return { ok: true }; + } + + @Get('discord/redirect') + @UseGuards(DiscordAuthGuard) + @UseGuards(AccessTokenGuard) + @ApiOperation({ summary: 'Redirect discord oauth2' }) + @Redirect('violet://discord-login') + async redirect(@CurrentUser() currentUser: User) { + return await this.authService.updateDiscordInfo(currentUser); + } } diff --git a/violet-server/src/auth/auth.module.ts b/violet-server/src/auth/auth.module.ts index 002ec446e..94762a1da 100644 --- a/violet-server/src/auth/auth.module.ts +++ b/violet-server/src/auth/auth.module.ts @@ -6,6 +6,7 @@ import { AccessTokenStrategy } from './jwt/access-token.strategy'; import { RefreshTokenStrategy } from './jwt/refresh-token.strategy'; import { UserModule } from 'src/user/user.module'; import { JwtModule } from '@nestjs/jwt'; +import { DiscordStrategy } from './discord/discord.strategy'; @Module({ imports: [JwtModule.register({}), UserModule], @@ -14,6 +15,7 @@ import { JwtModule } from '@nestjs/jwt'; UserRepository, AccessTokenStrategy, RefreshTokenStrategy, + DiscordStrategy, ], controllers: [AuthController], }) diff --git a/violet-server/src/auth/auth.service.ts b/violet-server/src/auth/auth.service.ts index cc2eb7b6a..007827a12 100644 --- a/violet-server/src/auth/auth.service.ts +++ b/violet-server/src/auth/auth.service.ts @@ -1,10 +1,16 @@ -import { BadRequestException, HttpException, Injectable } from '@nestjs/common'; +import { + BadRequestException, + HttpException, + Injectable, + Logger, +} from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import { UserRegisterDTO } from 'src/user/dtos/user-register.dto'; import { UserRepository } from 'src/user/user.repository'; import { ResLoginUser } from './dtos/res-login-user.dto'; import { Tokens } from './jwt/jwt.token'; +import { User } from 'src/user/entity/user.entity'; @Injectable() export class AuthService { @@ -95,4 +101,19 @@ export class AuthService { async deleteRefreshToken(userAppId: string) { await this.userRepository.update({ userAppId }, { refreshToken: null }); } + + async updateDiscordInfo( + user: User, + ): Promise<{ ok: boolean; error?: string }> { + try { + const { userAppId, discordId, avatar } = user; + await this.userRepository.update({ userAppId }, { discordId, avatar }); + + return { ok: true }; + } catch (e) { + Logger.error(e); + + return { ok: false, error: e }; + } + } } diff --git a/violet-server/src/auth/discord/discord.strategy.ts b/violet-server/src/auth/discord/discord.strategy.ts new file mode 100644 index 000000000..95a16b310 --- /dev/null +++ b/violet-server/src/auth/discord/discord.strategy.ts @@ -0,0 +1,28 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { Profile, Strategy } from 'passport-discord'; +import { ConfigService } from '@nestjs/config'; +import { PassportStrategy } from '@nestjs/passport'; + +@Injectable() +export class DiscordStrategy extends PassportStrategy(Strategy, 'discord') { + constructor(private readonly configService: ConfigService) { + super({ + clientID: configService.get('DISCORD_CLIENT_ID'), + clientSecret: configService.get('DISCORD_CLIENT_SECRET'), + callbackURL: 'http://localhost:3000/api/v2/auth/discord/redirect', + scope: ['identify'], + }); + } + + async validate(accessToken: string, refreshToken: string, profile: Profile) { + try { + const { id: discordId, avatar } = profile; + return { + discordId: discordId, + avatar: avatar, + }; + } catch (error) { + throw new UnauthorizedException(error); + } + } +} diff --git a/violet-server/src/auth/guards/discord.guard.ts b/violet-server/src/auth/guards/discord.guard.ts new file mode 100644 index 000000000..5d789de58 --- /dev/null +++ b/violet-server/src/auth/guards/discord.guard.ts @@ -0,0 +1,26 @@ +import { + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class DiscordAuthGuard extends AuthGuard('discord') { + constructor() { + super({ + property: 'discord', + }); + } + + async canActivate(context: ExecutionContext) { + return (await super.canActivate(context)) as boolean; + } + + handleRequest(err: any, user: any) { + if (err || !user) { + throw err || new UnauthorizedException('Retry login'); + } + return user; + } +} diff --git a/violet-server/src/auth/jwt/access-token.strategy.ts b/violet-server/src/auth/jwt/access-token.strategy.ts index 7bcfc3901..0f268b95d 100644 --- a/violet-server/src/auth/jwt/access-token.strategy.ts +++ b/violet-server/src/auth/jwt/access-token.strategy.ts @@ -25,7 +25,6 @@ export class AccessTokenStrategy extends PassportStrategy( async validate(req: Request, payload: JwtPayload) { try { - console.log('validate'); const user = await this.userRepository.findOneBy({ userAppId: payload.userAppId, }); diff --git a/violet-server/src/auth/jwt/jwt.payload.ts b/violet-server/src/auth/jwt/jwt.payload.ts index 93231a243..cebf08b4c 100644 --- a/violet-server/src/auth/jwt/jwt.payload.ts +++ b/violet-server/src/auth/jwt/jwt.payload.ts @@ -1,5 +1,6 @@ export class JwtPayload { userAppId: string; + discordId?: string; id: string; refreshToken?: string; } diff --git a/violet-server/src/common/decorators/current-user.decorator.ts b/violet-server/src/common/decorators/current-user.decorator.ts index 61a4f2c83..5fe9bd442 100644 --- a/violet-server/src/common/decorators/current-user.decorator.ts +++ b/violet-server/src/common/decorators/current-user.decorator.ts @@ -5,6 +5,8 @@ export const CurrentUser = createParamDecorator( (data: keyof JwtPayload | undefined, ctx: ExecutionContext) => { const request = ctx.switchToHttp().getRequest(); + request.user = { ...request.user, ...request.discord }; + const { refreshToken, ...responUser } = request.user; if (!data) return responUser; diff --git a/violet-server/src/user/entity/user.entity.ts b/violet-server/src/user/entity/user.entity.ts index 47d81f092..0fb110e99 100644 --- a/violet-server/src/user/entity/user.entity.ts +++ b/violet-server/src/user/entity/user.entity.ts @@ -1,7 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsNotEmpty } from 'class-validator'; import { CoreEntity } from 'src/common/entities/core.entity'; -import { Column, Entity, OneToMany } from 'typeorm'; +import { Column, Entity, OneToMany, Index } from 'typeorm'; import { Exclude } from 'class-transformer'; import { Comment } from 'src/comment/entity/comment.entity'; @@ -16,16 +16,18 @@ export class User extends CoreEntity { userAppId: string; @Column({ nullable: true }) + @Index() discordId?: string; @Column({ nullable: true }) - avartar?: string; + avatar?: string; @Column({ unique: true, nullable: true }) nickname?: string; @Column({ nullable: true }) @Exclude() + @Index() refreshToken?: string; @OneToMany(() => Comment, (comment) => comment.user) diff --git a/violet-server/src/user/user.controller.ts b/violet-server/src/user/user.controller.ts index a9da346f2..20699a9eb 100644 --- a/violet-server/src/user/user.controller.ts +++ b/violet-server/src/user/user.controller.ts @@ -3,6 +3,8 @@ import { ApiCreatedResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; import { UserRegisterDTO } from './dtos/user-register.dto'; import { UserService } from './user.service'; import { HmacAuthGuard } from 'src/auth/guards/hmac.guard'; +import { CurrentUser } from 'src/common/decorators/current-user.decorator'; +import { AccessTokenGuard } from 'src/auth/guards/access-token.guard'; @ApiTags('user') @Controller('user') @@ -18,4 +20,14 @@ export class UserController { ): Promise<{ ok: boolean; error?: string }> { return await this.userService.registerUser(dto); } + + @Get('discord') + @ApiOperation({ summary: 'Get userAppIds registered by discord id' }) + @ApiCreatedResponse({ description: '' }) + @UseGuards(AccessTokenGuard) + async listDiscordUserAppIds( + @CurrentUser('discordId') discordId?: string, + ): Promise { + return await this.userService.listDiscordUserAppIds(discordId); + } } diff --git a/violet-server/src/user/user.service.ts b/violet-server/src/user/user.service.ts index 298c7fa55..94e182d80 100644 --- a/violet-server/src/user/user.service.ts +++ b/violet-server/src/user/user.service.ts @@ -1,4 +1,9 @@ -import { Injectable, Logger, UnauthorizedException } from '@nestjs/common'; +import { + BadRequestException, + Injectable, + Logger, + UnauthorizedException, +} from '@nestjs/common'; import { UserRepository } from './user.repository'; import { UserRegisterDTO } from './dtos/user-register.dto'; @@ -8,7 +13,7 @@ export class UserService { async registerUser( dto: UserRegisterDTO, - ): Promise<{ ok: boolean; err?: string }> { + ): Promise<{ ok: boolean; error?: string }> { try { if (await this.userRepository.isUserExists(dto.userAppId)) throw new UnauthorizedException('user app id already exists'); @@ -19,7 +24,24 @@ export class UserService { } catch (e) { Logger.error(e); - return { ok: false, err: e }; + return { ok: false, error: e }; } } + + async listDiscordUserAppIds(discordId?: string): Promise { + if (discordId == null) { + throw new BadRequestException('discord login is required'); + } + + const users = await this.userRepository.find({ + select: { + userAppId: true, + }, + where: { + discordId: discordId!, + }, + }); + + return users.map(({ userAppId }) => userAppId); + } }