Files
EasyGoAdmin_GoFrame_EleVue/document/wiki/03_认证授权与多租户.txt
T
2026-10-20 11:37:25 +08:00

284 lines
17 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
================================================================================
EasyGoAdmin 后端开发者文档 · 03 认证授权与多租户
================================================================================
代码位置:internal/logic/auth.go、index.go、role.go、menu.go、tenant.go、
user.go、online_user.go、data_scope.go(数据权限);utility/jwt;
utility/captcha;utility/crypto
--------------------------------------------------------------------------------
1. JWT 双令牌(utility/jwt/jwt.go)
--------------------------------------------------------------------------------
算法 HS256;密钥 jwt.secret;签发者 jwt.issuer(默认 easygoadmin)。
Claims(golang-jwt/v5 + RegisteredClaims):
user_id / tenant_id / username / realname / typ(access|refresh)
RegisteredClaims:jti(随机)、iss、iat、exp(exp 必填,Parse 强校验)
有效期配置:
访问令牌 jwt.accessTokenExpire 默认 7200s(2h)
刷新令牌 代码读取 jwt.refreshExpire,默认 604800s(7d)
(config.yaml 中的 refreshTokenExpire 为同值说明项,运行时以
jwt.refreshExpire 为准,未配置走 7 天默认值)
Sign(ctx, tokenType, userID, tenantID, username, realname) → (token, claims, err)
Parse(ctx, tokenStr) → 校验 HMAC 方法(WithValidMethods HS256)、签名、
过期、签发者;失败统一返回 401 "Token无效或已失效"。
ExpireDuration(ctx, tokenType) → 有效期(黑名单 TTL 计算用)。
黑名单:easygoadmin:token:blacklist:{jti} = "1",TTL = 令牌剩余有效期。
写入时机:登出、refresh 轮换旧令牌、RemoveToken 指定撤销、在线用户强退
(访问+刷新一并拉黑)。
--------------------------------------------------------------------------------
2. 图形验证码(utility/captcha)
--------------------------------------------------------------------------------
GET /api/captcha → Captcha 生成(utility/captcha.Generate):
* 6 位字符(captcha.length,4~8),字符集排除易混淆字符
* 缓存 easygoadmin:captcha:{uuid},TTL captcha.expire(300s)
* 渲染 PNG(TTF 字体 resource/static/fonts/Vera.ttf + 干扰线/噪点),
字体缺失降级 SVG
* 返回 {key: uuid, captcha: "data:image/...;base64,..."}(前端直接展示)
Verify(ctx, key, code):一次性校验(命中即删除);配置 captcha.bypassCode
可免校验(仅开发调试,生产留空)。
--------------------------------------------------------------------------------
3. 登录闭环(logic/auth.go Login)
--------------------------------------------------------------------------------
POST /api/login(公开),入参 {username, password, tenantCode?, code?, key?}
流程(每一步失败均写登录日志 log_type=1 status=1):
1) 提供 key 时强制验证码校验(captcha.Verify)
2) findUser:username 全局查找;带 tenantCode 先按 code 定位租户再过滤;
多租户同名 → 优先平台租户(tenant_id=0),仍歧义 → 提示携带租户编码
3) 账号状态 status==1 校验(1002)
4) 租户状态与过期校验(mustLoadTenant,60s 缓存;1006/1005)
5) 密码:crypto.VerifyPassword(bcryptHash, password, salt)
—— bcrypt(password+salt) 双重加盐;IsBcrypt 识别旧 md5 数据(登录会拒绝,
需重置密码);salt 为空兼容直接比对
6) 签发双令牌(access + refresh,claims 含 user/tenant/username/realname)
7) 写登录日志成功(log_type=1 status=0,含 ip/location(os 归属地)/os/browser/
consume_time;code 参数脱敏为 ***)
8) MarkOnline 登记在线会话(见第 7 节)
返回(OAuth2 惯例命名,勿改驼峰):
{access_token, refresh_token, expires_in(秒), token_type:"Bearer"}
登录日志字段:username/log_type/source(0 后台)/method/request_method/url/param/
result(成功|失败)/ip/location/os/browser/consume_time/status/error。
3.1 登录时序图
Client Server/auth Redis DB
│ GET /api/captcha │ │ │
│────────────────────▶│ Generate ─────────▶│ SET captcha:uuid│
│◀── {key, 图片base64} │ │ │
│ POST /api/login {username,password,code,key,tenantCode?}
│────────────────────▶│ Verify ───────────▶│ GET+DEL captcha:uuid
│ │ findUser ──────────────────────────▶│ user(+tenant)
│ │ status==1? mustLoadTenant ────────▶│ tenant(60s缓存)
│ │ bcrypt(password+salt) 比对 │
│ │ Sign(access) + Sign(refresh) │
│ │ writeLoginLog(成功/失败) ──────────▶│ login_log
│ │ MarkOnline ───────▶│ ZAdd online:jti:set
│ │ │ SET online:info:{jti}
│◀── {access_token, refresh_token, expires_in, token_type} │
3.2 令牌生命周期状态图
Sign(access 2h) Refresh 轮换 / Logout / 强退
────────────────▶ 有效 ────────────────────────────────────────▶ 黑名单
▲ │ JWT中间件每请求: Parse + 黑名单Exists │
│ ▼ ▼
│ 到期(exp) ──前端──▶ POST /api/oauth2/token 自然过期(TTL
│ (refresh_token 模式) = 剩余有效期)自动清理
└── 旧 refresh 已拉黑则 1008;否则旧 refresh 入黑名单 + 签发新对
3.3 RBAC 授权链路图
请求 ──▶ JWT中间件 ──▶ LoadIdentity(userID)
│
user ─────────┤ dept_id → DeptId[](数据权限用)
user_role ────┤ role.code → Roles[]
│ role.data_scope → DataScope[](多角色取最宽松)
role_menu ────┤
menu(type=1, ┤ permission → Perms[]
status=0, │
hide=0) ────┘
▼
缓存 user:identity:{uid} JSON TTL 300s
▼
Bind 包装层 HasPerm("sys:user:page"):
permission=="" → 通过 │ Roles 含 ROLE_SUPER_ADMIN → 通过
│ Perms 精确匹配 → 通过 │ 否则 403
数据范围经 ctx(WithDataScope/WithDeptID)随行,
在 applyFilters 末环过滤(见第 6 节)
缓存失效:角色保存权限/改数据范围 / 用户分配角色/改部门 等主动 Del,过期兜底
--------------------------------------------------------------------------------
4. 令牌刷新与撤销
--------------------------------------------------------------------------------
POST /api/oauth2/token(公开)Refresh:
* grant_type=password 或无 refresh_token → 兼容密码模式,复用 Login
* refresh_token 模式:
- Parse 校验 + typ 必须为 refresh(1008)
- 旧 refresh 已在黑名单 → 1008(防重放)
- 旧 refresh 入黑名单(轮换)
- 签发新 access+refresh,MarkOnline 刷新在线登记
- 返回同登录格式四字段
GET /api/oauth2/check/token 登录可用;JWT 中间件通过即 {valid:true, userId}
DELETE /api/oauth2/remove/:token 登录可用;解析指定 token 的 jti 入黑名单
GET /api/logout 当前 jti 入黑名单 + MarkOffline + 登录日志(log_type=2)
--------------------------------------------------------------------------------
5. RBAC 权限模型
--------------------------------------------------------------------------------
表关系:user —(user_role)— role —(role_menu)— menu(type=1 权限节点)
身份加载 LoadIdentity(JWT 中间件调用,缓存 300s,键 user:identity:{uid}):
1) user 行回查 dept_id → DeptId(数据权限过滤用;单列查询)
2) user_role → roleIds → role.code 集合(Roles)
同一查询顺带读 role.data_scope:多角色取最宽松(数值 min,
1 最宽 4 最严;缺列值按"全部"参与比较)→ DataScope
3) role_menu → menuIds → menu 中 type=1 且 status=0 且 hide=0 的
permission 非空去重集合(Perms)
4) 超管/平台:DataScope 强制"全部"(过滤层另有 dataScopeBypass 双保险)
权限判定 consts.HasPerm(ctx, "sys:user:page"):
permission 为空 → 通过(仅登录即可)
Roles 含 ROLE_SUPER_ADMIN → 通过(超管全放行)
Perms 精确匹配 → 通过;否则 403。
前端识别超管通配权限 *:*:*(getUser 返回)放开全部按钮权限。
权限点声明:接口 gmeta permission 标签;生成器自动创建
sys:{module小驼峰}:{page|list|detail|add|update|delete|batchDelete|status|import|export}
节点并挂到模块菜单下。
角色管理扩展接口:
GET /api/role/menu/list/:roleId 查询角色已勾选菜单(sys:role:getPermission)
POST /api/role/menu/save 重写 role_menu + 清理相关用户身份缓存
(sys:role:savePermission)
菜单树 GetUserMenuTree:user→role→role_menu→menu 组装(index.GetMenus 消费);
超管返回全量菜单。
缓存失效:角色权限变更/改数据范围(roleSpec.AfterUpdate →
roleClearIdentityCache)/ 用户分配角色/用户改部门(user.Update →
AssignRoles → clearIdentityCache)后清理 user:identity:{uid}
(及 user:perms/user:roles 键),最迟 300s 自然过期兜底。
6. 角色数据权限(行级,role.data_scope,logic/data_scope.go)
与租户隔离(第 8 节,硬规则)独立叠加的第二层行级过滤,四档:
档位 含义 过滤条件(作用于挂接模块的列表行)
---- -------------- -----------------------------------------
1 全部 不过滤(租户隔离之内全量)
2 本部门及以下 dept_id IN (自身部门 + 全部子孙部门ID)
3 本部门 dept_id = 自身部门ID
4 仅本人 id = 自身用户ID
模块接线(一行声明):
userSpec = ModuleSpec[...]{ ..., DataScope: ApplyDataScope("dept_id") }
ApplyDataScope(deptColumn) 返回 DataScopeFunc 闭包,挂进 applyFilters
末环(Like→Eq→Keyword→Filter→DataScope),Page/List/导出自动生效。
目前仅 user 模块接线(全库仅 goframe_user 有 dept_id 列);
其他模块需要时声明同款闭包即可,无部门归属列的表可按需扩展
按创建人过滤的变体。
安全语义(与直觉不同的点):
* 多角色取最宽松(并集直觉),合并发生在 LoadIdentity 缓存层,
热路径零额外查询;
* 超管/平台完全绕过(IsPlatform 或 Roles 含 ROLE_SUPER_ADMIN,
dataScopeBypass 双判断);
* 未分配部门(dept_id=0)的用户挂档 2/3 时降级为"仅本人"
(防 dept_id=0 意外放大可见面);
* 仅作用于 Page/List/导出(applyFilters 链);Detail/Update/Delete
不过滤,由 permission 节点与租户隔离兜底(边界见 17 章第 8 节);
* 部门子树 deptSubtreeIds:dao.Dept.Ctx 拉全租户部门行(id,parent_id)
后 BFS(树小直查,无缓存;成热点可加短时缓存)。
配置链路:
role 表 data_scope 列(默认 1)→ 角色表单"数据范围"下拉(字典
role_data_scope 驱动,值 1/2/3/4)→ roleToData 归一(缺省回 1)
→ 角色 Update 后 AfterUpdate 清该角色全部用户身份缓存 → 生效。
--------------------------------------------------------------------------------
7. 个人中心(logic/index.go)
--------------------------------------------------------------------------------
GET /api/index/getUser 当前用户信息(超管返回通配权限 *:*:*)
GET /api/index/getMenus 当前用户菜单树(无参版,前端 common 菜单)
GET /api/index/getMenus/:id 同上(system/menu 页面调用)
PUT /api/index/updatePassword 旧密码校验(1012)+ bcrypt 新密码(事务外预计算)
PUT /api/index/updateProfile 更新个人信息(realname/avatar/mobile/email 等)
--------------------------------------------------------------------------------
8. 多租户
--------------------------------------------------------------------------------
7.1 数据模型
goframe_tenant:code(T+4位ID 规则由 Account 生成)、status(1 正常/2 禁用)、
expire_time(nil=永不过期)、max_user_num 等。
9 张租户级表带 tenant_id 列(见 01 章 5.3)。
7.2 读侧隔离
dao.Ctx(hooks.ApplyCtx):非平台上下文自动追加 tenant_id IN (0, 自身);
超管(ctx=0)不加过滤。写侧禁止手写该条件。
7.3 写侧归属
insertHook:租户用户插入未显式指定时自动填自身(防逃逸);超管未指定时填
tenant.defaultId(默认 1),显式 0 可建平台共享行,显式具体值从显式值。
业务取归属租户统一 consts.GetTenantID(ctx)。
7.4 请求级校验
Tenant 中间件(见 02 章)每请求校验 status/expire(60s 缓存);
登录路径 mustLoadTenant 同缓存复用。
7.5 租户开户(tenant.Account,POST /api/tenant/account)
创建租户 + 初始管理员:事务内建租户(code 生成)+ 建管理员用户 +
分配角色;max_user_num 上限校验(1007)在 user.Add 生效
(checkTenantUserMax)。事务纪律:bcrypt 等 CPU 计算在事务外预计算,
事务内不做 HTTP/文件 IO。
7.6 其他
tenant 用户列表:GET /api/tenant/users/:tenantId
租户状态变更后缓存 tenant:info:{id} 需清理(tenant 模块 clearCache 钩子)。
--------------------------------------------------------------------------------
9. 用户模块要点(logic/user.go)
--------------------------------------------------------------------------------
* 手写唯一校验:username/mobile 按“行现租户”检查(uk_tenant_username /
uk_tenant_mobile),Update 时以被改行的 tenant_id 为准(预检范围必须等于
唯一索引范围,详见 module_spec 章唯一校验说明)。
* Add:租户用户数上限校验;密码 bcrypt(事务外);genUserCode 生成用户编码;
AssignRoles 写 user_role。
* Update:AssignRoles 同步角色 + clearIdentityCache;城市编码经
ResolveCityCodes 存库、详情经 ResolveCityNames 还原。
* ResetPwd(PUT /api/user/resetPwd,sys:user:resetPwd):重置密码(bcrypt)。
* Import(POST /api/user/import):Excel(excelize)解析 → 逐行校验 →
批量插入(含角色/城市/密码默认);Export:ChunkQuery 分批防 OOM,
excel.WriteSheet 落临时文件返回 URL。
* 删除守卫:userDeleteGuard / 关联清理(user_role)。
--------------------------------------------------------------------------------
10. 在线登记(logic/online_user.go MarkOnline/MarkOffline)
--------------------------------------------------------------------------------
登录/刷新成功后登记:
zset easygoadmin:online:jti:set score=access 过期时间戳
详情键 easygoadmin:online:info:{jti} = JSON(含 userId、username、
realname、ip、location、os、browser、loginTime、access/refresh jti 与
起止时间戳、令牌原文(详情查看用)、tokenType、expiresIn),TTL=access
剩余有效期+60s。
登出/强退 MarkOffline:ZRem + Del。
在线用户模块的分页/详情/踢出见 06 章。
--------------------------------------------------------------------------------
11. 安全清单
--------------------------------------------------------------------------------
* 密码:bcrypt(password+salt),Cost=DefaultCost;旧 md5 数据被 IsBcrypt 拒绝
* 令牌:HS256 + jti 黑名单 + refresh 轮换(旧 refresh 拉黑防重放)
* 强退:访问+刷新令牌一并拉黑(防被踢会话经 refresh 复活)
* 限流:认证入口独立更严阈值(authLimit=10/分钟/IP)
* 脱敏:操作日志 param 中 password/salt/access_token/refresh_token → ***
* 代理:仅受信代理的 XFF 生效(api.proxyIps)
* 会话固定:登录信息全部由服务端签发 claims 承载,无 cookie 会话