Nest 身份鉴权与权限控制
登录验证是服务端开发非常核心的一个环节,通用方案我们会选择 jwt,也就是大家熟知的 token 作为用户身份令牌,用户登录时分配 token,后续请求都携带此 token 以表明用户身份以此实现身份验证与权限控制等功能。
1. Nest 集成 jwt
在 Nest 中,JWT(JSON Web Token)身份鉴权通过 Passport 模块来实现,它提供了一种基于令牌的认证方式。
1.1. AuthController
auth.controller.ts 中定义了两个API端点:登录接口( /auth/login )和获取用户信息接口( /me )。
登录接口:
使用 @UseGuards(AuthGuard('local')) 注解,这是应用了 local 策略的认证守卫,local 策略用于验证用户的用户名和密码。
当客户端发送登录请求时,守卫会触发 LocalStrategy 中的 validate 方法进行用户验证。
验证成功后,通过 this.authService.login(req.user) 生成JWT令牌并返回给客户端。
获取用户信息接口:
该接口通过 @UseGuards(AuthGuard('jwt')) 应用了 jwt 守卫,只有携带有效JWT令牌的请求才能访问。
当用户成功通过JWT守卫的认证后,可以访问其个人信息。
@UseGuards(AuthGuard('jwt'))
@Get('me')
getProfile(@Request() req) {return req.user;
}
此接口只在用户携带有效的JWT令牌时允许访问,守卫会解析并验证令牌的有效性。
1.2. AuthModule
auth.module.ts 是身份鉴权功能的配置中心。它引入了 PassportModule 和 JwtModule,并注册了 AuthService、LocalStrategy、JwtStrategy。
PassportModule:提供守卫机制,允许使用多种认证策略。
JwtModule:用于JWT的创建和验证,其中的 secret 用于签署和解析JWT,signOptions 设置令牌的有效期。
JwtModule.register({secret: jwtConstants.secret,signOptions: { expiresIn: '60s' },
})
1.3. AuthService
auth.service.ts 中定义了验证用户信息和生成JWT令牌的逻辑。
validateUser:用于用户验证,通常会通过 UsersService 查找用户,并验证用户的密码是否正确。这里用一个硬编码的用户示例替代真实用户查找逻辑。
login:登录成功后,调用 jwtService.sign(payload) 方法生成JWT令牌。 payload 中包含了用户的 username 和 sub(一般为用户ID)。
async login(user: any): Promise<any> {const payload = {username: user.username, sub: user.userId};return {access_token: this.jwtService.sign(payload),};
}
生成的令牌会返回给前端,前端在后续的请求中需要携带该令牌来访问受保护的接口。
1.4. LocalStrategy
local.strategy.ts 定义了用户名和密码的本地策略,主要用于用户登录验证。
LocalStrategy 继承自 PassportStrategy(Strategy),并实现 validate 方法。
在 validate 方法中,它会调用 AuthService.validateUser(username, password) 来验证用户名和密码。
async validate(username: string, password: string): Promise<any> {return {username, password};const user = await this.authService.validateUser(username, password);if (!user) {throw new HttpException({ message: 'authorized failed', error: 'please try again later.' },HttpStatus.BAD_REQUEST);}return user;
}
如果验证成功,用户信息将被附加到请求对象中,后续的 login 方法会根据此用户信息生成JWT令牌。
1.5. JwtStrategy
jwt.strategy.ts 定义了JWT验证的策略,主要用于解析和验证用户提供的JWT令牌。
JwtStrategy 继承自 PassportStrategy(Strategy),并配置了JWT的来源和解密密钥。
jwtFromRequest:指定如何从请求中提取JWT,代码中使用 ExtractJwt.fromHeader('token') 表示JWT从请求头中的 token 字段提取。
super({jwtFromRequest: ExtractJwt.fromHeader('token'),ignoreExpiration: false,secretOrKey: jwtConstants.secret,
})
validate 方法:验证通过后,validate 方法会被调用。这个方法的参数是JWT中的 payload,它通常包含用户的标识信息,如 username 和 sub(用户ID)。该方法返回的用户信息会附加到请求对象中,供后续路由处理使用。
async validate(payload: any) {return {userId: payload.sub, username: payload.username};
}
1.6. JWT 身份验证流程总结
整个JWT身份鉴权的处理过程如下:
用户通过 /auth/login 登录,local 策略验证用户名和密码。
验证成功后,AuthService.login 方法生成JWT令牌,令牌通过响应返回给用户。
用户在后续的请求中将JWT令牌附加到请求头的 token 字段中。
受保护的路由如 /me 使用 jwt 策略守卫,守卫通过 JwtStrategy 提取并验证JWT令牌。
验证成功后,用户信息被添加到请求对象,路由处理器可以基于此用户信息返回数据。
2. 身份鉴权方案盘点
在现代Web应用中,身份鉴权与权限控制是非常重要的一部分,尤其是涉及用户登录、资源访问的安全性时。身份鉴权的方案有多种,每种方案都有其适用的场景和优缺点。
2.1. 使用 Cookie 进行身份鉴权
主要特点:
适用于传统的Web系统。
通常会在服务器端创建Session来存储用户的认证信息,客户端通过Cookie携带Session ID来与服务器通信。
Cookie受限于同源策略,因此在跨平台场景中(如移动App)使用不便。
适用场景:
单纯的Web应用,安全性要求较高时,可以结合服务器的Session机制。
实现示例:
1. 服务端创建Session并设置Cookie
import { Controller, Post, Req, Res } from '@nestjs/common';
import { Response, Request } from 'express';@Controller('auth')
export class AuthController {@Post('login')login(@Req() req: Request, @Res() res: Response) {const user = { id: 1, name: 'test' }; // 模拟用户信息req.session.user = user; // 将用户信息存储在Session中res.cookie('sessionId', req.sessionID); // 设置Cookiereturn res.send({ message: 'Logged in successfully' });}@Post('logout')logout(@Req() req: Request, @Res() res: Response) {req.session.destroy(() => {res.clearCookie('sessionId'); // 清除Cookieres.send({ message: 'Logged out successfully' });});}
}
2. 配置Session
在NestJS中,可以使用 express-session 来管理会话。
import * as session from 'express-session';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';async function bootstrap() {const app = await NestFactory.create(AppModule);app.use(session({secret: 'my-secret', // 设置Session密钥resave: false,saveUninitialized: false,}));await app.listen(3000);
}
bootstrap();
2.2. 使用单 Token(accessToken)
主要特点:
适合跨平台使用,如Web、App等场景。
只使用一个短时效的 accessToken 进行身份验证。
但由于 accessToken 直接用于认证,如果被盗用,容易造成安全问题。
适用场景:
- 对安全性要求较低的系统或短时交互需求的应用。
实现示例:
1. 用户登录获取 accessToken
@Controller('auth')
export class AuthController {constructor(private readonly jwtService: JwtService) {}@Post('login')async login(@Req() req: Request, @Res() res: Response) {const user = { id: 1, name: 'test' }; // 模拟用户const payload = { userId: user.id };const accessToken = this.jwtService.sign(payload, { expiresIn: '15m' }); // 签发15分钟有效return res.send({ accessToken });}
}
2. 保护受限路由
@UseGuards(AuthGuard('jwt'))
@Get('protected')
getProtectedData(@Request() req) {return { message: 'This is protected data', user: req.user };
}
2.3. 使用双 Token(refreshToken + accessToken)
主要特点:
常见的方案,用于实现“无感刷新”。
accessToken 有效期较短,用于认证请求;refreshToken 有效期较长,仅用于刷新新的 accessToken。
refreshToken 在服务端安全存储,较少暴露,防止滥用。
适用场景:
- 安全性要求高且需要长期登录状态的场景,如电子商务、社交平台等。
实现示例:
1. 用户登录获取 accessToken 与 refreshToken。
@Controller('auth')
export class AuthController {constructor(private readonly jwtService: JwtService) {}@Post('login')async login(@Req() req: Request, @Res() res: Response) {const user = { id: 1, name: 'test' };const payload = { userId: user.id };// 生成短效的accessToken (15分钟)const accessToken = this.jwtService.sign(payload, { expiresIn: '15m' });// 生成长效的refreshToken (7天)const refreshToken = this.jwtService.sign(payload, { expiresIn: '7d' });return res.send({ accessToken, refreshToken });}
}
2. 刷新accessToken
@Controller('auth')
export class AuthController {constructor(private readonly jwtService: JwtService) {}@Post('refresh')async refresh(@Req() req: Request, @Res() res: Response) {const { refreshToken } = req.body;try {const payload = this.jwtService.verify(refreshToken);const newAccessToken = this.jwtService.sign({ userId: payload.userId }, { expiresIn: '15m' });return res.send({ accessToken: newAccessToken });} catch (e) {return res.status(401).send({ message: 'Invalid refresh token' });}}
}
2.4. 改良版双 Token
主要特点:
accessToken 有效期较短,存储在 HTTP Only 的 Cookie 中,确保安全性,并避免通过 JS 访问到它。
refreshToken 存储在数据库中,并与用户绑定,每次发起请求时都会验证其有效性。
能够通过失效服务器端存储的 refreshToken 来让用户下线。
适用场景:
- 需要支持安全的跨平台身份验证,且需要能让用户手动或自动退出登录。
实现思路:
1. accessToken 存储在 Cookie 中,用于短时间的验证,过期后需要使用 refreshToken 刷新。
2. refreshToken 存储在服务器端数据库 redis 中,只有在 refreshToken 有效的情况下,才能生成新的 accessToken。
3. 用户踢下线功能:管理员可随时从数据库中删除或标记失效某个 refreshToken,这样该用户在 accessToken 过期时将无法刷新新的 accessToken,即被踢下线。
实现示例:
我们可以将 refreshToken 存储在 Redis 中,Redis是一种内存型数据库,非常适合用作缓存或短期数据存储,尤其是在存储如 refreshToken 这样的短期有效的数据时。我们可以将每个用户的 refreshToken 与其ID相关联,并设置过期时间,确保在Redis中存储的 refreshToken 能够自动失效。
在NestJS中,可以使用 @nestjs/redis 或 ioredis 库来集成Redis,以下是通过 ioredis 库实现的示例。
1. 安装依赖
npm install ioredis @nestjs/bull
2. 配置Redis
在NestJS的模块中配置Redis客户端:
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import Redis from 'ioredis';@Module({providers: [AuthService,{provide: 'REDIS',useFactory: () => {return new Redis({host: 'localhost', // Redis服务器地址port: 6379, // Redis服务器端口});},},],exports: [AuthService],
})
export class AuthModule {}
2.4.1. 存储 refreshToken 到 Redis
我们将 refreshToken 和用户ID关联,并将其存储到Redis中,设置一个与Token有效期匹配的过期时间。
import { Injectable, Inject } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import Redis from 'ioredis';@Injectable()
export class AuthService {constructor(@Inject('REDIS') private readonly redisClient: Redis,private readonly jwtService: JwtService,) {}// 保存refreshToken到Redis,设置过期时间async saveRefreshToken(userId: number, refreshToken: string): Promise<void> {await this.redisClient.set(`refreshToken:${userId}`, refreshToken, 'EX', 7 * 24 * 60 * 60)}// 验证refreshToken的有效性async isRefreshTokenValid(userId: number, refreshToken: string): Promise<boolean> {const storedToken = await this.redisClient.get(`refreshToken:${userId}`);return storedToken === refreshToken;}// 使用户的refreshToken失效async invalidateUserRefreshTokens(userId: number): Promise<void> {await this.redisClient.del(`refreshToken:${userId}`);}
}
2.4.2. 用户登录并存储 accessToken 和 refreshToken
在用户登录时,生成并返回 accessToken 和 refreshToken,并将 refreshToken 存储到Redis中。
@Controller('auth')
export class AuthController {constructor(private readonly jwtService: JwtService, private readonly authService: AuthService) { }@Post('login')async login(@Req() req: Request, @Res() res: Response) {const user = { id: 1, name: 'testUser' };// 生成短效的accessTokenconst accessToken = this.jwtService.sign({ userId: user.id }, { expiresIn: '15m' });// 生成长效的refreshTokenconst refreshToken = this.jwtService.sign({ userId: user.id }, { expiresIn: '7d' });// 将refreshToken存储到Redis中await this.authService.saveRefreshToken(user.id, refreshToken);// 将accessToken存储在HTTP Only的Cookie中res.cookie('accessToken', accessToken, { httpOnly: true, secure: true });// 返回refreshToken给客户端存储return res.send({ refreshToken });}
}
2.4.3. 刷新 accessToken 时验证 refreshToken
用户通过 refreshToken 请求新的 accessToken。在刷新时,将从Redis中验证 refreshToken 的有效性。
@Controller('auth')
export class AuthController {constructor(private readonly jwtService: JwtService, private readonly authService: AuthService) { }@Post('refresh')async refreshToken(@Req() req: Request, @Res() res: Response) {const { refreshToken } = req.body;// 验证refreshTokenconst payload = this.jwtService.verify(refreshToken);// 检查Redis中是否存在该refreshTokenconst isValid = await this.authService.isRefreshTokenValid(payload.userId, refreshToken);if (!isValid) {return res.status(403).send({ message: 'Invalid or expired refresh token' });}// 生成新的accessTokenconst newAccessToken = this.jwtService.sign({ userId: payload.userId }, { expiresIn: '15m' });// 将新的accessToken存储在HTTP Only的Cookie中res.cookie('accessToken', newAccessToken, { httpOnly: true, secure: true });return res.send({ accessToken: newAccessToken });}
}
2.4.4. 将用户踢下线(失效 refreshToken)
管理员可以通过从Redis中删除用户的 refreshToken,让用户无法再刷新 accessToken,从而实现踢下线功能。
@Controller('auth')
export class AuthController {constructor(private readonly authService: AuthService) { }@Post('logout-user')async logoutUser(@Req() req: Request, @Res() res: Response) {const { userId } = req.body;// 删除该用户的refreshToken,失效该用户的登录状态await this.authService.invalidateUserRefreshTokens(userId);return res.send({ message: `User with ID ${userId} has been logged out.` });}
}
2.5. 总结
Cookie:适用于传统Web系统,安全性高,但跨平台能力弱。
单Token(accessToken):具备跨平台能力,但安全性较低。
双Token(refreshToken + accessToken):是常见的无感刷新方案,提升了安全性与用户体验。
改良双Token:Redis 读取和写入 refreshToken,且可以通过删除或标记失效 refreshToken,使得用户无法刷新 accessToken,从而实现用户的手动或自动下线功能。这种方案适用于Web、移动端等多平台,用户登录状态可以长期保持。