ASP.NET Core 结合 Zustand 实现事件驱动的实时前端权限状态同步架构


定义问题:无感知的权限变更

在多数系统中,用户的权限在登录时通过 JWT 或 Session 一次性获取。令牌在其生命周期内,包含的权限信息(通常是角色或声明)是固定的。如果在此期间,管理员在后台撤销了该用户的某个关键操作权限,例如“删除订单”,持有旧令牌的用户在令牌过期前,依然可以畅通无阻地执行该操作。这是一个典型的安全窗口期,在金融、风控或高权限后台管理等场景中,这种延迟是不可接受的。

传统的解决方案往往差强人意。

方案A:轮询与短令牌的权衡

一种直接的思路是缩短令牌(JWT)的有效期,例如设置为5分钟。客户端被迫频繁刷新令牌,从而获取最新的权限集。

优势:

  • 实现简单,不引入新的技术栈。
  • 无状态,符合 RESTful 设计。

劣势:

  • 网络开销与服务器压力: 客户端需要以固定的时间间隔(例如每分钟)轮询权限接口,或者在每次令牌刷新时重新获取。这在高并发场景下会产生巨大的、不必要的网络流量和服务器负载。绝大多数轮询都是空操作,因为权限变更本身是低频事件。
  • 延迟依旧存在: 权限变更的同步延迟取决于轮询间隔。如果间隔是1分钟,那么最坏情况下用户依然有长达1分钟的旧权限窗口。要缩短窗口,就必须增加轮询频率,这又会加剧服务器压力,陷入两难。
  • 糟糕的用户体验: 频繁的令牌刷新逻辑可能导致前端状态管理的复杂性增加,甚至在网络不佳时中断用户操作。

在真实项目中,这种方案通常只作为一种妥协。它能解决问题,但代价是性能和效率的损失,且无法做到真正的“实时”。对于需要即时响应的安全系统,我们必须寻找更主动的通信模型。

方案B:基于推送的实时状态同步

另一种架构思路是颠覆传统的客户端拉取(Pull)模型,转而采用服务器推送(Push)模型。当权限发生变更时,由服务器主动通知相关的在线客户端更新其本地权限状态。

技术选型考量:

  • 后端框架: ASP.NET Core。其内置的 SignalR 提供了稳定、高效的实时 Web 功能,完美契合推送模型。它的强类型 Hub 和灵活的连接管理(用户、组)是实现精确推送的关键。
  • 前端状态管理: Zustand。在 React 生态中,它以其极简的 API 和对 Hooks 的原生支持而著称。我们需要的正是一个轻量、无模板、能够轻松与外部系统(如 SignalR)集成的状态管理器,Zustand 是理想之选。
  • 安全核心: ASP.NET Core Identity 结合策略(Policy-based)授权。我们将以此为基础,构建一个能够动态响应变化的权限验证机制。

架构流程:

sequenceDiagram
    participant Admin as 管理员
    participant Backend as ASP.NET Core后端
    participant SignalRHub as SignalR Hub
    participant Frontend as 前端应用 (React+Zustand)
    participant User as 在线用户

    Admin->>+Backend: 发起API请求 (例如: 撤销用户X的 'DeleteOrder' 权限)
    Backend->>Backend: 1. 验证管理员权限
    Backend->>Backend: 2. 更新数据库中的用户权限
    Backend->>+SignalRHub: 3. 触发权限变更事件 (用户ID: X, 新权限集)
    SignalRHub->>-Frontend: 4. 向用户X的特定连接推送 `PermissionUpdated` 消息
    Frontend->>Frontend: 5. SignalR客户端接收消息
    Frontend->>Frontend: 6. 调用 Zustand Store 的 action 更新权限状态
    User->>Frontend: 尝试点击 "删除订单" 按钮
    Frontend->>Frontend: 7. UI组件根据 Zustand 中的新状态,按钮已禁用或隐藏
    Note right of Frontend: 权限变更在UI上实时生效,无需刷新页面

最终选择与理由:

我们选择方案B。尽管它引入了 SignalR 带来的状态化连接管理复杂性,但其优势是决定性的:

  1. 实时性: 权限变更近乎瞬时地同步到客户端,彻底关闭了安全窗口期。
  2. 效率: 避免了无效轮询,仅在必要时进行通信,极大降低了网络和服务器的空闲负载。
  3. 用户体验: 无感知的后台同步,前端UI可以做出即时响应(如禁用按钮、弹出提示),操作流畅自然。

对于安全性要求高的系统,这种架构带来的收益远超其增加的实现成本。接下来的部分将展示该架构的核心实现。

核心实现概览

我们将构建一个场景:管理员可以通过一个API端点动态撤销某个用户的特定权限,该用户的Web界面会实时禁用与该权限关联的功能。

1. 后端:ASP.NET Core 与 SignalR

项目设置与依赖:
首先,确保项目中已添加 SignalR。

// Program.cs
// ... other services
builder.Services.AddSignalR();
builder.Services.AddSingleton<IAuthorizationHandler, DynamicPermissionHandler>();
builder.Services.AddSingleton<IAuthorizationPolicyProvider, DynamicPermissionPolicyProvider>();
// ...

var app = builder.Build();
// ...
app.MapHub<PermissionHub>("/hubs/permissions");
// ...

权限管理与事件通知:
我们创建一个服务来管理权限变更,并在变更后通过 IHubContext 通知 SignalR Hub。

// Services/PermissionService.cs

using Microsoft.AspNetCore.SignalR;
using System.Collections.Concurrent;

public interface IPermissionService
{
    Task<List<string>> GetUserPermissionsAsync(string userId);
    Task RevokePermissionAsync(string userId, string permission);
    Task GrantPermissionAsync(string userId, string permission);
}

// 这是一个简化的内存实现,用于演示。
// 在生产环境中,这应该由数据库支持,例如使用 ASP.NET Core Identity 的 UserClaims。
public class InMemoryPermissionService : IPermissionService
{
    private readonly IHubContext<PermissionHub> _hubContext;
    private readonly ILogger<InMemoryPermissionService> _logger;

    // key: userId, value: list of permissions
    private static readonly ConcurrentDictionary<string, List<string>> UserPermissions = new();

    public InMemoryPermissionService(IHubContext<PermissionHub> hubContext, ILogger<InMemoryPermissionService> logger)
    {
        _hubContext = hubContext;
        _logger = logger;

        // 初始化一些种子数据
        UserPermissions.TryAdd("user-alice", new List<string> { "read:orders", "create:orders", "delete:orders" });
        UserPermissions.TryAdd("user-bob", new List<string> { "read:orders" });
    }

    public Task<List<string>> GetUserPermissionsAsync(string userId)
    {
        UserPermissions.TryGetValue(userId, out var permissions);
        return Task.FromResult(permissions ?? new List<string>());
    }

    public async Task RevokePermissionAsync(string userId, string permission)
    {
        if (UserPermissions.TryGetValue(userId, out var permissions))
        {
            var permissionRemoved = permissions.Remove(permission);
            if(permissionRemoved)
            {
                _logger.LogInformation("Revoking permission '{Permission}' for user '{UserId}'.", permission, userId);
                await NotifyClientOfPermissionChange(userId);
            }
        }
    }

    public async Task GrantPermissionAsync(string userId, string permission)
    {
        var permissions = UserPermissions.GetOrAdd(userId, new List<string>());
        if (!permissions.Contains(permission))
        {
            permissions.Add(permission);
            _logger.LogInformation("Granting permission '{Permission}' for user '{UserId}'.", permission, userId);
            await NotifyClientOfPermissionChange(userId);
        }
    }

    private async Task NotifyClientOfPermissionChange(string userId)
    {
        var updatedPermissions = await GetUserPermissionsAsync(userId);
        // 通过 SignalR 将最新的权限全集推送给指定用户
        // 这里的 "userId" 必须与客户端连接时提供的用户标识符一致
        await _hubContext.Clients.User(userId).SendAsync("PermissionsUpdated", updatedPermissions);
        _logger.LogInformation("Sent 'PermissionsUpdated' notification to user '{UserId}'.", userId);
    }
}

SignalR Hub:
Hub 本身非常简单。它的主要作用是管理连接。我们通过 UserIdProvider 将连接与我们的用户ID关联起来。

// Hubs/PermissionHub.cs

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using System.Security.Claims;

// 自定义 UserIdProvider,确保 SignalR 的 User(userId) 方法能正确定位
public class NameIdentifierUserIdProvider : IUserIdProvider
{
    public virtual string? GetUserId(HubConnectionContext connection)
    {
        // 在实际项目中,这通常来自 JWT 的 'sub' 或 'nameid' claim
        return connection.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
    }
}

// Program.cs 中注册:
// builder.Services.AddSingleton<IUserIdProvider, NameIdentifierUserIdProvider>();

[Authorize]
public class PermissionHub : Hub
{
    // 可以在这里处理连接事件,例如记录日志
    public override async Task OnConnectedAsync()
    {
        Context.GetHttpContext()?.RequestServices.GetService<ILogger<PermissionHub>>()?
            .LogInformation("User '{UserId}' connected to PermissionHub.", Context.UserIdentifier);
        await base.OnConnectedAsync();
    }
}

Admin API 控制器:
这个控制器模拟管理员操作,它调用 PermissionService 来修改权限。

// Controllers/AdminController.cs

[ApiController]
[Route("api/[controller]")]
// 在真实项目中,这里应该有严格的管理员权限检查
[Authorize(Roles = "Admin")]
public class AdminController : ControllerBase
{
    private readonly IPermissionService _permissionService;

    public AdminController(IPermissionService permissionService)
    {
        _permissionService = permissionService;
    }

    [HttpPost("revoke")]
    public async Task<IActionResult> RevokePermission([FromBody] PermissionChangeRequest request)
    {
        if (string.IsNullOrEmpty(request.UserId) || string.IsNullOrEmpty(request.Permission))
        {
            return BadRequest("UserId and Permission are required.");
        }

        await _permissionService.RevokePermissionAsync(request.UserId, request.Permission);
        return Ok(new { message = $"Attempted to revoke '{request.Permission}' from '{request.UserId}'." });
    }

    // DTO
    public class PermissionChangeRequest
    {
        public string UserId { get; set; } = string.Empty;
        public string Permission { get; set; } = string.Empty;
    }
}

动态权限策略处理器:
为了让后端API也能实时响应权限变更,我们需要一个自定义的 IAuthorizationHandler。它不会依赖于JWT中的静态声明,而是每次都从我们的 IPermissionService 查询实时权限。

// Security/DynamicPermissionHandler.cs

public class DynamicPermissionRequirement : IAuthorizationRequirement
{
    public string Permission { get; }
    public DynamicPermissionRequirement(string permission)
    {
        Permission = permission;
    }
}

public class DynamicPermissionHandler : AuthorizationHandler<DynamicPermissionRequirement>
{
    private readonly IPermissionService _permissionService;

    public DynamicPermissionHandler(IPermissionService permissionService)
    {
        _permissionService = permissionService;
    }

    protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, DynamicPermissionRequirement requirement)
    {
        var userId = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
        if (string.IsNullOrEmpty(userId))
        {
            context.Fail();
            return;
        }

        var userPermissions = await _permissionService.GetUserPermissionsAsync(userId);

        if (userPermissions.Contains(requirement.Permission))
        {
            context.Succeed(requirement);
        }
        else
        {
            context.Fail();
        }
    }
}

// Security/DynamicPermissionPolicyProvider.cs
// 这个 Provider 允许我们使用类似 [Authorize(Policy="delete:orders")] 的语法
// 它会自动将 "delete:orders" 字符串转换成我们的 DynamicPermissionRequirement
public class DynamicPermissionPolicyProvider : IAuthorizationPolicyProvider
{
    public Task<AuthorizationPolicy?> GetPolicyAsync(string policyName)
    {
        // 生产级代码需要更健壮的前缀检查或格式验证
        var requirement = new DynamicPermissionRequirement(policyName);
        var policy = new AuthorizationPolicyBuilder().AddRequirements(requirement).Build();
        return Task.FromResult<AuthorizationPolicy?>(policy);
    }
    
    // ... 其他接口成员的默认实现 ...
    public Task<AuthorizationPolicy> GetDefaultPolicyAsync() 
        => Task.FromResult(new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build());
    public Task<AuthorizationPolicy?> GetFallbackPolicyAsync() 
        => Task.FromResult<AuthorizationPolicy?>(null);
}

现在,我们可以在任何需要权限检查的控制器上使用这个动态策略:

// Controllers/OrdersController.cs

[ApiController]
[Route("api/[controller]")]
[Authorize]
public class OrdersController : ControllerBase
{
    // 这个端点的访问权限现在是动态的。
    // 即便用户的JWT包含了旧的权限,如果其'delete:orders'权限已被实时撤销,
    // DynamicPermissionHandler会查询到最新状态并拒绝访问。
    [HttpDelete("{id}")]
    [Authorize(Policy = "delete:orders")]
    public IActionResult DeleteOrder(int id)
    {
        // ... 删除订单的业务逻辑
        return NoContent();
    }
}

2. 前端:React 与 Zustand

安装依赖:

npm install zustand @microsoft/signalr

Zustand 安全状态存储:
创建一个 store 来统一管理认证状态和权限列表。

// store/securityStore.ts
import { create } from 'zustand';

type SecurityState = {
  isAuthenticated: boolean;
  permissions: string[];
  userId: string | null;
  setAuthState: (isAuthenticated: boolean, userId: string | null, initialPermissions: string[]) => void;
  updatePermissions: (newPermissions: string[]) => void;
  hasPermission: (permission: string) => boolean;
};

export const useSecurityStore = create<SecurityState>((set, get) => ({
  isAuthenticated: false,
  permissions: [],
  userId: null,

  setAuthState: (isAuthenticated, userId, initialPermissions) =>
    set({
      isAuthenticated,
      userId,
      permissions: initialPermissions,
    }),

  updatePermissions: (newPermissions) =>
    set({
      permissions: newPermissions,
    }),

  hasPermission: (permission) => {
    const { permissions } = get();
    // 这里的逻辑可以更复杂,例如支持通配符 `*:orders`
    return permissions.includes(permission);
  },
}));

// 导出一个方便使用的 hook,用于权限检查
export const useHasPermission = (permission: string): boolean => {
  return useSecurityStore((state) => state.hasPermission(permission));
};

SignalR 连接服务:
创建一个服务来封装 SignalR 的连接、事件监听和错误处理。

// services/permissionSignalRService.ts
import { HubConnection, HubConnectionBuilder, LogLevel } from '@microsoft/signalr';
import { useSecurityStore } from '../store/securityStore';

class PermissionSignalRService {
  private connection: HubConnection | null = null;

  public async startConnection(accessToken: string): Promise<void> {
    if (this.connection && this.connection.state === 'Connected') {
      console.log('SignalR connection already established.');
      return;
    }
    
    this.connection = new HubConnectionBuilder()
      .withUrl('/hubs/permissions', {
        accessTokenFactory: () => accessToken, // 传递 JWT 进行认证
      })
      .withAutomaticReconnect() // 自动重连是生产环境的关键
      .configureLogging(LogLevel.Information)
      .build();

    this.connection.on('PermissionsUpdated', (updatedPermissions: string[]) => {
      console.log('Received permission updates from server:', updatedPermissions);
      // 关键一步:用服务器推送的数据更新 Zustand store
      useSecurityStore.getState().updatePermissions(updatedPermissions);
    });

    try {
      await this.connection.start();
      console.log('SignalR connection started successfully.');
    } catch (err) {
      console.error('Error starting SignalR connection:', err);
      // 在这里可以实现更复杂的重试逻辑或用户通知
    }
  }

  public async stopConnection(): Promise<void> {
    if (this.connection) {
      await this.connection.stop();
      this.connection = null;
      console.log('SignalR connection stopped.');
    }
  }
}

export const permissionSignalRService = new PermissionSignalRService();

在React应用中集成:
在应用根组件或登录后的主布局中初始化 SignalR 连接。

// App.tsx or a layout component
import React, { useEffect } from 'react';
import { useSecurityStore } from './store/securityStore';
import { permissionSignalRService } from './services/permissionSignalRService';
import { OrderManagement } from './components/OrderManagement';

function MainApp() {
  const { isAuthenticated } = useSecurityStore();
  
  // 模拟登录
  const login = () => {
    const fakeToken = "your_jwt_here"; // 实际应用中从认证服务获取
    const userId = "user-alice";
    // 登录时,应通过API获取初始权限集
    const initialPermissions = ["read:orders", "create:orders", "delete:orders"];
    useSecurityStore.getState().setAuthState(true, userId, initialPermissions);
    permissionSignalRService.startConnection(fakeToken);
  };
  
  useEffect(() => {
    // 模拟组件加载时自动登录
    login();
    
    return () => {
      // 组件卸载时断开连接,防止内存泄漏
      permissionSignalRService.stopConnection();
    };
  }, []);

  if (!isAuthenticated) return <div>Please log in.</div>;

  return (
    <div>
      <h1>Order Management Dashboard</h1>
      <OrderManagement />
    </div>
  );
}

受权限控制的UI组件:
这是最终目的——UI能够根据Zustand中的状态自动更新。

// components/OrderManagement.tsx
import React from 'react';
import { useHasPermission, useSecurityStore } from '../store/securityStore';

export const OrderManagement: React.FC = () => {
  // 使用自定义 hook 来检查权限
  const canDeleteOrders = useHasPermission('delete:orders');
  const permissions = useSecurityStore(state => state.permissions);

  const handleDelete = () => {
    if (!canDeleteOrders) {
      alert("You don't have permission to delete orders!");
      return;
    }
    // 调用删除API
    console.log('Deleting order...');
  };

  return (
    <div>
      <h2>Orders</h2>
      <p>Current Permissions: {permissions.join(', ')}</p>
      <ul>
        <li>Order #1 <button onClick={handleDelete} disabled={!canDeleteOrders}>Delete</button></li>
        <li>Order #2 <button onClick={handleDelete} disabled={!canDeleteOrders}>Delete</button></li>
      </ul>
      {!canDeleteOrders && <p style={{ color: 'red' }}>Admin has revoked your permission to delete orders.</p>}
    </div>
  );
};

当管理员通过API撤销了 user-alicedelete:orders 权限后,后端 PermissionService 会触发 SignalR 推送。前端的 permissionSignalRService 接收到消息,调用 useSecurityStore.getState().updatePermissions。Zustand 状态更新,所有使用了 useHasPermission 或直接订阅了 permissions 状态的 React 组件会自动重新渲染。“删除”按钮会立即变为禁用状态,并显示提示信息。整个过程用户无需任何操作。

架构的扩展性与局限性

扩展路径:

  1. 消息总线集成: 在更复杂的微服务架构中,权限变更事件可以发布到 RabbitMQ 或 Kafka 等消息总线。一个专门的 SignalR 网关服务可以订阅这些事件,再将其推送给客户端,从而将核心业务逻辑与实时通知层解耦。
  2. 更精细的推送: SignalR 的 Groups 功能可用于按角色、租户或部门进行分组推送,而不是仅针对单个用户。例如,当一个角色的权限变更时,可以通知所有属于该角色的在线用户。
  3. 完整的CQRS/ES: 当前的实现是事件驱动的简化模型。一个完整的事件溯源(Event Sourcing)系统可以存储所有权限变更的历史事件,提供完整的审计日志,并能从事件流中重建任何时间点的权限状态。

现实世界的局限性与考量:

  1. SignalR 扩展性: 在大规模部署中,需要为 SignalR 配置 backplane(如 Redis),以确保在多服务器实例下,消息可以在所有实例间正确路由到目标客户端。
  2. 连接状态管理: 客户端可能因网络问题频繁断开和重连。withAutomaticReconnect 能处理大部分情况,但应用需要设计健壮的逻辑来处理重连成功后的状态同步,确保在离线期间没有错过关键的权限变更。一种常见的模式是在重连成功后,主动向服务器请求一次最新的全量权限数据。
  3. 初始状态加载: 本例中,初始权限是在登录时获取的。必须确保 SignalR 连接在获取到初始权限 之后 才建立,或者在连接建立后有一个可靠的机制来拉取首次的全量状态,避免“监听”和“初始状态”之间的竞态条件。
  4. 适用场景: 这个架构并非万能。它最适用于对安全实时性要求极高的内部系统、B2B平台或金融应用。对于普通的C端应用,传统的短令牌或会话管理方案可能在成本和复杂性上更具优势。这是一个典型的架构权衡,没有银弹。

  目录