认证(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--- 用户IDship_id: 1001--- 当前船舶IDrole: 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 });
}
安全规则:
- Access Token 有效期 15-60 分钟,短了用户体验差,长了泄露风险大
- Refresh Token 有效期 7-30 天,且只能使用一次(Rotation)
- Refresh Token 存数据库(哈希后),不存 JWT(无法撤销)
- 检测重复使用------如果一个已使用的 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]的白名单有明确记录
结语:权限系统的设计哲学
一个好的权限系统应该做到三点:
- 默认拒绝------没有明确授权的操作全部拒绝,而不是默认允许
- 最小权限------每个角色只获得完成工作所需的最少权限
- 纵深防御------不依赖单一防线。JWT 验证是第一道门,策略授权是第二道门,EF Core 查询过滤器是第三道门,资源级授权是第四道门
不要试图一次性设计出"完美的权限模型"。权限系统的演化通常是:
硬编码角色 → 角色表 + 权限表 → Policy 策略 → 资源级授权 → ABAC(基于属性)
从最简单的角色授权开始,当业务需要更细粒度的控制时再逐步演进。但底层的 Claims 模型和中间件顺序要从第一天就做对------这些是地基,改起来代价最大。
💬 最后一个互动:你们项目的权限系统做到了哪一级?接口级、数据行级还是字段级?有没有什么权限设计让你觉得"当时要是这么设计就好了"?评论区聊聊你的经验和教训。