认证鉴权流程说明
认证鉴权流程说明
一、登录流程
1.1 用户名密码登录 (POST /api/login)
SysLoginController.LoginAsync()
│
├─ 验证账号密码
├─ 双因子闸门: TwoFactorService.RequiresTwoFactor()
│ └─ 已启用 2FA 且设备未被信任 → 返回 203 + 一次性票据 (不签发 token)
│ 详见 1.3 双因子第二步
├─ 查询用户角色: roleService.SelectUserRoleListByUserId()
├─ 查询用户权限: permissionService.GetMenuPermission()
│
├─ 构建 LoginUser:
│ ├─ UserId, UserName, DeptId, NickName ← 从用户表
│ ├─ Roles, RoleKeys ← 从角色表
│ ├─ Permissions ← 从权限表 (完整列表, 如 system:user:list)
│ ├─ ScopeType ← 登录时预计算 (取所有角色中最宽松的权限)
│ └─ TenantId ← 请求头 tenantId
│
└─ 生成 JWT:
JwtUtil.GenerateJwtToken(AddClaims(loginUser))
│
└─ AddClaims 写入 JWT Claims:
├─ ClaimTypes.PrimarySid = UserId
├─ ClaimTypes.Name = UserName
├─ ClaimTypes.GroupSid = DeptId
├─ ClaimTypes.PrimaryGroupSid = TenantId
├─ ClaimTypes.UserData = JSON(LoginUser) ← 完整用户信息
└─ "sensitivePerms" = p:* 开头的敏感权限 (完整Permissions不入JWT, 太大)
注意: 登录时
Permissions只存在 LoginUser 对象上,未写入 Redis 缓存。登录后前端会立刻调GetUserInfo完成写入。
1.2 获取用户信息 (GET /api/getUserInfo)
SysLoginController.GetUserInfo()
│
└─ 写入 Redis 缓存:
├─ CacheService.SetUserPerms(UserPermKEY + userId, permissions) ← 权限列表入缓存
└─ CacheService.SetDataScopeDeptIds(userId, deptIds) ← 数据权限部门ID入缓存
1.3 双因子第二步 (POST /login/2fa/verify)
已启用双因子认证的账号,第一步校验通过后不签发 token,而是返回业务码 203 (MFA_REQUIRED) 与一次性票据;第二步校验认证器动态码(或一次性恢复码)后才走同一套签发出口 SysLoginService.SignIn()(权限装配 / 单会话 / 会话已过 2FA 标记 / 设备登记四件事集中在此)。
SysLoginController.VerifyTwoFactor() [AllowAnonymous, 关闭请求与响应日志]
│
└─ SysLoginService.VerifyTwoFactor(dto)
├─ CacheService.ConsumeTwoFactorTicket() ← 票据一次性消费;失败按绝对过期时间归还,重试不续命
├─ TenantContext.Change(payload.TenantId) ← 第二步是独立请求,租户取自票据载荷
├─ TwoFactorService.GetUserForAuth(userId) ← 绕开数据权限过滤取用户
├─ TwoFactorService.VerifyCodeOrThrow() ← 6 位动态码(同时间步防重放)或一次性恢复码(CAS 消费)
│ └─ 失败:写登录日志(Status=1)后抛出;达失败上限即锁定,票据不再归还
├─ 登录日志: AuthType(第一步凭据) + TwoFactorType(totp / recovery_code)
└─ SignIn(..., dto.RememberDevice) ← 勾选记住设备时签发 deviceToken(仅返回一次)
关键约定
| 项 | 说明 |
|---|---|
code == 203 | 表示"需要双因子验证",此时没有 token;只判断 200 的客户端必须单独处理 |
| 票据绑定设备 | 票据里存的是第一步携带的设备凭据哈希,第二步不允许替换设备(clientId 已废弃并忽略) |
| 失败可重试 | 未锁定时归还票据,用户可在剩余 TTL 内重试;重试不刷新票据生命周期 |
| 扫码登录 | 会话需带"已通过 2FA"标记(CacheService.SetTwoFactorSessionOk),防止旧 token 绕过闸门 |
| 免第二因子的审计 | 命中可信设备时 TwoFactorType=trusted_device,必须能与真正校验过动态码区分开 |
完整功能、配置、数据库与安全设计见 双因子认证(2FA / TOTP)。
二、中间件管道
管道顺序 (Program.cs)
请求 →
1. GlobalExceptionMiddleware 全局异常拦截
2. UseRequestLocalization 国际化
3. UseForwardedHeaders 反向代理IP转发
4. 内联中间件 EnableBuffering + Query token→Header
5. UseStaticFiles 静态文件
6. UseRouting 路由匹配
7. UseCors 跨域
────────────────────── 认证/授权区 ──────────────────────
8. TenantResolveMiddleware 租户一致性校验 ← User 可能还未挂载,自行解析JWT
9. UseAuthentication() 标准认证中间件
10. JwtAuthMiddleware JWT 解析 + Token刷新 + 设置 context.User ★
11. TenantFeatureMiddleware 租户套餐功能开关拦截
12. UseAuthorization() 标准授权中间件
─────────────────────────────────────────────────────────
13. UseResponseCaching
14. 业务中间件 (限流、SignalR、Swagger...)
→ Controller Action
各中间件职责
| # | 中间件 | 职责 | 跳过条件 |
|---|---|---|---|
| 8 | TenantResolveMiddleware | 校验 header.tenantId 与 token 中 tenantId 一致性;缺失时自动补全 | IsTenantEnabled=false 或 [AllowAnonymous] |
| 10 | JwtAuthMiddleware ★ | 解析 JWT → LoginUser;临近过期时刷新 Token;挂载 context.User;存入 context.Items | 静态资源、白名单、[AllowAnonymous] |
| 11 | TenantFeatureMiddleware | 按租户套餐拦截无权限的菜单/功能 | — |
三、JwtAuthMiddleware 详解
JwtAuthMiddleware.InvokeAsync()
│
├─ 静态资源 (含 ".") → 跳过
├─ 白名单路径 → 跳过
├─ [AllowAnonymous] → 跳过
│
├─ 解析 JWT: JwtUtil.GetLoginUser(context)
│ └─ 从 Authorization Header 取 token → ValidateJwtToken → 反序列化 LoginUser
│
├─ Token 为空 → 返回 401
│
├─ Token 临近过期 (≤ RefreshTokenTime 分钟, 默认5) → 刷新
│ └─ 防并发: CacheHelper 锁
│ └─ 响应头: X-Refresh-Token = 新Token
│
├─ ★ 存入 Items: context.Items["__CurrentUser"] = loginUser
│ └─ 后续所有读取都从 Items 取, 避免重复解析 JWT
│
├─ 挂载 context.User:
│ └─ new ClaimsIdentity(AddClaims(loginUser), "jwt")
│
└─ await _next(context)
四、用户信息获取 (多入口统一到 Items)
本次重构后,所有用户信息读取统一收敛到 JwtAuthMiddleware 写入 HttpContext.Items["__CurrentUser"] 的 LoginUser 对象,业务代码无需自行解析 JWT。
推荐用法(走 Items 缓存,零重复解析)
// Controller 中获取
long userId = HttpContext.GetUId(); // 用户ID
string userName = HttpContext.GetName(); // 用户名
long deptId = HttpContext.GetDeptId(); // 部门ID
// Service 层获取(通过 App.HttpContext,等价于上面)
string userName = App.HttpContext.GetName();
long userId = App.HttpContext.GetUId();
// 获取完整登录信息(含 Permissions,首次从 Redis 补全并回写 Items)
LoginUser info = HttpContext.GetCurrentUser(); // Controller 内
LoginUser info = App.HttpContext.GetCurrentUser(); // Service 内
GetUId / GetName / GetDeptId内部均委托GetCurrentUser(),整个请求只解析一次 JWT, 结果缓存到Items,同请求内多次调用零开销。GetCurrentUser()在Permissions为空时 会从 Redis (UserPermKEY + userId) 补全,无需业务层手动处理。
获取租户 / 主库信息
string tenantId = App.GetCurrentTenantId(); // 当前租户ID
string mainDb = App.MainDbConfigId; // 主库 ConfigId
架构图
JwtAuthMiddleware (唯一JWT解析入口)
│
└─ context.Items["__CurrentUser"] = loginUser ← 一次解析, 全请求复用
│
├── GetCurrentUser() [HttpContextExtension] 权限+用户信息
├── ActionPermissionFilter [MVC Filter] 权限校验
├── DataPermi.GetLoginUser [SqlSugar层] 数据权限计算 (已废弃)
└── DataScopeExtensions [Repository层] 数据权限过滤
五、ActionPermissionFilter 权限校验
OnActionExecutionAsync() [每个带 [Permission("xxx")] 的 Action 都执行]
│
├─ 从 Items 获取 LoginUser
│
├─ SaaS 租户隔离: 非主租户禁止访问平台级菜单 (前缀 p:*)
│
├─ 获取权限列表:
│ ├─ 先查 Redis: CacheService.GetUserPerms(UserPermKEY + userId)
│ └─ 未命中则查 DB: permissionService.GetMenuPermission() → 回写 Redis
│
├─ 权限判断优先级:
│ 1. 拥有 *:*:* (管理员权限) → 放行
│ 2. 拥有 admin 角色 → 放行
│ 3. 权限列表包含当前 Action 所需权限 → 放行
│ 4. RolePermi 匹配 → 放行
│ └─ 都不满足 → 返回 403
│
└─ 演示模式: 屏蔽增删改等危险操作
六、数据权限过滤 (DataScopeExtensions / DataPermi)
BaseRepository.Queryable<T>()
│
└─ TryApplyScope<T>() ← 自动附加 WHERE 条件
│
├─ SysUser: WHERE UserId=@uid OR DeptId IN (用户可见部门)
├─ SysDept: WHERE DeptId IN (用户可见部门)
├─ SysRole: WHERE RoleId IN (用户拥有的角色)
├─ LoginLog: WHERE UserName=@uname
└─ OnlineLog: WHERE UserId=@uid
权限等级优先级: All(管理员) > DeptList(多部门) > Dept(本部门) > Self(仅本人)
七、LoginUser.Permissions 数据流图
┌─────────────────────────────────────────────────────────────────┐
│ 登录 LoginAsync │
│ permissions ← DB │
│ loginUser.Permissions = permissions │
│ GenerateJwtToken(AddClaims(loginUser)) │
│ └─ JWT 只含 p:* 敏感权限, 完整 Permissions 不入 JWT │
└───────────────────────────┬─────────────────────────────────────┘
│
┌───────────────────────────▼─────────────────────────────────────┐
│ GetUserInfo (前端登录后立即调用) │
│ CacheService.SetUserPerms(UserPermKEY+userId, permissions) │
│ └─ 完整权限列表写入 Redis │
└───────────────────────────┬─────────────────────────────────────┘
│
┌───────────────────────────▼─────────────────────────────────────┐
│ JwtAuthMiddleware (每个请求) │
│ JwtUtil.GetLoginUser() → LoginUser (Permissions = []) │
│ context.Items["__CurrentUser"] = loginUser │
└───────────────────────────┬─────────────────────────────────────┘
│
┌───────────────────┼───────────────────┐
▼ ▼ ▼
GetCurrentUser ActionPermissionFilter DataScopeExtensions
Items中LoginUser Items中LoginUser Items中LoginUser
Permissions为空 Permissions为空 不需要Permissions
→ Redis补全 → Redis/DB补全
→ 回写Items对象 → 本地变量,不回写Items
八、会员令牌(mall-token,C 端) 会员
C 端会员身份与后台账号体系完全隔离,由独立公网宿主 ZR.Front.WebApi 签发与校验(内网宿主不再注册 C 端接口,详见公网宿主)。
8.1 令牌配置(MemberJwt)
"MemberJwt": {
"Issuer": "ZRAdmin.Member",
"Audience": "ZRAdmin.Member.App",
"Expire": 20160, //过期时间(分),默认 14 天
"SecretKey": "" //⚠️ 禁止明文提交,用 appsettings.{Env}.json / 环境变量 MemberJwt__SecretKey 注入
}
- 请求头为
mall-token(避开后台Authorization),支持Bearer前缀; - 密钥不再从后台
JwtSettings:SecretKey派生(派生后缀是源码公开常量,会被用来伪造会员身份); - fail-closed:
SecretKey未配置时,验签侧MemberJwt.GetMember直接返回null(会员接口返回"登录已失效"),签发侧抛异常——不会静默退化到其他密钥。
8.2 鉴权流程(MemberAuthorize)
请求(Header: mall-token)
│
├─ MemberJwt.GetMember() 解析并验签令牌
│ ├─ 无令牌 / 验签失败 / SecretKey 未配置 → null
│ └─ 成功 → MemberToken(MemberId 等)
│
├─ 状态复核(JWT 无状态,禁用/删除的会员即使令牌未过期也须拒绝)
│ └─ IsMemberActive(memberId) ← 60 秒 TTL 缓存,未命中回查库
│
├─ 失败 → 返回 OAUTH_FAIL("登录已失效,请重新登录" / "账号已被禁用")
│ ★ 刻意避开 401,防止前端拦截器误触发后台重登
│
└─ 成功 → 写入 context.Items["__CurrentMember"],业务侧可取当前会员
8.3 禁用会员的令牌失效时效
后台启用/禁用会员后,禁用状态写入 60 秒 TTL 的状态缓存并立即清除旧缓存;由于 JWT 本身无状态,存量令牌经 MemberAuthorize 的状态复核最迟 60 秒失效(多实例部署下各实例一致)。
