第 2 篇:认证(AuthN)—— 别让登录逻辑散落在 50 个文件里
在上一篇文章中,我们探讨了多租户上下文的实现。现在,让我们关注另一个基础但至关重要的横切关注点:认证(Authentication)。
认证是系统安全的第一道防线,确保只有合法用户能够访问系统资源。然而,许多团队在实现认证时,往往将登录逻辑分散在各个模块中,导致代码重复、维护困难,甚至出现安全漏洞。
JWT 解析、刷新、黑名单
JWT(JSON Web Token)是现代 Web 应用中最常用的认证机制之一。正确实现 JWT 的解析、刷新和黑名单管理是构建安全认证系统的关键。
typescript
// JWT 配置接口
export interface JwtConfig {
secret: string;
expiresIn: string;
refreshSecret: string;
refreshExpiresIn: string;
}
// JWT 载荷接口
export interface JwtPayload {
sub: string; // 用户ID
tenantId: string; // 租户ID
username: string;
email: string;
roles: string[];
iat?: number;
exp?: number;
}
// JWT 服务
@Injectable()
export class JwtService {
constructor(
private readonly configService: ConfigService,
) {}
private get jwtConfig(): JwtConfig {
return {
secret: this.configService.get<string>('JWT_SECRET'),
expiresIn: this.configService.get<string>('JWT_EXPIRES_IN', '15m'),
refreshSecret: this.configService.get<string>('JWT_REFRESH_SECRET'),
refreshExpiresIn: this.configService.get<string>('JWT_REFRESH_EXPIRES_IN', '7d'),
};
}
// 生成访问令牌
async generateAccessToken(payload: JwtPayload): Promise<string> {
return sign(payload, this.jwtConfig.secret, {
expiresIn: this.jwtConfig.expiresIn,
issuer: 'your-app',
audience: 'your-app-users',
});
}
// 生成刷新令牌
async generateRefreshToken(payload: JwtPayload): Promise<string> {
return sign(payload, this.jwtConfig.refreshSecret, {
expiresIn: this.jwtConfig.refreshExpiresIn,
issuer: 'your-app',
audience: 'your-app-users',
jwtid: uuidv4(), // 唯一标识符,用于黑名单管理
});
}
// 验证访问令牌
async verifyAccessToken(token: string): Promise<JwtPayload> {
try {
return verify(token, this.jwtConfig.secret) as JwtPayload;
} catch (error) {
if (error.name === 'TokenExpiredError') {
throw new UnauthorizedException('Access token expired');
}
throw new UnauthorizedException('Invalid access token');
}
}
// 验证刷新令牌
async verifyRefreshToken(token: string): Promise<JwtPayload & { jti: string }> {
try {
const payload = verify(token, this.jwtConfig.refreshSecret) as JwtPayload & { jti: string };
// 检查令牌是否在黑名单中
const isBlacklisted = await this.isTokenBlacklisted(payload.jti);
if (isBlacklisted) {
throw new UnauthorizedException('Token has been revoked');
}
return payload;
} catch (error) {
if (error.name === 'TokenExpiredError') {
throw new UnauthorizedException('Refresh token expired');
}
throw new UnauthorizedException('Invalid refresh token');
}
}
// 将令牌加入黑名单
async blacklistToken(jti: string, expiresAt: Date): Promise<void> {
// 在实际应用中,这应该存储在 Redis 或数据库中
// 这里使用内存存储作为示例
const blacklist = global.tokenBlacklist || new Map<string, Date>();
blacklist.set(jti, expiresAt);
global.tokenBlacklist = blacklist;
}
// 检查令牌是否在黑名单中
private async isTokenBlacklisted(jti: string): Promise<boolean> {
const blacklist = global.tokenBlacklist || new Map<string, Date>();
const expiresAt = blacklist.get(jti);
if (!expiresAt) {
return false;
}
// 如果令牌已过期,从黑名单中移除
if (expiresAt < new Date()) {
blacklist.delete(jti);
global.tokenBlacklist = blacklist;
return false;
}
return true;
}
}子域名/Path/Header 如何识别租户?
在多租户系统中,认证系统还需要能够识别当前请求属于哪个租户。这通常通过子域名、路径或请求头来实现。
typescript
// 租户识别策略接口
export interface TenantIdentificationStrategy {
identify(req: Request): string | null;
}
// 子域名识别策略
@Injectable()
export class SubdomainTenantIdentificationStrategy implements TenantIdentificationStrategy {
identify(req: Request): string | null {
const host = req.get('host') || '';
const subdomain = host.split('.')[0];
// 排除常见的子域名
if (subdomain && !['www', 'api', 'admin'].includes(subdomain)) {
return subdomain;
}
return null;
}
}
// 路径识别策略
@Injectable()
export class PathTenantIdentificationStrategy implements TenantIdentificationStrategy {
identify(req: Request): string | null {
const pathSegments = req.path.split('/');
// 例如: /tenant/tenant1/orders
if (pathSegments[1] === 'tenant' && pathSegments[2]) {
return pathSegments[2];
}
return null;
}
}
// 请求头识别策略
@Injectable()
export class HeaderTenantIdentificationStrategy implements TenantIdentificationStrategy {
identify(req: Request): string | null {
return req.headers['x-tenant-id'] as string || null;
}
}
// 租户识别服务
@Injectable()
export class TenantIdentificationService {
constructor(
private readonly subdomainStrategy: SubdomainTenantIdentificationStrategy,
private readonly pathStrategy: PathTenantIdentificationStrategy,
private readonly headerStrategy: HeaderTenantIdentificationStrategy,
) {}
identifyTenant(req: Request): string | null {
// 按优先级尝试不同的识别策略
return (
this.headerStrategy.identify(req) ||
this.subdomainStrategy.identify(req) ||
this.pathStrategy.identify(req)
);
}
}统一认证中间件设计
为了防止认证逻辑散落在各个文件中,我们需要设计统一的认证中间件:
typescript
// 认证中间件
@Injectable()
export class AuthenticationMiddleware implements NestMiddleware {
constructor(
private readonly jwtService: JwtService,
private readonly tenantIdentificationService: TenantIdentificationService,
private readonly userService: UserService,
) {}
async use(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.startsWith('Bearer ')
? authHeader.substring(7)
: null;
if (!token) {
// 对于某些公开接口,可以允许无认证访问
if (this.isPublicRoute(req)) {
return next();
}
throw new UnauthorizedException('Access token required');
}
try {
// 验证访问令牌
const payload = await this.jwtService.verifyAccessToken(token);
// 验证租户
const tenantIdFromToken = payload.tenantId;
const tenantIdFromRequest = this.tenantIdentificationService.identifyTenant(req);
if (tenantIdFromRequest && tenantIdFromToken !== tenantIdFromRequest) {
throw new UnauthorizedException('Tenant mismatch');
}
// 获取用户信息
const user = await this.userService.findById(payload.sub);
if (!user) {
throw new UnauthorizedException('User not found');
}
// 将用户和租户信息附加到请求对象
(req as any).user = user;
(req as any).tenantId = tenantIdFromToken;
next();
} catch (error) {
if (error instanceof UnauthorizedException) {
throw error;
}
throw new UnauthorizedException('Invalid token');
}
}
private isPublicRoute(req: Request): boolean {
const publicRoutes = [
'/auth/login',
'/auth/register',
'/health',
'/metrics',
];
return publicRoutes.some(route => req.path.startsWith(route));
}
}
// 用户信息接口
export interface AuthenticatedUser {
id: string;
username: string;
email: string;
tenantId: string;
roles: string[];
}
// 扩展 Express Request 接口
declare global {
namespace Express {
interface Request {
user?: AuthenticatedUser;
tenantId?: string;
}
}
}警惕:前端传 userId 后端直接信任!
一个常见的安全问题是后端直接信任前端传递的用户ID,而不进行验证。这种做法极其危险:
typescript
// 错误的做法:直接信任前端传递的用户ID
@Controller('orders')
export class OrderController {
@Post()
async createOrder(
@Body('userId') userId: string, // 危险:直接使用前端传递的用户ID
@Body() orderData: CreateOrderDto,
) {
// 任何人都可以创建属于其他用户的订单
return this.orderService.create(userId, orderData);
}
}
// 正确的做法:从认证信息中获取用户ID
@Controller('orders')
export class OrderController {
@Post()
@UseGuards(JwtAuthGuard)
async createOrder(
@Request() req, // 从认证中间件中获取用户信息
@Body() orderData: CreateOrderDto,
) {
const userId = req.user.id; // 安全:从认证令牌中获取用户ID
const tenantId = req.user.tenantId;
return this.orderService.create(userId, tenantId, orderData);
}
}完整的认证模块实现
让我们将上述组件整合成一个完整的认证模块:
typescript
// 认证模块
@Module({
imports: [
UserModule,
forwardRef(() => TenantModule),
],
providers: [
JwtService,
AuthService,
AuthenticationMiddleware,
TenantIdentificationService,
SubdomainTenantIdentificationStrategy,
PathTenantIdentificationStrategy,
HeaderTenantIdentificationStrategy,
],
controllers: [AuthController],
exports: [
JwtService,
AuthService,
AuthenticationMiddleware,
],
})
export class AuthModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(AuthenticationMiddleware)
.forRoutes({ path: '*', method: RequestMethod.ALL });
}
}
// 认证服务
@Injectable()
export class AuthService {
constructor(
private readonly userService: UserService,
private readonly jwtService: JwtService,
private readonly tenantService: TenantService,
) {}
async validateUser(username: string, password: string, tenantId: string): Promise<AuthenticatedUser | null> {
// 验证租户是否存在
const tenant = await this.tenantService.getTenant(tenantId);
if (!tenant) {
return null;
}
// 验证用户是否存在
const user = await this.userService.findByUsernameAndTenant(username, tenantId);
if (!user) {
return null;
}
// 验证密码
const isPasswordValid = await this.userService.validatePassword(user, password);
if (!isPasswordValid) {
return null;
}
return {
id: user.id,
username: user.username,
email: user.email,
tenantId: user.tenantId,
roles: user.roles,
};
}
async login(user: AuthenticatedUser): Promise<{ accessToken: string; refreshToken: string }> {
const payload: JwtPayload = {
sub: user.id,
tenantId: user.tenantId,
username: user.username,
email: user.email,
roles: user.roles,
};
const accessToken = await this.jwtService.generateAccessToken(payload);
const refreshToken = await this.jwtService.generateRefreshToken(payload);
return { accessToken, refreshToken };
}
async refresh(refreshToken: string): Promise<{ accessToken: string; refreshToken: string }> {
const payload = await this.jwtService.verifyRefreshToken(refreshToken);
// 创建新的令牌对
const newPayload: JwtPayload = {
sub: payload.sub,
tenantId: payload.tenantId,
username: payload.username,
email: payload.email,
roles: payload.roles,
};
const newAccessToken = await this.jwtService.generateAccessToken(newPayload);
const newRefreshToken = await this.jwtService.generateRefreshToken(newPayload);
// 将旧的刷新令牌加入黑名单
const decoded = decode(refreshToken) as { exp: number; jti: string };
if (decoded && decoded.jti) {
const expiresAt = new Date(decoded.exp * 1000);
await this.jwtService.blacklistToken(decoded.jti, expiresAt);
}
return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}
async logout(refreshToken: string): Promise<void> {
try {
const payload = await this.jwtService.verifyRefreshToken(refreshToken);
const decoded = decode(refreshToken) as { exp: number; jti: string };
if (decoded && decoded.jti) {
const expiresAt = new Date(decoded.exp * 1000);
await this.jwtService.blacklistToken(decoded.jti, expiresAt);
}
} catch (error) {
// 即使令牌无效,也视为登出成功
// 这样可以防止通过登出接口探测有效令牌
}
}
}
// 认证控制器
@Controller('auth')
export class AuthController {
constructor(
private readonly authService: AuthService,
) {}
@Post('login')
async login(@Body() loginDto: LoginDto) {
const user = await this.authService.validateUser(
loginDto.username,
loginDto.password,
loginDto.tenantId,
);
if (!user) {
throw new UnauthorizedException('Invalid credentials');
}
return this.authService.login(user);
}
@Post('refresh')
async refresh(@Body('refreshToken') refreshToken: string) {
if (!refreshToken) {
throw new BadRequestException('Refresh token is required');
}
return this.authService.refresh(refreshToken);
}
@Post('logout')
async logout(@Body('refreshToken') refreshToken: string) {
if (refreshToken) {
await this.authService.logout(refreshToken);
}
return { message: 'Logged out successfully' };
}
}创业团队行动清单
立即行动:
- 实现 JWT 令牌的生成、验证和刷新机制
- 创建统一的认证中间件
- 确保用户ID从认证信息中获取,而不是前端传递
一周内完成:
- 实现多租户识别机制(子域名、路径、请求头)
- 建立令牌黑名单机制
- 添加公共路由白名单
一月内完善:
- 实现密码重置和邮箱验证功能
- 添加多因素认证支持
- 建立认证日志和监控
总结
认证系统是应用安全的基础,正确的实现需要:
- 统一管理:避免认证逻辑散落在各处
- 安全验证:永远不要信任前端传递的用户身份信息
- 令牌管理:正确实现 JWT 的生成、验证、刷新和黑名单
- 租户识别:在多租户系统中正确识别和验证租户身份
在下一篇文章中,我们将探讨授权(Authorization)系统,这是控制用户访问权限的关键机制。