diff --git a/package.json b/package.json index 55dac81..1e1edff 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,8 @@ "bcrypt": "^5.1.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", + "cookie-parser": "^1.4.6", + "ms": "3.0.0-canary.1", "nestjs-prisma": "^0.20.0", "nodemailer": "^6.9.1", "passport": "^0.6.0", @@ -50,6 +52,7 @@ "@nestjs/cli": "^9.0.0", "@nestjs/schematics": "^9.0.0", "@types/bcrypt": "^5.0.0", + "@types/cookie-parser": "^1.4.3", "@types/express": "^4.17.13", "@types/node": "18.11.18", "@types/passport-jwt": "^3.0.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6011444..e2ff98f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,7 @@ specifiers: '@nestjs/swagger': ^6.2.1 '@prisma/client': ^4.10.1 '@types/bcrypt': ^5.0.0 + '@types/cookie-parser': ^1.4.3 '@types/express': ^4.17.13 '@types/jsonwebtoken': ^9.0.1 '@types/node': 18.11.18 @@ -23,11 +24,13 @@ specifiers: bcrypt: ^5.1.0 class-transformer: ^0.5.1 class-validator: ^0.14.0 + cookie-parser: ^1.4.6 eslint: ^8.0.1 eslint-config-prettier: ^8.3.0 eslint-plugin-prettier: ^4.0.0 husky: ^8.0.0 lint-staged: ^13.1.2 + ms: 3.0.0-canary.1 nestjs-prisma: ^0.20.0 nodemailer: ^6.9.1 passport: ^0.6.0 @@ -58,6 +61,8 @@ dependencies: bcrypt: 5.1.0 class-transformer: 0.5.1 class-validator: 0.14.0 + cookie-parser: 1.4.6 + ms: 3.0.0-canary.1 nestjs-prisma: 0.20.0_uhhmeuf5jto6tk72f36tv2cdfe nodemailer: 6.9.1 passport: 0.6.0 @@ -69,6 +74,7 @@ devDependencies: '@nestjs/cli': 9.2.0 '@nestjs/schematics': 9.0.4_typescript@4.9.5 '@types/bcrypt': 5.0.0 + '@types/cookie-parser': 1.4.3 '@types/express': 4.17.17 '@types/node': 18.11.18 '@types/passport-jwt': 3.0.8 @@ -756,6 +762,12 @@ packages: '@types/node': 18.11.18 dev: true + /@types/cookie-parser/1.4.3: + resolution: {integrity: sha512-CqSKwFwefj4PzZ5n/iwad/bow2hTCh0FlNAeWLtQM3JA/NX/iYagIpWG2cf1bQKQ2c9gU2log5VUCrn7LDOs0w==} + dependencies: + '@types/express': 4.17.17 + dev: true + /@types/ejs/3.1.2: resolution: {integrity: sha512-ZmiaE3wglXVWBM9fyVC17aGPkLo/UgaOjEiI2FXQfyczrCefORPxIe+2dVmnmk3zkVIbizjrlQzmPGhSYGXG5g==} requiresBuild: true @@ -1754,10 +1766,23 @@ packages: engines: {node: '>= 0.6'} dev: false + /cookie-parser/1.4.6: + resolution: {integrity: sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==} + engines: {node: '>= 0.8.0'} + dependencies: + cookie: 0.4.1 + cookie-signature: 1.0.6 + dev: false + /cookie-signature/1.0.6: resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} dev: false + /cookie/0.4.1: + resolution: {integrity: sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==} + engines: {node: '>= 0.6'} + dev: false + /cookie/0.5.0: resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==} engines: {node: '>= 0.6'} @@ -3989,6 +4014,11 @@ packages: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} dev: false + /ms/3.0.0-canary.1: + resolution: {integrity: sha512-kh8ARjh8rMN7Du2igDRO9QJnqCb2xYTJxyQYK7vJJS4TvLLmsbyhiKpSW+t+y26gyOyMd0riphX0GeWKU3ky5g==} + engines: {node: '>=12.13'} + dev: false + /multer/1.4.4-lts.1: resolution: {integrity: sha512-WeSGziVj6+Z2/MwQo3GvqzgR+9Uc+qt8SwHKh3gvNPiISKfsMfG4SvCOFYlxxgkXt7yIV2i1yczehm0EOKIxIg==} engines: {node: '>= 6.0.0'} diff --git a/src/common/configs/security.config.ts b/src/common/configs/security.config.ts index 191891d..2881e44 100644 --- a/src/common/configs/security.config.ts +++ b/src/common/configs/security.config.ts @@ -1,10 +1,11 @@ import { registerAs, ConfigType } from '@nestjs/config' +import ms, { StringValue } from 'ms' export const securityConfig = registerAs('security', () => ({ jwt_access_secret: process.env.JWT_ACCESS_SECRET || 'JWT_ACCESS_SECRET', jwt_refresh_secret: process.env.JWT_REFRESH_SECRET || 'JWT_REFRESH_SECRET', - expiresIn: process.env.expiresIn || '15m', - refreshIn: process.env.refreshIn || '7d', + expiresIn: ms((process.env.expiresIn || '15m') as StringValue), + refreshIn: ms((process.env.refreshIn || '7d') as StringValue), bcryptSaltOrRound: Number(process.env.bcryptSaltOrRound) || 10, })) diff --git a/src/common/decorators/cookies.decorator.ts b/src/common/decorators/cookies.decorator.ts new file mode 100644 index 0000000..2be785f --- /dev/null +++ b/src/common/decorators/cookies.decorator.ts @@ -0,0 +1,9 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common' + +export const Cookies = createParamDecorator( + (data: string, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest() + + return data ? request.cookies?.[data] : request.cookies + }, +) diff --git a/src/main.ts b/src/main.ts index 6c5b1de..4b26a46 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,6 +3,7 @@ import { ValidationPipe } from '@nestjs/common' import { ConfigService } from '@nestjs/config' import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger' import { PrismaClientExceptionFilter, PrismaService } from 'nestjs-prisma' +import * as CookieParser from 'cookie-parser' import { JwtExceptionsFilter } from './common/filters/jwt-exceptions.filter' import { AppModule } from './app.module' @@ -21,9 +22,13 @@ async function bootstrap() { app.useGlobalFilters(new PrismaClientExceptionFilter(httpAdapter)) app.useGlobalFilters(new JwtExceptionsFilter(httpAdapter)) + // CookieParser + app.use(CookieParser()) + // Swagger Api const options = new DocumentBuilder() .addBearerAuth() + .addCookieAuth('refreshToken') .setTitle('Nest Project') .setDescription('The Nest-Project API description') .setVersion('1.0') diff --git a/src/users/dto/token.dto.ts b/src/users/dto/token.dto.ts index 8a3824d..4e6549d 100644 --- a/src/users/dto/token.dto.ts +++ b/src/users/dto/token.dto.ts @@ -1,6 +1,5 @@ import '@nestjs/mapped-types' import { ApiProperty } from '@nestjs/swagger' -import { IsString } from 'class-validator' export class Token { @ApiProperty() @@ -9,11 +8,6 @@ export class Token { refreshToken: string } -export class TokenRefreshPayload { - @IsString() - refreshToken: string -} - export class TokenPayload { userId: string } diff --git a/src/users/me.controller.ts b/src/users/me.controller.ts index 30d6063..bc6d8d2 100644 --- a/src/users/me.controller.ts +++ b/src/users/me.controller.ts @@ -5,10 +5,14 @@ import { Patch, Put, Body, + Res, UseInterceptors, BadRequestException, Query, + UnauthorizedException, } from '@nestjs/common' +import type { Response } from 'express' +import { Cookies } from 'src/common/decorators/cookies.decorator' import { MeService } from './me.service' import { ApiTags, ApiOperation, ApiUnauthorizedResponse } from '@nestjs/swagger' import { User } from 'src/common/decorators/user.decorator' @@ -19,7 +23,6 @@ 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' import { ChangeEmailDto } from './dto/change-email.dto' @Controller('api/users/me') @@ -93,7 +96,18 @@ export class MeController { @Put('token') @ApiOperation({ summary: '刷新token' }) @ApiUnauthorizedResponse({ description: 'Unauthorized' }) - async updateAccessToken(@Body() payload: TokenRefreshPayload) { - return this.meService.updateAccessToken(payload.refreshToken) + async updateAccessToken( + @Res({ passthrough: true }) res: Response, + @Cookies('refreshToken') refreshToken: string, + ) { + if (!refreshToken) { + throw new UnauthorizedException('no refresh token') + } + try { + return this.meService.updateAccessToken(refreshToken) + } catch (err) { + res.clearCookie('refreshToken') + throw err + } } } diff --git a/src/users/me.service.ts b/src/users/me.service.ts index 92b8a6f..ec4812c 100644 --- a/src/users/me.service.ts +++ b/src/users/me.service.ts @@ -77,17 +77,13 @@ export class MeService { 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, - }, - ), - } + return this.jwtService.sign( + { userId }, + { + secret: this.secureConfig.jwt_access_secret, + expiresIn: this.secureConfig.refreshIn, + }, + ) } private async checkPassword(pwd: string, hashPwd: string) { diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index 37bdaac..132de19 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -1,31 +1,64 @@ -import { Controller, Post, Patch, Body, Param } from '@nestjs/common' +import { Controller, Inject, Post, Patch, Body, Res } from '@nestjs/common' +import type { Response, CookieOptions } from 'express' import { UsersService } from './users.service' import { ApiTags, ApiOperation } from '@nestjs/swagger' import { CreateUserDto } from './dto/create-user.dto' import { LoginInputDto } from './dto/login-input.dto' import { ResetPassword } from './dto/reset-password.dto' +import { securityConfig, SecurityConfig } from 'src/common/configs' @Controller('api/users') @ApiTags('Users') export class UsersController { - constructor(private readonly usersService: UsersService) {} + private refreshTokenCookieConfig: CookieOptions = { + path: '/api/users/me/token', + secure: true, + maxAge: this.secureConfig.refreshIn / 1000, + httpOnly: true, + sameSite: 'lax', + } + + constructor( + private readonly usersService: UsersService, + @Inject(securityConfig.KEY) private secureConfig: SecurityConfig, + ) {} @Post() @ApiOperation({ summary: '邮箱注册' }) - async registerByEmail(@Body() userData: CreateUserDto) { - return this.usersService.registerByEmail(userData) + async registerByEmail( + @Body() userData: CreateUserDto, + @Res({ passthrough: true }) res: Response, + ) { + const { refreshToken, accessToken } = + await this.usersService.registerByEmail(userData) + res.cookie('refreshToken', refreshToken, this.refreshTokenCookieConfig) + return accessToken } // TODO: 限制调用频率,避免暴力破解 @Post('token') @ApiOperation({ summary: '邮箱登录' }) - async loginByEmail(@Body() user: LoginInputDto) { - return this.usersService.loginByEmail(user.email, user.password) + async loginByEmail( + @Body() user: LoginInputDto, + @Res({ passthrough: true }) res: Response, + ) { + const { refreshToken, accessToken } = await this.usersService.loginByEmail( + user.email, + user.password, + ) + res.cookie('refreshToken', refreshToken, this.refreshTokenCookieConfig) + return accessToken } @Patch('password') @ApiOperation({ summary: '找回密码' }) - async forgetPassword(@Body() payload: ResetPassword) { - return this.usersService.resetPasswordByEmail(payload) + async forgetPassword( + @Body() payload: ResetPassword, + @Res({ passthrough: true }) res: Response, + ) { + const { refreshToken, accessToken } = + await this.usersService.resetPasswordByEmail(payload) + res.cookie('refreshToken', refreshToken, this.refreshTokenCookieConfig) + return accessToken } }