diff --git a/package.json b/package.json index 9cff2db..f4312c8 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "@nestjs/core": "^10.0.0", "@nestjs/jwt": "^10.2.0", "@nestjs/mapped-types": "*", - "@nestjs/passport": "^10.0.3", + "@nestjs/passport": "^10.0.0", "@nestjs/platform-express": "^10.0.0", "@nestjs/swagger": "^11.0.2", "@nestjs/typeorm": "^10.0.2", @@ -40,7 +40,7 @@ "dotenv": "^16.4.7", "install": "^0.13.0", "mysql2": "^3.12.0", - "passport": "^0.7.0", + "passport": "^0.6.0", "passport-github2": "^0.1.12", "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", @@ -57,7 +57,7 @@ "@types/express": "^4.17.17", "@types/jest": "^29.5.2", "@types/node": "^20.3.1", - "@types/passport-jwt": "^4.0.1", + "@types/passport-jwt": "^3.0.9", "@types/supertest": "^6.0.0", "@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/parser": "^6.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9e691a7..989a4fd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,7 +8,7 @@ specifiers: '@nestjs/core': ^10.0.0 '@nestjs/jwt': ^10.2.0 '@nestjs/mapped-types': '*' - '@nestjs/passport': ^10.0.3 + '@nestjs/passport': ^10.0.0 '@nestjs/platform-express': ^10.0.0 '@nestjs/schematics': ^10.0.0 '@nestjs/swagger': ^11.0.2 @@ -19,7 +19,7 @@ specifiers: '@types/jest': ^29.5.2 '@types/node': ^20.3.1 '@types/passport-github2': ^1.2.9 - '@types/passport-jwt': ^4.0.1 + '@types/passport-jwt': ^3.0.9 '@types/supertest': ^6.0.0 '@types/uuid': ^10.0.0 '@typescript-eslint/eslint-plugin': ^6.0.0 @@ -35,7 +35,7 @@ specifiers: install: ^0.13.0 jest: ^29.5.0 mysql2: ^3.12.0 - passport: ^0.7.0 + passport: ^0.6.0 passport-github2: ^0.1.12 passport-jwt: ^4.0.1 passport-local: ^1.0.0 @@ -60,7 +60,7 @@ dependencies: '@nestjs/core': 10.4.15_akwwzhtnoftcbx5g7sbkce2laq '@nestjs/jwt': 10.2.0_@nestjs+common@10.4.15 '@nestjs/mapped-types': 2.0.6_lhexpmbaszs2uqyaxe2vaiymm4 - '@nestjs/passport': 10.0.3_zkygu43hvfc54d4x4wrnlrmh5q + '@nestjs/passport': 10.0.3_kyxbe6v4wnr5hwxeohqnnyhglu '@nestjs/platform-express': 10.4.15_5u4hn6whjn5aawl2edmluzh4i4 '@nestjs/swagger': 11.0.2_f2iyopv64iutag35mprrixece4 '@nestjs/typeorm': 10.0.2_5ay6scu5luhdsc23ie3iqrw3sm @@ -74,7 +74,7 @@ dependencies: dotenv: 16.4.7 install: 0.13.0 mysql2: 3.12.0 - passport: 0.7.0 + passport: 0.6.0 passport-github2: 0.1.12 passport-jwt: 4.0.1 passport-local: 1.0.0 @@ -91,7 +91,7 @@ devDependencies: '@types/express': 4.17.21 '@types/jest': 29.5.14 '@types/node': 20.17.12 - '@types/passport-jwt': 4.0.1 + '@types/passport-jwt': 3.0.13 '@types/supertest': 6.0.2 '@typescript-eslint/eslint-plugin': 6.21.0_wj7xg5aijo4gzz5szz6d7vhele '@typescript-eslint/parser': 6.21.0_6txzh3afdjfsavlpa2fczfkiua @@ -1056,14 +1056,14 @@ packages: reflect-metadata: 0.1.14 dev: false - /@nestjs/passport/10.0.3_zkygu43hvfc54d4x4wrnlrmh5q: + /@nestjs/passport/10.0.3_kyxbe6v4wnr5hwxeohqnnyhglu: resolution: {integrity: sha512-znJ9Y4S8ZDVY+j4doWAJ8EuuVO7SkQN3yOBmzxbGaXbvcSwFDAdGJ+OMCg52NdzIO4tQoN4pYKx8W6M0ArfFRQ==} peerDependencies: '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 passport: ^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0 dependencies: '@nestjs/common': 10.4.15_rcbhqa4so6dtcde4mhxkgzk6je - passport: 0.7.0 + passport: 0.6.0 dev: false /@nestjs/platform-express/10.4.15_5u4hn6whjn5aawl2edmluzh4i4: @@ -1407,9 +1407,10 @@ packages: '@types/passport-oauth2': 1.4.17 dev: false - /@types/passport-jwt/4.0.1: - resolution: {integrity: sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==} + /@types/passport-jwt/3.0.13: + resolution: {integrity: sha512-fjHaC6Bv8EpMMqzTnHP32SXlZGaNfBPC/Po5dmRGYi2Ky7ljXPbGnOy+SxZqa6iZvFgVhoJ1915Re3m93zmcfA==} dependencies: + '@types/express': 4.17.21 '@types/jsonwebtoken': 9.0.5 '@types/passport-strategy': 0.2.38 dev: true @@ -4740,8 +4741,8 @@ packages: engines: {node: '>= 0.4.0'} dev: false - /passport/0.7.0: - resolution: {integrity: sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==} + /passport/0.6.0: + resolution: {integrity: sha512-0fe+p3ZnrWRW74fe8+SvCyf4a3Pb2/h7gFkQ8yTJpAO50gDzlfjZUZTO1k5Eg9kUct22OxHLqDZoKUWRHOh9ug==} engines: {node: '>= 0.4.0'} dependencies: passport-strategy: 1.0.0 diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index a484e06..8e8b7b7 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -1,11 +1,28 @@ import { Module } from '@nestjs/common'; +import { AuthService } from './auth.service'; +import { UserModule } from '../user/user.module'; import { PassportModule } from '@nestjs/passport'; -import { AuthController } from './auth.controller'; -import { GitHubStrategy } from './github.strategy'; +import { JwtModule } from '@nestjs/jwt'; +import { JwtStrategy } from './strategies/jwt.strategy'; +import { APP_GUARD } from '@nestjs/core'; +import { JwtAuthGuard } from './guards/jwt-auth.guard'; @Module({ - imports: [PassportModule], - controllers: [AuthController], - providers: [GitHubStrategy], + imports: [ + UserModule, + PassportModule.register({ defaultStrategy: 'jwt' }), + JwtModule.register({ + secret: 'your-secret-key', // 使用与 JwtStrategy 相同的密钥 + signOptions: { expiresIn: '1h' }, + }), + ], + providers: [ + AuthService, + JwtStrategy, + { + provide: APP_GUARD, + useClass: JwtAuthGuard, + }, + ], }) export class AuthModule {} diff --git a/src/auth/decorators/public.decorator.ts b/src/auth/decorators/public.decorator.ts new file mode 100644 index 0000000..b3845e1 --- /dev/null +++ b/src/auth/decorators/public.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const IS_PUBLIC_KEY = 'isPublic'; +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); diff --git a/src/auth/guards/jwt-auth.guard.ts b/src/auth/guards/jwt-auth.guard.ts new file mode 100644 index 0000000..6fb27d8 --- /dev/null +++ b/src/auth/guards/jwt-auth.guard.ts @@ -0,0 +1,39 @@ +import { + Injectable, + ExecutionContext, + UnauthorizedException, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { Reflector } from '@nestjs/core'; +import { IS_PUBLIC_KEY } from '../decorators/public.decorator'; +import { Observable } from 'rxjs'; + +@Injectable() +export class JwtAuthGuard extends AuthGuard('jwt') { + constructor(private reflector: Reflector) { + super(); + } + + canActivate( + context: ExecutionContext, + ): boolean | Promise | Observable { + // 检查是否有Public装饰器 + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (isPublic) { + return true; + } + + return super.canActivate(context); + } + + handleRequest(err: any, user: any) { + if (err || !user) { + throw new UnauthorizedException('请先登录'); + } + return user; + } +} diff --git a/src/auth/strategies/jwt.strategy.ts b/src/auth/strategies/jwt.strategy.ts new file mode 100644 index 0000000..49bc48c --- /dev/null +++ b/src/auth/strategies/jwt.strategy.ts @@ -0,0 +1,26 @@ +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { PassportStrategy } from '@nestjs/passport'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { UserService } from '../../user/user.service'; + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy) { + constructor(private userService: UserService) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: 'your-secret-key', // 必须与 JwtModule 中的密钥相同 + }); + } + + async validate(payload: any) { + const user = await this.userService.findUserByUsername(payload.username); + if (!user) { + throw new UnauthorizedException(); + } + if (!user.isActive) { + throw new UnauthorizedException('用户已被禁用'); + } + return user; + } +} diff --git a/src/common/filters/http-exception.filter.ts b/src/common/filters/http-exception.filter.ts new file mode 100644 index 0000000..38c5ad4 --- /dev/null +++ b/src/common/filters/http-exception.filter.ts @@ -0,0 +1,35 @@ +import { + ExceptionFilter, + Catch, + ArgumentsHost, + HttpException, +} from '@nestjs/common'; +import { Response } from 'express'; + +@Catch(HttpException) +export class HttpExceptionFilter implements ExceptionFilter { + catch(exception: HttpException, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const status = exception.getStatus(); + const exceptionResponse = exception.getResponse(); + + let message = '请求失败'; + if (typeof exceptionResponse === 'string') { + message = exceptionResponse; + } else if ( + typeof exceptionResponse === 'object' && + 'message' in exceptionResponse + ) { + message = (exceptionResponse as { message: string }).message; + } + + response.status(status).json({ + code: status, + success: false, + message, + data: null, + timestamp: new Date().toISOString(), + }); + } +} diff --git a/src/common/interceptors/transform.interceptor.ts b/src/common/interceptors/transform.interceptor.ts new file mode 100644 index 0000000..d059b79 --- /dev/null +++ b/src/common/interceptors/transform.interceptor.ts @@ -0,0 +1,36 @@ +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +export interface Response { + code: number; + success: boolean; + data: T; + message: string; + timestamp: string; +} + +@Injectable() +export class TransformInterceptor + implements NestInterceptor> +{ + intercept( + context: ExecutionContext, + next: CallHandler, + ): Observable> { + return next.handle().pipe( + map((data) => ({ + code: context.switchToHttp().getResponse().statusCode, + success: true, + data, + message: '请求成功', + timestamp: new Date().toISOString(), + })), + ); + } +} diff --git a/src/main.ts b/src/main.ts index 124a695..01ebb28 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,6 +3,8 @@ import { AppModule } from './app.module'; import * as mysql from 'mysql2/promise'; import { ConfigService } from '@nestjs/config'; import * as dotenv from 'dotenv'; +import { HttpExceptionFilter } from './common/filters/http-exception.filter'; +import { TransformInterceptor } from './common/interceptors/transform.interceptor'; dotenv.config(); @@ -26,6 +28,12 @@ async function bootstrap() { credentials: true, // 允许带有凭证(cookies)的请求 }); + // 注册全局异常过滤器 + app.useGlobalFilters(new HttpExceptionFilter()); + + // 注册全局响应转换拦截器 + app.useGlobalInterceptors(new TransformInterceptor()); + await app.listen(3030); } bootstrap(); diff --git a/src/user/entities/user.entity.ts b/src/user/entities/user.entity.ts index 18bce22..3d0c458 100644 --- a/src/user/entities/user.entity.ts +++ b/src/user/entities/user.entity.ts @@ -16,4 +16,10 @@ export class User { @Column({ default: true }) isActive: boolean; + + @Column({ nullable: true }) + refreshToken: string; + + @Column({ nullable: true }) + refreshTokenExpires: Date; } diff --git a/src/user/user.controller.ts b/src/user/user.controller.ts index f8c733c..a5585b4 100644 --- a/src/user/user.controller.ts +++ b/src/user/user.controller.ts @@ -1,14 +1,26 @@ -import { Controller, Post, Body, UnauthorizedException } from '@nestjs/common'; +import { + Controller, + Post, + Body, + UnauthorizedException, + BadRequestException, + Get, +} from '@nestjs/common'; import { UserService } from '../user/user.service'; +import { Public } from '../auth/decorators/public.decorator'; @Controller('user') export class UserController { constructor(private readonly userService: UserService) {} + @Public() @Post('register') async register( @Body() body: { username: string; password: string; email: string }, ) { + if (!body.password) { + throw new BadRequestException('Password is required'); + } const { username, password, email } = body; const user = await this.userService.createUser(username, password, email); const token = await this.userService.generateToken(user); @@ -19,6 +31,7 @@ export class UserController { }; } + @Public() @Post('login') async login(@Body() body: { username: string; password: string }) { const { username, password } = body; @@ -40,4 +53,10 @@ export class UserController { ...token, }; } + + @Public() + @Post('refresh-token') + async refreshToken(@Body() body: { refresh_token: string }) { + return this.userService.refreshAccessToken(body.refresh_token); + } } diff --git a/src/user/user.module.ts b/src/user/user.module.ts index 6f4b8e8..75f9b7f 100644 --- a/src/user/user.module.ts +++ b/src/user/user.module.ts @@ -9,7 +9,7 @@ import { UserController } from './user.controller'; imports: [ TypeOrmModule.forFeature([User]), JwtModule.register({ - secret: 'your-secret-key', // 建议从环境变量中读取 + secret: 'your-secret-key', // 必须与 JwtStrategy 中的密钥相同 signOptions: { expiresIn: '24h' }, }), ], diff --git a/src/user/user.service.ts b/src/user/user.service.ts index df8a0c7..923a06e 100644 --- a/src/user/user.service.ts +++ b/src/user/user.service.ts @@ -1,9 +1,10 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { JwtService } from '@nestjs/jwt'; import { User } from './entities/user.entity'; import * as bcrypt from 'bcrypt'; +import { MoreThan } from 'typeorm'; @Injectable() export class UserService { @@ -14,7 +15,6 @@ export class UserService { ) {} async createUser(username: string, password: string, email: string) { - console.log(username, password, email); if (!password) { throw new Error('Password is required'); } @@ -43,12 +43,79 @@ export class UserService { } async generateToken(user: User) { - const payload = { - sub: user.id, - username: user.username, - }; + const accessTokenExpiresIn = 3600; // 1小时,单位:秒 + const refreshTokenExpiresIn = 7 * 24 * 3600; // 7天,单位:秒 + + const accessToken = this.jwtService.sign( + { + sub: user.id, + username: user.username, + }, + { expiresIn: `${accessTokenExpiresIn}s` }, + ); + + const refreshToken = this.jwtService.sign( + { + sub: user.id, + }, + { expiresIn: `${refreshTokenExpiresIn}s` }, + ); + + // 保存 refresh token 到数据库 + const refreshTokenExpires = new Date(); + refreshTokenExpires.setSeconds( + refreshTokenExpires.getSeconds() + refreshTokenExpiresIn, + ); + + await this.userRepository.update(user.id, { + refreshToken, + refreshTokenExpires, + }); + return { - access_token: this.jwtService.sign(payload), + access_token: accessToken, + refresh_token: refreshToken, + expires_in: accessTokenExpiresIn, + expires_at: Date.now() + accessTokenExpiresIn * 1000, + refresh_token_expires_in: refreshTokenExpiresIn, + refresh_token_expires_at: refreshTokenExpires.getTime(), }; } + + async refreshAccessToken(refreshToken: string) { + try { + // 验证 refresh token + const payload = this.jwtService.verify(refreshToken); + const user = await this.userRepository.findOne({ + where: { + id: payload.sub, + refreshToken, + refreshTokenExpires: MoreThan(new Date()), + }, + }); + + if (!user) { + throw new UnauthorizedException('Invalid refresh token'); + } + + // 生成新的 access token + const accessTokenExpiresIn = 3600; // 1小时,单位:秒 + + const accessToken = this.jwtService.sign( + { + sub: user.id, + username: user.username, + }, + { expiresIn: `${accessTokenExpiresIn}s` }, + ); + + return { + access_token: accessToken, + expires_in: accessTokenExpiresIn, + expires_at: Date.now() + accessTokenExpiresIn * 1000, + }; + } catch { + throw new UnauthorizedException('Invalid refresh token'); + } + } }