Skip to content

第 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' };
  }
}

创业团队行动清单

  1. 立即行动

    • 实现 JWT 令牌的生成、验证和刷新机制
    • 创建统一的认证中间件
    • 确保用户ID从认证信息中获取,而不是前端传递
  2. 一周内完成

    • 实现多租户识别机制(子域名、路径、请求头)
    • 建立令牌黑名单机制
    • 添加公共路由白名单
  3. 一月内完善

    • 实现密码重置和邮箱验证功能
    • 添加多因素认证支持
    • 建立认证日志和监控

总结

认证系统是应用安全的基础,正确的实现需要:

  1. 统一管理:避免认证逻辑散落在各处
  2. 安全验证:永远不要信任前端传递的用户身份信息
  3. 令牌管理:正确实现 JWT 的生成、验证、刷新和黑名单
  4. 租户识别:在多租户系统中正确识别和验证租户身份

在下一篇文章中,我们将探讨授权(Authorization)系统,这是控制用户访问权限的关键机制。