feat: 添加jwt相关校验

This commit is contained in:
lichao 2025-01-23 16:13:38 +08:00
parent 3f4a7e4e55
commit caa43298ef
13 changed files with 287 additions and 29 deletions

View File

@ -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",

View File

@ -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

View File

@ -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 {}

View File

@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

View File

@ -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<boolean> | Observable<boolean> {
// 检查是否有Public装饰器
const isPublic = this.reflector.getAllAndOverride<boolean>(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;
}
}

View File

@ -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;
}
}

View File

@ -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<Response>();
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(),
});
}
}

View File

@ -0,0 +1,36 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
export interface Response<T> {
code: number;
success: boolean;
data: T;
message: string;
timestamp: string;
}
@Injectable()
export class TransformInterceptor<T>
implements NestInterceptor<T, Response<T>>
{
intercept(
context: ExecutionContext,
next: CallHandler,
): Observable<Response<T>> {
return next.handle().pipe(
map((data) => ({
code: context.switchToHttp().getResponse().statusCode,
success: true,
data,
message: '请求成功',
timestamp: new Date().toISOString(),
})),
);
}
}

View File

@ -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();

View File

@ -16,4 +16,10 @@ export class User {
@Column({ default: true })
isActive: boolean;
@Column({ nullable: true })
refreshToken: string;
@Column({ nullable: true })
refreshTokenExpires: Date;
}

View File

@ -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);
}
}

View File

@ -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' },
}),
],

View File

@ -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 = {
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');
}
}
}