From a863806111fe32c7a40ea80973e46ef31a0e0c54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E7=A7=8B=E6=97=AD?= Date: Thu, 23 Feb 2023 17:09:41 +0800 Subject: [PATCH] refactor: controllers --- src/email/email.service.ts | 25 +++- ...-password.dto.ts => reset-password.dto.ts} | 2 +- src/users/me.controller.ts | 93 +++++++++++++ src/users/me.service.ts | 95 +++++++++++++ src/users/token.controller.ts | 25 ---- src/users/token.service.ts | 65 --------- src/users/users.controller.ts | 113 +++------------ src/users/users.module.ts | 8 +- src/users/users.service.ts | 129 ++++++------------ 9 files changed, 279 insertions(+), 276 deletions(-) rename src/users/dto/{forget-password.dto.ts => reset-password.dto.ts} (89%) create mode 100644 src/users/me.controller.ts create mode 100644 src/users/me.service.ts delete mode 100644 src/users/token.controller.ts delete mode 100644 src/users/token.service.ts diff --git a/src/email/email.service.ts b/src/email/email.service.ts index 775bb6d..2265611 100644 --- a/src/email/email.service.ts +++ b/src/email/email.service.ts @@ -1,14 +1,15 @@ import { - ConflictException, Inject, Injectable, + ConflictException, + ForbiddenException, NotFoundException, } from '@nestjs/common' import { securityConfig, SecurityConfig } from 'src/common/configs' import { MailerService } from '@nestjs-modules/mailer' import { JwtService } from '@nestjs/jwt' -import { EmailScene } from './dto/email.dto' import { PrismaService } from 'nestjs-prisma' +import { EmailSendDto, EmailScene } from 'src/email/dto/email.dto' @Injectable() export class EmailService { @@ -55,10 +56,26 @@ export class EmailService { subject: `【qiuxu.site】${this.subjectMap[scene]}`, html: `您正在qiuxu.site${this.subjectMap[scene]},验证码为 ${verifyCode},30分钟内有效`, }) - return registerToken + return { registerToken, userId: user.id } } - getEmailJwtSecret(verifyCode: string, scene: EmailScene) { + // TODO: 做成守卫? + async verifyEmail(data: { + email: string + token: string + verifyCode: string + scene: EmailScene + }) { + const { email, token, verifyCode, scene } = data + const payload = this.jwtService.verify(token, { + secret: this.getEmailJwtSecret(verifyCode, scene), + }) + if (payload.email !== email || payload.scene !== scene) { + throw new ForbiddenException('请输入正确的邮箱验证码') + } + } + + private getEmailJwtSecret(verifyCode: string, scene: EmailScene) { return this.secureConfig.jwt_access_secret + verifyCode + scene } diff --git a/src/users/dto/forget-password.dto.ts b/src/users/dto/reset-password.dto.ts similarity index 89% rename from src/users/dto/forget-password.dto.ts rename to src/users/dto/reset-password.dto.ts index f766e99..0b6ac38 100644 --- a/src/users/dto/forget-password.dto.ts +++ b/src/users/dto/reset-password.dto.ts @@ -1,6 +1,6 @@ import { IsEmail, IsNotEmpty, IsStrongPassword } from 'class-validator' -export class ForgetPassword { +export class ResetPassword { @IsNotEmpty() @IsEmail() email: string diff --git a/src/users/me.controller.ts b/src/users/me.controller.ts new file mode 100644 index 0000000..a7f211e --- /dev/null +++ b/src/users/me.controller.ts @@ -0,0 +1,93 @@ +import { + Controller, + Get, + Delete, + Patch, + Put, + Body, + UseInterceptors, + BadRequestException, +} from '@nestjs/common' +import { MeService } from './me.service' +import { ApiTags, ApiOperation, ApiUnauthorizedResponse } from '@nestjs/swagger' +import { User } from 'src/common/decorators/user.decorator' +import { NeedAuth } from 'src/common/decorators/need-auth.decorator' +import { PasswordInterceptor } from 'src/common/interceptors/password.interceptor' +import { PrismaService } from 'nestjs-prisma' +import { UserEntity } from './entities/user.entity' +import { DeleteUserDto } from './dto/delete-user.dto' +import { ChangePassword } from './dto/change-password.dto' +import { UpdateUserDto } from './dto/update-user.dto' +import { TokenRefreshPayload } from './dto/token.dto' + +@Controller('api/users/me') +@ApiTags('Me') +export class MeController { + constructor( + private readonly meService: MeService, + private readonly prismaService: PrismaService, + ) {} + + @Get() + @ApiOperation({ summary: '获取用户信息' }) + @UseInterceptors(PasswordInterceptor) + @NeedAuth() + async getUserInfo(@User('userId') userId: string): Promise { + return this.prismaService.user.findUniqueOrThrow({ + where: { id: userId }, + }) + } + + @Patch() + @ApiOperation({ summary: '修改用户信息(用户名等)' }) + @UseInterceptors(PasswordInterceptor) + @NeedAuth() + async updateUserInfo( + @Body() payload: UpdateUserDto, + @User('userId') userId: string, + ): Promise { + if (Object.keys(payload).length === 0) { + throw new BadRequestException() + } + return this.prismaService.user.update({ + where: { id: userId }, + data: payload, + }) + } + + @Delete() + @NeedAuth() + @ApiOperation({ summary: '删除用户' }) + async deleteUser( + @Body() userData: DeleteUserDto, + @User('userId') userId: string, + ) { + return this.meService.deleteUser(userData, userId) + } + + @Patch('?field=password') + @NeedAuth() + @ApiOperation({ summary: '修改密码' }) + @UseInterceptors(PasswordInterceptor) + async changePassword( + @Body() payload: ChangePassword, + @User('userId') userId: string, + ) { + return this.meService.changePassword(payload, userId) + } + + @Patch('?field=email') + @NeedAuth() + @ApiOperation({ summary: '修改邮箱(TODO)' }) + @UseInterceptors(PasswordInterceptor) + async updateEmail(@Body() payload: unknown) { + return '修改邮箱' + } + + @Put('token') + @ApiOperation({ summary: '刷新token' }) + @ApiUnauthorizedResponse({ description: 'Unauthorized' }) + async updateAccessToken(@Body() payload: TokenRefreshPayload) { + return this.meService.updateAccessToken(payload.refreshToken) + } +} diff --git a/src/users/me.service.ts b/src/users/me.service.ts new file mode 100644 index 0000000..d138b06 --- /dev/null +++ b/src/users/me.service.ts @@ -0,0 +1,95 @@ +import { + Inject, + Injectable, + ForbiddenException, + UnauthorizedException, +} from '@nestjs/common' +import * as bcrypt from 'bcrypt' +import { PrismaService } from 'nestjs-prisma' +import { JwtService } from '@nestjs/jwt' +import { securityConfig, SecurityConfig } from 'src/common/configs' +import { EmailScene } from 'src/email/dto/email.dto' +import { EmailService } from 'src/email/email.service' +import { DeleteUserDto } from './dto/delete-user.dto' +import { ChangePassword } from './dto/change-password.dto' +import { TokenContnet } from './dto/token.dto' + +@Injectable() +export class MeService { + constructor( + private jwtService: JwtService, + private prismaService: PrismaService, + private emailService: EmailService, + @Inject(securityConfig.KEY) private secureConfig: SecurityConfig, + ) {} + + async deleteUser(userToDelete: DeleteUserDto, userId: string) { + const { email, token, verifyCode, password } = userToDelete + await this.emailService.verifyEmail({ + email, + token, + verifyCode, + scene: EmailScene.deleteUser, + }) + const user = await this.prismaService.user.findUniqueOrThrow({ + where: { email: userToDelete.email }, + }) + if (user.id !== userId) { + throw new ForbiddenException() + } + const passwordValid = await bcrypt.compare(password, user.password) + if (!passwordValid) { + throw new ForbiddenException('Invalid password') + } + + return this.prismaService.user.delete({ + where: { email: userToDelete.email }, + }) + } + + async changePassword(payload: ChangePassword, userId: string) { + const user = await this.prismaService.user.findUniqueOrThrow({ + where: { id: userId }, + }) + await this.checkPassword(payload.oldPassword, user.password) + const hashedPassword = await bcrypt.hash( + payload.newPassword, + this.secureConfig.bcryptSaltOrRound, + ) + return this.prismaService.user.update({ + where: { id: userId }, + data: { password: hashedPassword }, + }) + } + + async updateAccessToken(refreshToken: string) { + const { userId, iat } = this.jwtService.verify(refreshToken, { + secret: this.secureConfig.jwt_refresh_secret, + }) + const user = await this.prismaService.user.findUniqueOrThrow({ + where: { id: userId }, + }) + // TODO:不使用updatedAt,而是自定义的一个refreshTime字段 + if (iat * 1000 < user.updatedAt.getTime()) { + throw new UnauthorizedException('token失效,请重新登录') + } + return { + userId, + refreshToken: refreshToken, + accessToken: this.jwtService.sign( + { userId }, + { + secret: this.secureConfig.jwt_refresh_secret, + expiresIn: this.secureConfig.refreshIn, + }, + ), + } + } + + private async checkPassword(pwd: string, hashPwd: string) { + const valid = await bcrypt.compare(pwd, hashPwd) + if (!valid) { + throw new ForbiddenException('Invalid password') + } + } +} diff --git a/src/users/token.controller.ts b/src/users/token.controller.ts deleted file mode 100644 index 8b3e49c..0000000 --- a/src/users/token.controller.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Controller, Post, Put, Body } from '@nestjs/common' -import { ApiTags, ApiOperation, ApiUnauthorizedResponse } from '@nestjs/swagger' -import { LoginInputDto } from './dto/login-input.dto' -import { TokenRefreshPayload } from './dto/token.dto' -import { TokenService } from './token.service' - -@ApiTags('token') -@Controller('api/token') -export class TokenController { - constructor(private tokenService: TokenService) {} - - // TODO: 限制调用频率,避免暴力破解 - @ApiOperation({ summary: '登录用户' }) - @Post() - async login(@Body() user: LoginInputDto) { - return this.tokenService.login(user.email, user.password) - } - - @ApiOperation({ summary: '刷新token' }) - @ApiUnauthorizedResponse({ description: 'Unauthorized' }) - @Put() - async refreshToken(@Body() payload: TokenRefreshPayload) { - return this.tokenService.refreshToken(payload.refreshToken) - } -} diff --git a/src/users/token.service.ts b/src/users/token.service.ts deleted file mode 100644 index c863d29..0000000 --- a/src/users/token.service.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { Inject, Injectable, UnauthorizedException } from '@nestjs/common' -import * as bcrypt from 'bcrypt' -import { PrismaService } from 'nestjs-prisma' -import { Token, TokenPayload, TokenContnet } from './dto/token.dto' -import { JwtService } from '@nestjs/jwt' -import { securityConfig, SecurityConfig } from 'src/common/configs' - -@Injectable() -export class TokenService { - constructor( - private jwtService: JwtService, - private prismaService: PrismaService, - @Inject(securityConfig.KEY) - private secureConfig: SecurityConfig, - ) {} - - async login(email: string, password: string) { - const user = await this.prismaService.user.findUniqueOrThrow({ - where: { email }, - }) - - const passwordValid = await bcrypt.compare(password, user.password) - - if (!passwordValid) { - throw new UnauthorizedException('Invalid password') - } - - return this.generateTokens({ userId: user.id }) - } - - async refreshToken(token: string) { - const { userId, iat } = this.jwtService.verify(token, { - secret: this.secureConfig.jwt_refresh_secret, - }) - const user = await this.prismaService.user.findUniqueOrThrow({ - where: { id: userId }, - }) - if (iat * 1000 < user.updatedAt.getTime()) { - throw new UnauthorizedException('token失效,请重新登录') - } - return { - refreshToken: token, - accessToken: this.jwtService.sign( - { userId }, - { - secret: this.secureConfig.jwt_refresh_secret, - expiresIn: this.secureConfig.refreshIn, - }, - ), - } - } - - generateTokens(payload: TokenPayload): Token { - const accessToken = this.jwtService.sign(payload, { - secret: this.secureConfig.jwt_access_secret, - expiresIn: this.secureConfig.expiresIn, - }) - const refreshToken = this.jwtService.sign(payload, { - secret: this.secureConfig.jwt_refresh_secret, - expiresIn: this.secureConfig.refreshIn, - }) - - return { accessToken, refreshToken } - } -} diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index 9840f30..8e4548b 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -1,105 +1,34 @@ -import { - Controller, - Get, - Post, - Delete, - Put, - Patch, - Body, - UseInterceptors, - BadRequestException, -} from '@nestjs/common' +import { Controller, Post, Patch, Body, Param } from '@nestjs/common' import { UsersService } from './users.service' import { ApiTags, ApiOperation } from '@nestjs/swagger' -import { User } from 'src/common/decorators/user.decorator' -import { NeedAuth } from 'src/common/decorators/need-auth.decorator' -import { PasswordInterceptor } from 'src/common/interceptors/password.interceptor' -import { PrismaService } from 'nestjs-prisma' -import { UserEntity } from './entities/user.entity' import { CreateUserDto } from './dto/create-user.dto' -import { ForgetPassword } from './dto/forget-password.dto' -import { DeleteUserDto } from './dto/delete-user.dto' -import { ChangePassword } from './dto/change-password.dto' -import { UpdateUserDto } from './dto/update-user.dto' +import { LoginInputDto } from './dto/login-input.dto' +import { ResetPassword } from './dto/reset-password.dto' @Controller('api/users') +@ApiTags('Users') export class UsersController { - constructor( - private readonly userService: UsersService, - private readonly prismaService: PrismaService, - ) {} + constructor(private readonly usersService: UsersService) {} - @ApiTags('Me') - @ApiOperation({ summary: '获取用户信息' }) - @UseInterceptors(PasswordInterceptor) - @NeedAuth() - @Get('me') - async getUserInfo(@User('userId') userId: string): Promise { - return this.prismaService.user.findUniqueOrThrow({ where: { id: userId } }) - } - - @ApiTags('Me') - @ApiOperation({ summary: '修改用户信息(用户名等)' }) - @UseInterceptors(PasswordInterceptor) - @NeedAuth() - @Patch('me') - async updateUserInfo( - @Body() payload: UpdateUserDto, - @User('userId') userId: string, - ): Promise { - if (Object.keys(payload).length === 0) { - throw new BadRequestException() - } - return this.prismaService.user.update({ - where: { id: userId }, - data: payload, - }) - } - - @ApiTags('User') - @ApiOperation({ summary: '注册用户' }) @Post() - async register(@Body() userData: CreateUserDto) { - return this.userService.register(userData) + @ApiOperation({ summary: '邮箱注册' }) + async registerByEmail(@Body() userData: CreateUserDto) { + return this.usersService.registerByEmail(userData) } - @ApiTags('Me') - @NeedAuth() - @ApiOperation({ summary: '删除用户' }) - @Delete('me') - async deleteUser( - @Body() userData: DeleteUserDto, - @User('userId') userId: string, + // TODO: 限制调用频率,避免暴力破解 + @Post('token') + @ApiOperation({ summary: '邮箱登录' }) + async loginByEmail(@Body() user: LoginInputDto) { + return this.usersService.loginByEmail(user.email, user.password) + } + + @Patch(':id/?field=password') + @ApiOperation({ summary: '找回密码' }) + async forgetPassword( + @Body() payload: ResetPassword, + @Param('id') userId: string, ) { - return this.userService.deleteUser(userData, userId) - } - - @ApiTags('Me') - @NeedAuth() - @ApiOperation({ summary: '修改密码' }) - @UseInterceptors(PasswordInterceptor) - @Patch('me/password') - async changePassword( - @Body() payload: ChangePassword, - @User('userId') userId: string, - ) { - return this.userService.changePasswor(payload, userId) - } - - @ApiTags('User') - @ApiOperation({ summary: '忘记密码' }) - @UseInterceptors(PasswordInterceptor) - @Patch('password') - async forgetPassword(@Body() payload: ForgetPassword): Promise { - return this.userService.forgetPassword(payload) - } - - @ApiTags('Me') - @NeedAuth() - @ApiOperation({ summary: '修改邮箱(TODO)' }) - @UseInterceptors(PasswordInterceptor) - @Patch('me/email') - async updateEmail(@Body() payload: unknown) { - return '修改邮箱' + return this.usersService.resetPasswordByEmail(payload, userId) } } diff --git a/src/users/users.module.ts b/src/users/users.module.ts index 3f60d8c..68a673c 100644 --- a/src/users/users.module.ts +++ b/src/users/users.module.ts @@ -3,12 +3,12 @@ import { UsersService } from './users.service' import { UsersController } from './users.controller' import { JwtService } from '@nestjs/jwt' import { EmailService } from 'src/email/email.service' -import { TokenController } from './token.controller' -import { TokenService } from './token.service' +import { MeService } from './me.service' +import { MeController } from './me.controller' @Module({ - controllers: [UsersController, TokenController], - providers: [UsersService, JwtService, EmailService, TokenService], + controllers: [UsersController, MeController], + providers: [UsersService, JwtService, EmailService, MeService], exports: [UsersService], }) export class UsersModule {} diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 9566fed..99642b8 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -1,15 +1,13 @@ -import { Inject, Injectable, ForbiddenException } from '@nestjs/common' +import { Inject, Injectable, UnauthorizedException } from '@nestjs/common' import * as bcrypt from 'bcrypt' import { PrismaService } from 'nestjs-prisma' import { JwtService } from '@nestjs/jwt' -import { securityConfig, SecurityConfig } from 'src/common/configs' +import { securityConfig, type SecurityConfig } from 'src/common/configs' import { CreateUserDto } from 'src/users/dto/create-user.dto' -import { EmailSendDto, EmailScene } from 'src/email/dto/email.dto' +import { EmailScene } from 'src/email/dto/email.dto' import { EmailService } from 'src/email/email.service' -import { TokenService } from './token.service' -import { ForgetPassword } from './dto/forget-password.dto' -import { DeleteUserDto } from './dto/delete-user.dto' -import { ChangePassword } from './dto/change-password.dto' +import { ResetPassword } from './dto/reset-password.dto' +import { Token, TokenPayload } from './dto/token.dto' @Injectable() export class UsersService { @@ -17,109 +15,70 @@ export class UsersService { private jwtService: JwtService, private prismaService: PrismaService, private emailService: EmailService, - private tokenService: TokenService, - @Inject(securityConfig.KEY) - private secureConfig: SecurityConfig, + @Inject(securityConfig.KEY) private secureConfig: SecurityConfig, ) {} - async register(userToCreate: CreateUserDto) { - await this.verifyEmail( - userToCreate.email, - userToCreate.token, - userToCreate.verifyCode, - EmailScene.register, - ) + async registerByEmail(userToCreate: CreateUserDto) { + const { email, token, verifyCode, username, password } = userToCreate + await this.emailService.verifyEmail({ + email, + token, + verifyCode, + scene: EmailScene.register, + }) const hashedPassword = await bcrypt.hash( - userToCreate.password, + password, this.secureConfig.bcryptSaltOrRound, ) const user = await this.prismaService.user.create({ - data: { - username: userToCreate.username, - email: userToCreate.email, - password: hashedPassword, - }, + data: { username, email, password: hashedPassword }, }) - return this.tokenService.generateTokens({ userId: user.id }) + return this.generateTokens({ userId: user.id }) } - async deleteUser(userToDelete: DeleteUserDto, userId: string) { - await this.verifyEmail( - userToDelete.email, - userToDelete.token, - userToDelete.verifyCode, - EmailScene.deleteUser, - ) + async loginByEmail(email: string, password: string) { const user = await this.prismaService.user.findUniqueOrThrow({ - where: { email: userToDelete.email }, + where: { email }, }) - if (user.id !== userId) { - throw new ForbiddenException() - } - const passwordValid = await bcrypt.compare( - user.password, - userToDelete.password, - ) + + const passwordValid = await bcrypt.compare(password, user.password) + if (!passwordValid) { - throw new ForbiddenException('Invalid password') + throw new UnauthorizedException('Invalid password') } - return this.prismaService.user.delete({ - where: { email: userToDelete.email }, - }) + return this.generateTokens({ userId: user.id }) } - async changePasswor(payload: ChangePassword, userId: string) { - const user = await this.prismaService.user.findFirstOrThrow({ - where: { id: userId }, + async resetPasswordByEmail(data: ResetPassword, userId: string) { + const { email, token, verifyCode, password } = data + await this.emailService.verifyEmail({ + email, + token, + verifyCode, + scene: EmailScene.forgetPassword, }) - await this.checkPassword(payload.oldPassword, user.password) const hashedPassword = await bcrypt.hash( - payload.newPassword, - this.secureConfig.bcryptSaltOrRound, - ) - return this.prismaService.user.update({ - where: { id: userId }, - data: { password: hashedPassword }, - }) - } - - async forgetPassword(payload: ForgetPassword) { - await this.verifyEmail( - payload.email, - payload.token, - payload.verifyCode, - EmailScene.forgetPassword, - ) - const hashedPassword = await bcrypt.hash( - payload.password, + password, this.secureConfig.bcryptSaltOrRound, ) const user = await this.prismaService.user.update({ - where: { email: payload.email }, + where: { id: userId }, data: { password: hashedPassword }, }) - return user + return this.generateTokens({ userId: user.id }) } - private async checkPassword(pwd: string, hashPwd: string) { - const valid = await bcrypt.compare(pwd, hashPwd) - if (!valid) { - throw new ForbiddenException('Invalid password') - } - } - - private async verifyEmail( - email: string, - token: string, - verifyCode: string, - scene: EmailScene, - ) { - const payload = this.jwtService.verify(token, { - secret: this.emailService.getEmailJwtSecret(verifyCode, scene), + private generateTokens(payload: TokenPayload): Token { + const accessToken = this.jwtService.sign(payload, { + secret: this.secureConfig.jwt_access_secret, + expiresIn: this.secureConfig.expiresIn, }) - if (payload.email !== email || payload.scene !== scene) { - throw new ForbiddenException('请输入正确的邮箱验证码') - } + const refreshToken = this.jwtService.sign(payload, { + secret: this.secureConfig.jwt_refresh_secret, + expiresIn: this.secureConfig.refreshIn, + }) + + return { accessToken, refreshToken, ...payload } } }