284 lines
17 KiB
Plaintext
284 lines
17 KiB
Plaintext
================================================================================
|
||
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 会话
|