ASP.NET Core 认证授权实战:从 JWT 到 Policy,设计一套企业级 RBAC 权限系统

认证(Authentication)和授权(Authorization)是两个词。认证回答"你是谁",授权回答"你能做什么"。90% 的权限 Bug 不是代码写错了,而是把这两件事混在了一起。

开篇:一个被 401/403 搞疯的下午

前端发来消息:"接口返回 401 了。"

你检查代码:[Authorize] 加了,Token 也传了,用户也登录了。你调了半天,最后发现是 Token 过期了。401 = 未认证。

过了一会儿,前端又说:"接口返回 403 了。"

你又检查:用户是普通船员角色,但接口要求轮机长角色。403 = 无权限。

401 是"门都没让你进",403 是"门让你进了但这个房间你不能进"。

这个区别看似简单,但在实际的企业系统中,认证和授权的复杂度远超大多数人的想象。尤其是多船舶、多角色、多模块的 PMS 系统中,权限模型的设计直接决定了系统是安全还是"看起来安全"。

这篇文章从基础概念出发,一步步构建一套完整的、可落地的 RBAC 权限系统。


一、认证 vs 授权:先把概念钉死

复制代码
HTTP 请求
    │
    ▼
┌──────────────────┐
│  认证中间件       │  你是谁?Token 有效吗?
│  Authentication  │  → 无效:401 Unauthorized
└────────┬─────────┘
         │ 有效,填充 User.Claims
         ▼
┌──────────────────┐
│  授权中间件       │  你能做这个操作吗?
│  Authorization   │  → 无权限:403 Forbidden
└────────┬─────────┘
         │ 有权限
         ▼
┌──────────────────┐
│  Controller      │  执行业务逻辑
└──────────────────┘

ASP.NET Core 中,这个流水线体现在中间件顺序上:

csharp 复制代码
app.UseAuthentication();   // 认证:解析 Token,填充 User
app.UseAuthorization();    // 授权:检查权限
app.MapControllers();

顺序不能反。 如果把 UseAuthorization 放在 UseAuthentication 之前,授权中间件看到的 User 是未认证的,所有 [Authorize] 都会返回 401。

💬 互动一下:你有没有在项目中把这两个中间件的顺序写反过?或者遇到过 401/403 傻傻分不清的情况?评论区聊聊。


二、JWT 认证:从零开始搭建

2.1 什么是 JWT?

JWT(JSON Web Token)由三部分组成,用 . 分隔:

复制代码
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMiLCJzaGlwX2lkIjoiMTAwMSIsInJvbGUiOiJDaGVmRW5naW5lZXIifQ.s8a3...
└──────────────────────┘ └──────────────────────────────────────────────────┘ └──────────────────┘
       Header                          Payload (Claims)                        Signature
       算法和类型                        用户信息和权限声明                        HMACSHA256 签名

Payload 中的每一项叫一个 Claim(声明)。比如:

  • sub: 123 --- 用户ID
  • ship_id: 1001 --- 当前船舶ID
  • role: ChiefEngineer --- 角色
  • perm: device:read --- 设备读取权限

关键点:JWT 的 Payload 只是 Base64 编码,不是加密。 任何人把 Token 复制到 jwt.io 都能看到内容。所以不要在 JWT 里放密码、密钥等敏感信息。JWT 的安全性来自签名------服务端用密钥验证 Token 没有被篡改。

2.2 完整配置

bash 复制代码
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
csharp 复制代码
// Options 类
public class JwtOptions
{
    public const string SectionName = "Jwt";

    [Required]
    public string Issuer { get; set; } = string.Empty;

    [Required]
    public string Audience { get; set; } = string.Empty;

    [Required]
    [StringLength(100, MinimumLength = 32)]
    public string SigningKey { get; set; } = string.Empty;

    [Range(1, 1440)]
    public int ExpiryMinutes { get; set; } = 60;
}

// Program.cs 注册
builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        var jwt = builder.Configuration.GetSection(JwtOptions.SectionName).Get<JwtOptions>()!;

        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            ValidIssuer = jwt.Issuer,
            ValidAudience = jwt.Audience,
            IssuerSigningKey = new SymmetricSecurityKey(
                Encoding.UTF8.GetBytes(jwt.SigningKey)),
            ClockSkew = TimeSpan.FromMinutes(5)  // 允许 5 分钟时钟偏差
        };

        // JWT 事件
        options.Events = new JwtBearerEvents
        {
            OnTokenValidated = context =>
            {
                // Token 验证成功后的逻辑(如加载用户最新权限)
                var logger = context.HttpContext.RequestServices
                    .GetRequiredService<ILogger<Program>>();
                var userId = context.Principal?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
                logger.LogInformation("用户 {UserId} 认证成功", userId);
                return Task.CompletedTask;
            },
            OnAuthenticationFailed = context =>
            {
                var logger = context.HttpContext.RequestServices
                    .GetRequiredService<ILogger<Program>>();
                logger.LogWarning("认证失败: {Error}", context.Exception.Message);
                return Task.CompletedTask;
            }
        };
    });

builder.Services.AddAuthorization();

2.3 生成 Token

csharp 复制代码
public interface ITokenService
{
    TokenResult GenerateToken(User user, ShipContext shipContext);
}

public record TokenResult(string AccessToken, string RefreshToken, DateTime ExpiresAt);

public class TokenService : ITokenService
{
    private readonly JwtOptions _jwt;

    public TokenService(IOptions<JwtOptions> options) => _jwt = options.Value;

    public TokenResult GenerateToken(User user, ShipContext shipContext)
    {
        // 1. 构建 Claims
        var claims = new List<Claim>
        {
            new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
            new(JwtRegisteredClaimNames.UniqueName, user.Username),
            new(JwtRegisteredClaimNames.Email, user.Email ?? ""),
            new("display_name", user.DisplayName),
            new("ship_id", shipContext.ShipId.ToString()),
            new("ship_code", shipContext.ShipCode),
            new("ship_mode", shipContext.Mode),  // ship / shore
            new("dept_code", user.DepartmentCode ?? ""),
        };

        // 2. 添加角色 Claims(一个用户可能有多个角色)
        foreach (var role in user.Roles)
        {
            claims.Add(new Claim(ClaimTypes.Role, role.RoleCode));
        }

        // 3. 添加权限 Claims(细粒度权限)
        foreach (var perm in user.GetEffectivePermissions())
        {
            claims.Add(new Claim("permission", perm));
            // 例如: "device:read", "sparepart:approve:level2"
        }

        // 4. 签名
        var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwt.SigningKey));
        var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);

        var expires = DateTime.UtcNow.AddMinutes(_jwt.ExpiryMinutes);

        var token = new JwtSecurityToken(
            issuer: _jwt.Issuer,
            audience: _jwt.Audience,
            claims: claims,
            expires: expires,
            signingCredentials: credentials);

        var accessToken = new JwtSecurityTokenHandler().WriteToken(token);

        // 5. 生成 Refresh Token(不存敏感信息,只存引用ID)
        var refreshToken = GenerateRefreshToken();

        return new TokenResult(accessToken, refreshToken, expires);
    }

    private static string GenerateRefreshToken()
    {
        var bytes = RandomNumberGenerator.GetBytes(64);
        return Convert.ToBase64String(bytes);
    }
}

2.4 Claims 的设计:不要把所有权限塞进 Token

一个常见的错误是把用户的全部权限放进 JWT。如果用户有 500 个权限点,Token 会变得非常大(几 KB),每次请求都在 HTTP Header 里传输。

两种策略:

策略 Token 内容 优点 缺点
胖 Token 用户ID + 角色 + 全部权限 授权时不需要查库 Token 大,权限变更需重新登录
瘦 Token 只放用户ID + 船舶ID Token 小,权限实时生效 每次授权需查库/缓存

推荐方案:折中 ------Token 里放角色(角色数量有限,通常不超过 5 个),细粒度权限通过 IClaimsTransformation 在每次请求时从缓存加载:

csharp 复制代码
public class PermissionClaimsTransformation : IClaimsTransformation
{
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly IDistributedCache _cache;
    private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(10);

    public PermissionClaimsTransformation(
        IServiceScopeFactory scopeFactory,
        IDistributedCache cache)
    {
        _scopeFactory = scopeFactory;
        _cache = cache;
    }

    public async Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
    {
        if (principal.Identity is not ClaimsIdentity identity || !identity.IsAuthenticated)
            return principal;

        // 已经添加过权限就不重复添加
        if (principal.HasClaim(c => c.Type == "permission"))
            return principal;

        var userId = identity.FindFirst(ClaimTypes.NameIdentifier)?.Value;
        if (string.IsNullOrEmpty(userId)) return principal;

        var cacheKey = $"perms:{userId}";
        var cached = await _cache.GetStringAsync(cacheKey);

        List<string> permissions;
        if (cached != null)
        {
            permissions = JsonSerializer.Deserialize<List<string>>(cached)!;
        }
        else
        {
            using var scope = _scopeFactory.CreateScope();
            var permService = scope.ServiceProvider
                .GetRequiredService<IPermissionService>();
            permissions = await permService.GetUserPermissionsAsync(int.Parse(userId));

            await _cache.SetStringAsync(cacheKey,
                JsonSerializer.Serialize(permissions),
                new DistributedCacheEntryOptions
                {
                    AbsoluteExpirationRelativeToNow = CacheDuration
                });
        }

        foreach (var perm in permissions)
        {
            identity.AddClaim(new Claim("permission", perm));
        }

        return principal;
    }
}

// 注册
builder.Services.AddScoped<IClaimsTransformation, PermissionClaimsTransformation>();

这样 Token 保持小巧(只放用户ID和角色),细粒度权限通过缓存注入,权限变更后最多 10 分钟生效(也可以在权限变更时主动清缓存)。


三、RBAC 模型:基于角色的权限控制

3.1 核心模型

RBAC(Role-Based Access Control)是企业系统中最常用的权限模型:

复制代码
用户 (User) ──M:N── 角色 (Role) ──M:N── 权限 (Permission)
                                              │
                                    ┌─────────┴─────────┐
                                    │                   │
                                模块 (Module)       操作 (Action)
                                device              read / write
                                sparepart           approve / delete
                                material            export

数据库设计:

sql 复制代码
-- 用户表
CREATE TABLE Sys_User (
    Id INT PRIMARY KEY,
    Username VARCHAR(50) NOT NULL UNIQUE,
    PasswordHash VARCHAR(255) NOT NULL,
    DisplayName VARCHAR(100),
    Email VARCHAR(200),
    DepartmentCode VARCHAR(20),
    IsActive BIT DEFAULT 1,
    CreatedAt DATETIME,
    UpdatedAt DATETIME
);

-- 角色表
CREATE TABLE Sys_Role (
    Id INT PRIMARY KEY,
    RoleCode VARCHAR(50) NOT NULL UNIQUE,
    RoleName NVARCHAR(100) NOT NULL,
    Description NVARCHAR(500),
    IsSystem BIT DEFAULT 0  -- 系统内置角色不可删除
);

-- 用户角色关联
CREATE TABLE Sys_UserRole (
    UserId INT NOT NULL,
    RoleId INT NOT NULL,
    ShipId INT NULL,  -- 角色可能按船舶分配
    PRIMARY KEY (UserId, RoleId, ShipId)
);

-- 权限点
CREATE TABLE Sys_Permission (
    Id INT PRIMARY KEY,
    PermissionCode VARCHAR(100) NOT NULL UNIQUE,
    ModuleCode VARCHAR(50) NOT NULL,    -- device, sparepart, material...
    ActionCode VARCHAR(50) NOT NULL,    -- read, write, approve, delete, export...
    Description NVARCHAR(200)
);

-- 角色权限关联
CREATE TABLE Sys_RolePermission (
    RoleId INT NOT NULL,
    PermissionId INT NOT NULL,
    PRIMARY KEY (RoleId, PermissionId)
);

3.2 权限编码规范

好的权限编码应该是可预测的,遵循统一格式:

复制代码
{模块}:{操作}[:{范围}]

device:read              -- 读取设备
device:write             -- 创建设备
device:delete            -- 删除设备
sparepart:read
sparepart:approve:level1 -- 一级审批
sparepart:approve:level2 -- 二级审批
sparepart:approve:level3 -- 三级审批
material:read
material:export
system:user:manage       -- 用户管理
system:role:manage       -- 角色管理
system:config:manage     -- 系统配置

这样在代码中可以非常直观地使用:

csharp 复制代码
[HttpGet]
[Authorize(Policy = "device:read")]
public async Task<IActionResult> GetDevices() { ... }

[HttpPost("approve/{id}")]
[Authorize(Policy = "sparepart:approve:level2")]
public async Task<IActionResult> Approve(int id, [FromBody] ApprovalDto dto) { ... }

四、授权策略:Policy-Based 授权

4.1 从简单到复杂

最简单:角色授权

csharp 复制代码
[Authorize(Roles = "ChiefEngineer")]
public class DeviceController : ControllerBase { }

[Authorize(Roles = "Captain,ChiefEngineer")]  // 任意一个角色即可
public IActionResult Approve() { }

推荐:基于策略的授权

csharp 复制代码
builder.Services.AddAuthorization(options =>
{
    // 简单策略:需要特定权限
    options.AddPolicy("device:read", policy =>
        policy.RequireClaim("permission", "device:read"));

    options.AddPolicy("device:write", policy =>
        policy.RequireClaim("permission", "device:write"));

    options.AddPolicy("sparepart:approve:level2", policy =>
        policy.RequireClaim("permission", "sparepart:approve:level2"));

    // 复合策略:需要同时满足多个条件
    options.AddPolicy("DeviceManagement", policy =>
    {
        policy.RequireRole("ChiefEngineer", "TechnicalManager");
        policy.RequireClaim("ship_mode", "shore");  // 必须是岸端
    });

    // 仅船长或轮机长可访问,且必须是本船
    options.AddPolicy("ShipOfficer", policy =>
    {
        policy.RequireRole("Captain", "ChiefEngineer");
        policy.RequireAssertion(context =>
        {
            var shipIdClaim = context.User.FindFirst("ship_id")?.Value;
            var requestShipId = context.HttpContext.Request.Headers["X-Ship-Id"].FirstOrDefault();
            return shipIdClaim == requestShipId;
        });
    });
});

4.2 动态策略:用 IAuthorizationPolicyProvider 自动生成

上面的方式需要为每个权限点手动注册策略。如果有 200 个权限点,就要写 200 行注册代码。更好的方式是动态生成策略:

csharp 复制代码
public class PermissionPolicyProvider : IAuthorizationPolicyProvider
{
    private const string PermissionPrefix = "permission:";
    private readonly DefaultAuthorizationPolicyProvider _fallback;

    public PermissionPolicyProvider(IOptions<AuthorizationOptions> options)
    {
        _fallback = new DefaultAuthorizationPolicyProvider(options);
    }

    public Task<AuthorizationPolicy> GetDefaultPolicyAsync() =>
        _fallback.GetDefaultPolicyAsync();

    public Task<AuthorizationPolicy?> GetFallbackPolicyAsync() =>
        _fallback.GetFallbackPolicyAsync();

    public Task<AuthorizationPolicy?> GetPolicyAsync(string policyName)
    {
        if (policyName.StartsWith(PermissionPrefix, StringComparison.OrdinalIgnoreCase))
        {
            var permission = policyName[PermissionPrefix.Length..];

            var policy = new AuthorizationPolicyBuilder()
                .AddRequirements(new PermissionRequirement(permission))
                .Build();

            return Task.FromResult<AuthorizationPolicy?>(policy);
        }

        return _fallback.GetPolicyAsync(policyName);
    }
}

// 权限要求
public record PermissionRequirement(string Permission) : IAuthorizationRequirement;

// 权限处理器
public class PermissionHandler : AuthorizationHandler<PermissionRequirement>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context,
        PermissionRequirement requirement)
    {
        var hasPermission = context.User
            .HasClaim("permission", requirement.Permission);

        if (hasPermission)
        {
            context.Succeed(requirement);
        }

        // 超级管理员绕过所有权限检查
        if (context.User.IsInRole("SuperAdmin"))
        {
            context.Succeed(requirement);
        }

        return Task.CompletedTask;
    }
}

// 注册
builder.Services.AddSingleton<IAuthorizationPolicyProvider, PermissionPolicyProvider>();
builder.Services.AddScoped<IAuthorizationHandler, PermissionHandler>();

使用时不需要预先注册每个权限点:

csharp 复制代码
// 权限编码即策略名,用 permission: 前缀
[Authorize(Policy = "permission:device:read")]
public IActionResult GetDevices() { }

[Authorize(Policy = "permission:sparepart:approve:level2")]
public IActionResult Approve() { }

// 自定义属性,更简洁
public class RequirePermissionAttribute : AuthorizeAttribute
{
    public RequirePermissionAttribute(string permission)
    {
        Policy = $"permission:{permission}";
    }
}

// 使用
[HttpGet]
[RequirePermission("device:read")]
public async Task<IActionResult> GetDevices() { }

[HttpPost]
[RequirePermission("sparepart:write")]
public async Task<IActionResult> Create() { }

[HttpDelete("{id}")]
[RequirePermission("device:delete")]
public async Task<IActionResult> Delete(int id) { }

4.3 资源级授权:同一条数据不同人能看不同字段

有时候授权不只是"能/不能访问这个接口",而是"你能看到这条数据的哪些字段"。比如普通船员只能看到设备名称和编号,轮机长能看到维修记录和成本信息。

csharp 复制代码
public interface IResourceAuthorizationHandler
{
    Task<AuthorizationResult> AuthorizeAsync(ClaimsPrincipal user, object resource, string action);
}

// 资源级授权处理器
public class DeviceAuthorizationHandler : IResourceAuthorizationHandler
{
    public Task<AuthorizationResult> AuthorizeAsync(
        ClaimsPrincipal user, object resource, string action)
    {
        if (resource is not Device device)
            return Task.FromResult(AuthorizationResult.Failed());

        var shipId = user.FindFirst("ship_id")?.Value;

        // 只能看本船设备
        if (device.ShipId.ToString() != shipId)
            return Task.FromResult(AuthorizationResult.Failed());

        // 轮机长可以看全部信息
        if (user.IsInRole("ChiefEngineer"))
            return Task.FromResult(AuthorizationResult.Success());

        // 普通船员不能看成本信息
        if (action == "read" && !user.IsInRole("ChiefEngineer"))
        {
            // 返回部分授权:可以看,但需要过滤敏感字段
            return Task.FromResult(AuthorizationResult.PartialSuccess(
                maskedFields: new[] { "Cost", "MaintenanceHistory" }));
        }

        return Task.FromResult(AuthorizationResult.Failed());
    }
}

更复杂的资源授权可以用 ASP.NET Core 的 IAuthorizationService

csharp 复制代码
public class DeviceService
{
    private readonly IAuthorizationService _authz;

    public DeviceService(IAuthorizationService authz) => _authz = authz;

    public async Task<DeviceDto?> GetDeviceAsync(int id, ClaimsPrincipal user)
    {
        var device = await _db.Devices.FindAsync(id);
        if (device == null) return null;

        // 资源级授权
        var result = await _authz.AuthorizeAsync(user, device, "DeviceAccess");
        if (!result.Succeeded) return null;  // 或抛出异常

        return MapToDto(device, result);
    }
}

// 资源级 Requirement
public record DeviceAccessRequirement : IAuthorizationRequirement;

public class DeviceAccessHandler : AuthorizationHandler<DeviceAccessRequirement, Device>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context,
        DeviceAccessRequirement requirement,
        Device resource)
    {
        var userShipId = context.User.FindFirst("ship_id")?.Value;

        if (resource.ShipId.ToString() == userShipId)
        {
            context.Succeed(requirement);
        }

        return Task.CompletedTask;
    }
}

💬 互动一下:在你的项目中,权限是只做到接口级(能不能调),还是做到了数据级(能看哪些数据、哪些字段)?有没有遇到过"同一个列表页,不同角色看到不同列"的需求?


五、多船舶上下文:权限隔离的关键

在 PMS 系统中,一个用户可能关联多艘船舶,但每次登录只操作一艘船的数据。这比普通的单租户系统多了一层"当前船舶"的概念。

5.1 当前船舶上下文

csharp 复制代码
public interface ICurrentShipContext
{
    int ShipId { get; }
    string ShipCode { get; }
    string Mode { get; }       // "ship" 或 "shore"
    int UserId { get; }
    string Username { get; }
    bool IsAuthenticated { get; }
}

public class CurrentShipContext : ICurrentShipContext
{
    private readonly IHttpContextAccessor _httpContextAccessor;

    public CurrentShipContext(IHttpContextAccessor httpContextAccessor)
    {
        _httpContextAccessor = httpContextAccessor;
    }

    private ClaimsPrincipal? User => _httpContextAccessor.HttpContext?.User;

    public int ShipId =>
        int.TryParse(User?.FindFirst("ship_id")?.Value, out var id) ? id : 0;

    public string ShipCode =>
        User?.FindFirst("ship_code")?.Value ?? string.Empty;

    public string Mode =>
        User?.FindFirst("ship_mode")?.Value ?? "ship";

    public int UserId =>
        int.TryParse(User?.FindFirst(ClaimTypes.NameIdentifier)?.Value, out var id) ? id : 0;

    public string Username =>
        User?.FindFirst(ClaimTypes.Name)?.Value ?? string.Empty;

    public bool IsAuthenticated =>
        User?.Identity?.IsAuthenticated ?? false;
}

5.2 船舶切换

岸端用户可能需要切换船舶来查看不同船的数据:

csharp 复制代码
[HttpPost("switch-ship/{shipId}")]
[Authorize]
public async Task<IActionResult> SwitchShip(int shipId, [FromServices] ITokenService tokenService)
{
    var userId = User.GetUserId();

    // 验证用户是否有权访问该船
    var hasAccess = await _shipService.UserHasShipAccessAsync(userId, shipId);
    if (!hasAccess)
        return Forbid();

    // 获取船舶信息和用户在该船的角色
    var ship = await _shipService.GetByIdAsync(shipId);
    var roles = await _userService.GetUserRolesAsync(userId, shipId);

    // 重新生成 Token(包含新船舶的上下文)
    var token = tokenService.GenerateToken(
        new UserInfo(userId, User.Identity!.Name!),
        new ShipContext(shipId, ship.Code, "shore"),
        roles);

    return Ok(token);
}

5.3 EF Core 全局过滤:自动隔离船舶数据

csharp 复制代码
public class AppDbContext : DbContext
{
    private readonly ICurrentShipContext _shipContext;

    public AppDbContext(DbContextOptions<AppDbContext> options,
                        ICurrentShipContext shipContext)
        : base(options)
    {
        _shipContext = shipContext;
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // 所有实现 IShipEntity 的实体自动按 ShipId 过滤
        modelBuilder.Entity<Device>()
            .HasQueryFilter(d => d.ShipId == _shipContext.ShipId);

        modelBuilder.Entity<SparePart>()
            .HasQueryFilter(s => s.ShipId == _shipContext.ShipId);

        modelBuilder.Entity<Material>()
            .HasQueryFilter(m => m.ShipId == _shipContext.ShipId);

        // 软删除过滤
        modelBuilder.Entity<Device>()
            .HasQueryFilter(d => d.IsDelete == 0);
    }
}

重要 :全局查询过滤器在船端单船模式下工作良好。但在岸端需要跨船查询时(如汇总报表),需要用 IgnoreQueryFilters()

csharp 复制代码
// 岸端跨船报表:忽略船舶过滤
var allShipsData = await _db.Devices
    .IgnoreQueryFilters()
    .Where(d => d.IsDelete == 0)
    .GroupBy(d => d.ShipId)
    .Select(g => new { ShipId = g.Key, Count = g.Count() })
    .ToListAsync();

六、Refresh Token:安全地维持登录状态

Access Token 有效期短(15-60 分钟),如果过期就让用户重新登录,体验太差。Refresh Token 解决了这个问题:

csharp 复制代码
public class RefreshToken
{
    public string Id { get; set; } = Guid.NewGuid().ToString("N");
    public int UserId { get; set; }
    public string TokenHash { get; set; } = string.Empty;  // 存哈希,不存明文
    public DateTime ExpiresAt { get; set; }
    public DateTime CreatedAt { get; set; }
    public string? CreatedByIp { get; set; }
    public bool IsRevoked { get; set; }
    public string? ReplacedByTokenId { get; set; }
}

[HttpPost("refresh")]
public async Task<IActionResult> Refresh([FromBody] RefreshTokenRequest request)
{
    var user = await _tokenService.ValidateRefreshTokenAsync(request.RefreshToken);
    if (user == null)
        return Unauthorized(new { error = "invalid_refresh_token" });

    // 旧 Token 轮换(Rotation):生成新 Refresh Token,旧的作废
    var (accessToken, refreshToken) = await _tokenService.RotateRefreshTokenAsync(
        user, request.RefreshToken, HttpContext.Connection.RemoteIpAddress?.ToString());

    // 检测到旧 Token 被重复使用(可能被盗)→ 撤销该用户所有 Token
    // 这是 Refresh Token Rotation 的安全机制

    return Ok(new { accessToken, refreshToken });
}

安全规则:

  1. Access Token 有效期 15-60 分钟,短了用户体验差,长了泄露风险大
  2. Refresh Token 有效期 7-30 天,且只能使用一次(Rotation)
  3. Refresh Token 存数据库(哈希后),不存 JWT(无法撤销)
  4. 检测重复使用------如果一个已使用的 Refresh Token 再次被使用,说明可能被盗,立即撤销该用户的所有 Token

七、权限缓存:每次请求都查库会崩

权限数据变化不频繁,但每次请求都需要检查。用缓存:

csharp 复制代码
public class CachedPermissionService : IPermissionService
{
    private readonly IPermissionRepository _repo;
    private readonly IDistributedCache _cache;
    private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(10);

    public async Task<List<string>> GetUserPermissionsAsync(int userId)
    {
        var key = $"user:perms:{userId}";

        var cached = await _cache.GetStringAsync(key);
        if (cached != null)
            return JsonSerializer.Deserialize<List<string>>(cached)!;

        var perms = await _repo.GetUserPermissionCodesAsync(userId);

        await _cache.SetStringAsync(key,
            JsonSerializer.Serialize(perms),
            new DistributedCacheEntryOptions
            {
                AbsoluteExpirationRelativeToNow = CacheTtl
            });

        return perms;
    }

    // 权限变更时清除缓存
    public async Task InvalidateUserPermissionsAsync(int userId)
    {
        await _cache.RemoveAsync($"user:perms:{userId}");
    }

    // 角色权限变更时,清除该角色下所有用户的缓存
    public async Task InvalidateRolePermissionsAsync(int roleId)
    {
        var userIds = await _repo.GetUserIdsByRoleAsync(roleId);
        var keys = userIds.Select(id => $"user:perms:{id}");
        // 批量删除(Redis 用 DEL 命令)
    }
}

八、Swagger 集成:让开发调试更方便

csharp 复制代码
builder.Services.AddSwaggerGen(options =>
{
    options.SwaggerDoc("v1", new OpenApiInfo
    {
        Title = "PMS API",
        Version = "v1"
    });

    // JWT 认证
    options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
    {
        Name = "Authorization",
        Type = SecuritySchemeType.Http,
        Scheme = "Bearer",
        BearerFormat = "JWT",
        In = ParameterLocation.Header,
        Description = "输入 JWT Token(不需要 Bearer 前缀)"
    });

    options.AddSecurityRequirement(new OpenApiSecurityRequirement
    {
        {
            new OpenApiSecurityScheme
            {
                Reference = new OpenApiReference
                {
                    Type = ReferenceType.SecurityScheme,
                    Id = "Bearer"
                }
            },
            Array.Empty<string>()
        }
    });
});

九、一个完整的权限检查流程

把所有部分串起来,一次 API 请求的认证授权流程:

复制代码
1. 请求到达
   │
2. UseAuthentication()
   ├─ 从 Authorization Header 提取 Bearer Token
   ├─ 验证 JWT 签名和过期时间
   ├─ 解析 Claims(userId, shipId, roles...)
   └─ IClaimsTransformation 从缓存加载细粒度权限
   │
3. UseAuthorization()
   ├─ 检查 [AllowAnonymous] → 直接通过
   ├─ 检查 [Authorize] → 需要认证
   ├─ 检查 [Authorize(Roles="...")] → 检查角色 Claim
   ├─ 检查 [RequirePermission("device:read")]
   │   ├─ PermissionPolicyProvider 动态生成策略
   │   └─ PermissionHandler 检查 permission Claim
   └─ 资源级授权(在 Service/Controller 中手动调用 IAuthorizationService)
   │
4. EF Core Global Query Filter
   └─ 自动注入 WHERE ShipId = @currentShipId AND IsDelete = 0
   │
5. 执行 Action
   │
6. 返回响应

十、安全加固清单

  • JWT 签名密钥至少 32 字符,通过环境变量/Key Vault 注入
  • Access Token 有效期 15-60 分钟
  • Refresh Token 只存哈希,支持 Rotation 和撤销
  • 检测 Refresh Token 重复使用,异常时撤销所有 Token
  • 密码用 BCrypt/Argon2 哈希,不用 MD5/SHA256(不带盐)
  • 启用 HTTPS,HSTS 至少一年
  • CORS 不要用 AllowAnyOrigin,指定明确的来源
  • 敏感操作(删除、审批、权限变更)记录审计日志
  • EF Core 全局查询过滤器确保船舶数据隔离
  • 岸端跨船查询使用 IgnoreQueryFilters() 时必须有额外的权限检查
  • 失败登录有限速和锁定策略
  • 权限缓存有失效机制,权限变更后及时清除
  • Swagger 在生产环境关闭或加认证
  • 不使用 [Authorize] 的白名单有明确记录

结语:权限系统的设计哲学

一个好的权限系统应该做到三点:

  1. 默认拒绝------没有明确授权的操作全部拒绝,而不是默认允许
  2. 最小权限------每个角色只获得完成工作所需的最少权限
  3. 纵深防御------不依赖单一防线。JWT 验证是第一道门,策略授权是第二道门,EF Core 查询过滤器是第三道门,资源级授权是第四道门

不要试图一次性设计出"完美的权限模型"。权限系统的演化通常是:

复制代码
硬编码角色 → 角色表 + 权限表 → Policy 策略 → 资源级授权 → ABAC(基于属性)

从最简单的角色授权开始,当业务需要更细粒度的控制时再逐步演进。但底层的 Claims 模型和中间件顺序要从第一天就做对------这些是地基,改起来代价最大。

💬 最后一个互动:你们项目的权限系统做到了哪一级?接口级、数据行级还是字段级?有没有什么权限设计让你觉得"当时要是这么设计就好了"?评论区聊聊你的经验和教训。

相关推荐
Lost of 程序猿1 小时前
ASP.NET Core 后台任务全景:从 BackgroundService 到 Channel 队列,再到分布式调度
后端·asp.net·.netcore
倾颜1 小时前
NestJS 核心概念梳理:从 Module、Controller 到 Guard、Interceptor
后端·node.js·nestjs
东风破_2 小时前
JWT 1:从一个登录请求开始,理解 React 项目里的 API 层与 Mock
前端·后端
东风破_2 小时前
JWT 3:为什么 Token 要放进 Authorization?Axios 拦截器到底解决了什么?
前端·后端
东风破_2 小时前
JWT 5:路由守卫是什么?把整个 JWT 登录鉴权流程串起来
前端·后端
东风破_2 小时前
JWT 2:HTTP 是无状态的,为什么登录成功后还要给 Token?
前端·后端
东风破_2 小时前
JWT 4:Zustand 到底解决了什么?为什么登录状态要放进 Store?
前端·后端
IT_陈寒2 小时前
Vite动态导入差点让我秃头,原来问题出在这
前端·人工智能·后端
唐青枫4 小时前
别只把大括号当作用域:Zig Block、标签块与控制流实战
后端