19. 接口权限与角色授权
一句话定位:权限码 = 去掉 api/ 前缀的接口相对路径(如 sysUser/page),它既写在菜单按钮的 Permission 字段上,也是前端 v-auth 的判断依据;请求进来时 JwtHandler 拿它与当前用户"按钮白名单 + 接口黑名单"做比对。
一、三张表的关系
SysMenu SysRoleMenu SysRoleApi
(菜单/目录/按钮) (角色↔菜单 授权) (角色↔接口 黑名单)
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Id │◄─────────────┤ MenuId │ │ Id │
│ Type │ MenuTypeEnum.Btn 才参与 │ │ RoleId │
│ Permission │ │ RoleId │───┐ │ Route │ = sysXxx/action
│ Path / Route │ └──────────────┘ │ └──────────────┘
└──────────────┘ │ ▲
▲ ▼ │
│ RoleIdList = GetUserRoleIdList(userId) │
│ menuIds = GetRoleMenuIdList(roleIds) │
└────► GetButtonList(menuIds) ──► apiList[0] 白名单 ◄────────┘
apiList[1] 黑名单 = 所有按钮 - 白名单 + RoleApi实体源码:
// Admin.NET.Core/Entity/SysRoleMenu.cs
[SugarTable(null, "系统角色菜单表")]
[SysTable]
public class SysRoleMenu : EntityBaseId
{
public long RoleId { get; set; }
public long MenuId { get; set; }
[Navigate(NavigateType.OneToOne, nameof(MenuId))]
public SysMenu SysMenu { get; set; }
}
// Admin.NET.Core/Entity/SysRoleApi.cs
[SugarTable(null, "系统角色接口黑名单表")] // ← 注意是【黑名单】
[SysTable]
public class SysRoleApi : EntityBaseId
{
public long RoleId { get; set; }
[Required]
public string Route { get; set; } // 接口路由,如 sysUser/page
}只有
Type == MenuTypeEnum.Btn的菜单行才会被算作权限码——菜单/目录不参与接口鉴权,只用于前端路由与侧边栏渲染。
二、权限码命名约定
后端动态 API 的真实路由由 DynamicApiControllerSettings 决定:
// Admin.NET.Application/Configuration/App.json
"DynamicApiControllerSettings": {
"DefaultRoutePrefix": "api", // 默认值(注释掉时也是 api)
"CamelCaseSeparator": "",
"SplitCamelCase": false,
"LowercaseRoute": false,
"AsLowerCamelCase": true,
"KeepVerb": false,
"KeepName": false
}因此 SysUserService.Page() → POST /api/sysUser/page,权限码为 sysUser/page。
| 服务端类/方法 | HTTP 路由 | 权限码 | 说明 |
|---|---|---|---|
SysUserService.Page | /api/sysUser/page | sysUser/page | 类名小驼峰 + 方法名小驼峰 |
SysUserService.AddUser | /api/sysUser/addUser | sysUser/addUser | 方法名原样输出,不做拆分 |
SysRoleService.GrantMenu | /api/sysRole/grantMenu | sysRole/grantMenu | 授权接口本身也要被授权 |
APIJSONService.Query | /api/apijson/get | apijson/get | [HttpPost("get")] 显式指定了 Name |
约定要点:
- 一律
模块/动作两段式,不要用/开头、不要带api/ - 动作名系统自带通用值:
page(分页)、add、update、delete、detail、tree、list、export、import - 自定义动作建议小驼峰且与后端方法名保持一致,避免"菜单里配的和实际路由不同过"
- 菜单管理界面新增时有格式校验,非法格式会报错误码
D4003(权限标识格式为空)/D4004(权限标识格式错误 如xxx/xxx)
三、JwtHandler 校验源码走读
文件:Admin.NET.Web.Core/Handlers/JwtHandler.cs(继承 Furion 的 AppAuthorizeHandler)
3.1 HandleAsync —— Token 有效性
public override async Task HandleAsync(AuthorizationHandlerContext context, DefaultHttpContext httpContext)
{
using var serviceScope = _serviceProvider.CreateScope();
var sysCacheService = serviceScope.ServiceProvider.GetRequiredService<SysCacheService>();
// ① 自动续期:失败即视为过期
var tokenExpire = await sysConfigService.GetTokenExpire();
var refreshTokenExpire = await sysConfigService.GetRefreshTokenExpire();
if (JWTEncryption.AutoRefreshToken(context, context.GetCurrentHttpContext(), tokenExpire, refreshTokenExpire))
await AuthorizeHandleAsync(context);
else { context.Fail(...); ... return; }
// ② Token 黑名单(Logout 时写入)
var accessToken = httpContext.Request.Headers.Authorization.ToString();
if (sysCacheService.ExistKey($"{CacheConst.KeyTokenBlacklist}:{accessToken}")) { context.Fail(...); return; }
// ③ 租户有效期
var tenantId = httpContext.User.FindFirst(ClaimConst.TenantId)?.Value;
var tenant = sysCacheService.HashGetOne<SysTenant>(CacheConst.KeyTenantHash, tenantId);
if (tenant?.ExpirationTime != null && DateTime.Now > tenant.ExpirationTime) { context.Fail(...); return; }
// ④ TokenVersion(移动端可由 SysMobileTokenVersion 配置跳过)
var tokenVersion1 = httpContext.User.FindFirst(ClaimConst.TokenVersion)?.Value;
var tokenVersion2 = sysCacheService.Get<string>($"{CacheConst.KeyUserToken}{userId}");
if (tokenVersion1 != tokenVersion2) { context.Fail(...); return; }
}顺序记忆:续期 → 黑名单 → 租户过期 → 停用状态 → Token 版本。
3.2 PipelineAsync / CheckAuthorizeAsync —— 权限码比对
public override async Task<bool> PipelineAsync(AuthorizationHandlerContext context, DefaultHttpContext httpContext)
{
return await CheckAuthorizeAsync(httpContext);
}
private static async Task<bool> CheckAuthorizeAsync(DefaultHttpContext httpContext)
{
// ① 超管直接放行
if (App.User.FindFirst(ClaimConst.AccountType)?.Value == ((int)AccountTypeEnum.SuperAdmin).ToString()) return true;
using var serviceScope = httpContext.RequestServices.CreateScope();
// ② 去掉 /api/ 前缀,得到 sysUser/page 形式的权限码
var defaultRoutePrefix = App.GetOptions<DynamicApiControllerSettingsOptions>().DefaultRoutePrefix;
var path = httpContext.Request.Path.ToString().Replace($"/{defaultRoutePrefix}/", "");
// ③ 移动端用独立的 App 接口集合
if (App.User.FindFirst(ClaimConst.LoginMode)?.Value == ((int)LoginModeEnum.APP).ToString())
return serviceScope.ServiceProvider.GetRequiredService<SysCommonService>()
.GetAppApiList().Exists(u => path.Equals(u, StringComparison.CurrentCultureIgnoreCase));
// ④ PC 端:白名单命中即放行;否则必须在黑名单之外
var userApiList = await serviceScope.ServiceProvider.GetRequiredService<SysRoleService>().GetUserApiList();
if (userApiList[0].Exists(u => path.Equals(u, StringComparison.CurrentCultureIgnoreCase))) return true;
return userApiList[1].TrueForAll(u => !path.Equals(u, StringComparison.CurrentCultureIgnoreCase));
}匹配用的是
path.Equals(...)(全等忽略大小写),不是Contains。这意味着带路由参数的接口(sysUser/detail/{id})不会被自动放行,需要把对应的权限码配到按钮里,或走黑名单之外的默认放行逻辑。
四、GetUserApiList() 白/黑名单
源码:Admin.NET.Core/Service/Role/SysRoleService.cs:426
[DisplayName("获取用户接口集合")]
public async Task<List<List<string>>> GetUserApiList()
{
var userId = _userManager.UserId;
var apiList = _sysCacheService.Get<List<List<string>>>($"{CacheConst.KeyUserApi}{userId}");
if (apiList != null) return apiList;
apiList = [[], []]; // [0]=白名单 [1]=黑名单
var allButtonList = await GetButtonList(); // 所有 MenuTypeEnum.Btn 的 Permission
if (_userManager.SuperAdmin)
{
// 超管:所有真实接口路由 + 所有按钮权限(取并集)
var allApiList = _sysCommonService.GetAllSysApiList();
foreach (var controller in allApiList.SelectMany(api => api.Children))
apiList[0].AddRange(controller.Children.Select(u => u.Route));
apiList[0].AddRange(allButtonList.Except(apiList[0]));
}
else
{
var roleIdList = await _sysUserRoleService.GetUserRoleIdList(_userManager.UserId);
var menuIdList = await _sysRoleMenuService.GetRoleMenuIdList(roleIdList);
apiList[0] = await GetButtonList(menuIdList, false); // 已授按钮(且启用)
apiList[1] = allButtonList.Except(apiList[0]).ToList(); // 未授按钮
apiList[1].AddRange(await _sysRoleApiService.GetRoleApiList(roleIdList)); // + 角色黑名单
}
apiList[0].Sort(); apiList[1].Sort();
_sysCacheService.Set($"{CacheConst.KeyUserApi}{userId}", apiList, TimeSpan.FromDays(7));
return apiList;
}
private async Task<List<string>> GetButtonList(List<long> menuIds = null, bool isAll = true)
{
return await _sysRoleRep.ChangeRepository<SqlSugarRepository<SysMenu>>().AsQueryable()
.WhereIF(menuIds != null && menuIds.Count > 0, u => menuIds.Contains(u.Id))
.WhereIF(!isAll, u => u.Status == StatusEnum.Enable) // ← 停用菜单不生效
.Where(u => u.Type == MenuTypeEnum.Btn)
.Select(u => u.Permission).ToListAsync();
}判定真值表:
| path 在白名单 | path 在黑名单 | 结果 |
|---|---|---|
| 是 | 任意 | ✅ 放行 |
| 否 | 否 | ✅ 放行(未纳入权限体系的接口,例如 sysAuth/getUserInfo) |
| 否 | 是 | ❌ 403 |
由此得出三条设计常识:
- 没在任何菜单按钮上登记的接口 = 谁都能调(只要登录)。若要收紧,必须把它登记到某个按钮上。
SysRoleApi用于"扣减":某个角色被授予了含该按钮的菜单,但你又想单独禁掉这一个接口时,才往SysRoleApi写一行。- 菜单停用 = 权限失效:
Status == Disable的按钮不进白名单,从而落进黑名单差集。
五、角色授权接口一览
| 作用 | 方法 | 路由 | 备注 |
|---|---|---|---|
| 授权菜单 | SysRoleService.GrantMenu(RoleMenuInput) | /api/sysRole/grantMenu | 内部转调 SysRoleMenuService.GrantRoleMenu |
| 授权接口(写黑名单) | SysRoleService.GrantApi(RoleApiInput) | /api/sysRole/grantApi | 转调 SysRoleApiService.GrantRoleApi |
| 查询角色接口黑名单 | SysRoleService.GetRoleApiList(BaseIdInput) | /api/sysRole/roleApiList | |
| 查询当前用户接口集合 | SysRoleService.GetUserApiList() | /api/sysRole/userApiList | 返回 [[白],[黑]] |
| 清角色相关用户缓存 | SysRoleService.ClearUserApiCache(long roleId) | [NonAction] | 删 sys_user_api:* 与 sys_user_role:* |
GrantMenu / GrantApi 之后务必清理用户接口缓存,否则 7 天内不生效:
await App.GetRequiredService<SysRoleService>().ClearUserApiCache(roleId);
// 或者直接
App.GetRequiredService<SysCacheService>().Remove($"{CacheConst.KeyUserApi}{userId}");
ClearUserApiCache只处理KeyUserApi与KeyUserRole;如果角色的数据范围也变了,还要配合SqlSugarFilter.DeleteUserOrgCache(userId, configId)(见第 13 篇)。
六、前端 v-auth 对应关系
后端 SysMenu.Permission = "sysUser/page"
│
├─► GetUserApiList()[0] ──► LoginUserOutput.Apis ──► pinia userInfos.authApiList
│ (SysAuthService.GetUserInfo 第 315、338 行)
└─► 前端指令判断
<el-button v-auth="'sysUser/page'">查询</el-button>前端指令源码 Web/src/directive/authDirective.ts:
export function authDirective(app: App) {
// 单个权限验证(v-auth="xxx")
app.directive('auth', {
mounted(el, binding) {
const stores = useUserInfo();
if (!stores.userInfos.authApiList.some((v: string) => v === binding.value)) el.parentNode.removeChild(el);
},
});
// 多个权限验证,满足一个则显示(v-auths="[xxx,xxx]")
app.directive('auths', { mounted(el, binding) { /* 命中任意一个即保留 */ } });
// 多个权限验证,全部满足则显示(v-auth-all="[xxx,xxx]")
app.directive('auth-all', { mounted(el, binding) { /* judgementSameArr 全包含 */ } });
}三种用法:
<el-button v-auth="'document/upload'">上传</el-button>
<el-button v-auths="['sysConfig/page', 'sysConfig/list']">查询</el-button>
<el-button v-auth-all="['sysUser/page', 'sysUser/update']">批量改角色</el-button>函数式写法(Web/src/utils/authFunction.ts):auth('sysUser/page') / auths([...]) / authAll([...])。
重要:前端
v-auth只是隐藏 DOM,真正的拦截在后端JwtHandler。按钮可以藏,接口一定要配权限码,否则用 Postman 直连就能跑。
七、新增接口的完整授权步骤
以"新增一个 sysDictType/export 导出接口"为例:
① 写后端接口
[ApiDescriptionSettings(Order = 200, Description = "字典管理")]
public class SysDictTypeService : IDynamicApiController, ITransient
{
/// <summary>
/// 导出字典类型 🔖
/// </summary>
[DisplayName("导出字典类型")]
public async Task<IActionResult> Export([FromBody] PageDictTypeInput input)
{
var list = await _dictTypeRep.AsQueryable().ToListAsync();
return App.HttpContext.Response.ExportExcel(list); // 示例形态,按实际导出组件写
}
}② 菜单表里登记权限(路径:系统管理 → 菜单管理)
- 找到所属菜单(例如"字典管理")→ 新增子项
- 类型 = 按钮
- 权限标识 =
sysDictType/export - 名称 = 导出
- 状态 = 启用
- 前端也可以只把它作为权限载体不渲染路由
③ 角色授权(路径:系统管理 → 角色管理 → 授权)
- 勾选刚才的"导出"按钮 → 调
POST /api/sysRole/grantMenu - 若要在某角色上单独禁掉,则
POST /api/sysRole/grantApi写黑名单
④ 清缓存
- 该角色下的所有用户:
SysRoleService.ClearUserApiCache(roleId) - 或直接戳 UI 上的"刷新缓存" / 让用户退出重登
⑤ 前端绑定
<el-button v-auth="'sysDictType/export'" @click="onExport">导出</el-button>⑥ 验证
- 用该角色账号登录 → 能看到按钮 → 请求返回 200
- 换个不含此权限的角色 → 按钮不渲染 → 直接 curl 该接口应得到
{"code":403,"type":"error","message":"403 禁止访问,没有权限"}
八、常见问题排查清单
现象:接口返回 403
├─ 该 path 是否出现在 GetUserApiList()[1] 黑名单里?
│ ├─ 是 → 检查是否有人往 SysRoleApi 写了这行;检查菜单是否停用(Status=Disable)
│ └─ 否 → 见下
├─ Claims 里 AccountType 是不是超管?(超管全放行,若期望被拦截则换账号)
├─ 是不是移动端 Token?移动走 GetAppApiList(),要把接口加入移动端接口集合
└─ path 替换是否正确?二级虚拟目录/网关改写了路径会导致 "/api/" 替换失效
现象:按钮显示了但点不动(403)
└─ 前端 v-auth 用的是白名单登录态,但服务端重新拉过权限且用户被换。让它重登;
或 authApiList 与 GetUserApiList 不同步(缓存 7 天)。
现象:所有人都被拦截
└─ 按钮权限码写错(多了斜杠/大小写),落进了“未被授予 → 黑名单”分支。⚠️ 注意事项 / 常见坑
- 未登记的接口=公开。新上线的敏感接口如果没有配菜单按钮,任何登录用户都能访问。上线前请跑一遍"换成普通角色 curl 一遍"的验收。
api/前缀替换只做一次:Replace($"/{defaultRoutePrefix}/", "")是全量替换,若路径里出现两次前缀(网关拼接失误)会得到错误权限码。- 修改
DefaultRoutePrefix会全站权限失效:菜单里存的还是旧格式,必须批量迁移SysMenu.Permission。 - 菜单停用会同时收权限:
GetButtonList(menuIds, isAll:false)加了Status == Enable条件,别以为停用菜单只影响侧边栏。 - 移动端走独立集合:
LoginMode == APP时用SysCommonService.GetAppApiList(),完全不看SysRoleMenu,别拿 PC 授权结果去解释移动端 403。 - 缓存 7 天:
KeyUserApiTTL 7 天,授权后一定要ClearUserApiCache,否则"改了没反应"的工单会找上你。 ClearUserApiCache(roleId)只按角色清:跨角色变更(比如直接改SysRoleApi表)要单独删用户级缓存。- 黑名单是"差集 + 显式记录"两部分:即使没人往
SysRoleApi插数据,未授予的按钮也会自动进黑名单。删除SysRoleApi行不一定能解封,要同步授予对应菜单按钮。 - 路由参数接口:
/api/sysUser/detail/{id}实际 path 是/api/sysUser/detail/123,永远不会等于权限码sysUser/detail。这种要么登记成不带参接口,要么在CheckAuthorizeAsync里自行扩展匹配规则(推荐用路由模板而非字符串比较)。 [AllowAnonymous]优先级最高:贴了就完全跳过JwtHandler,慎用于写操作。
小结
- 三张表:
SysMenu(权限码源)→SysRoleMenu(授予)→SysRoleApi(扣减) - 权限码形态
sysXxx/action,等于去掉api/前缀的接口路径 JwtHandler先验 Token(续期/黑名单/租户/TokenVersion),再由CheckAuthorizeAsync做白黑名单比对GetUserApiList()返回[[白名单],[黑名单]],缓存sys_user_api:{userId}七天- 前端
authApiList就是白名单数组,v-auth/v-auths/v-auth-all三个指令 +authFunction三个函数 - 新增接口六步:写接口 → 登记按钮 → 角色授权 → 清缓存 → 前端绑定 → 换号验收