FIX: RBAC 禁用角色权限隔离(Codes/HasPermission/Routes 过滤 r.status=1)
- 三处联表查询统一 LeftJoin admin_role 并加启用状态过滤 - 修复前 opuser(禁用角色) 仍能拿到权限码并访问受保护接口
This commit is contained in:
parent
45f9098e89
commit
2ed31f012e
@ -1,6 +1,11 @@
|
|||||||
# service.xpcool.com 变更记录
|
# service.xpcool.com 变更记录
|
||||||
> 倒序:最新在上。格式:YYYY-MM-DD | 类型 | 摘要
|
> 倒序:最新在上。格式:YYYY-MM-DD | 类型 | 摘要
|
||||||
|
|
||||||
|
2026-08-26 | FIX | RBAC 安全漏洞修复:禁用角色(status=0)绑定的权限仍生效。根因为 Codes/HasPermission/Routes 三处联表查询未过滤 admin_role 启用状态,仅 roleCodes 过滤。修复:三处统一 LeftJoin admin_role 并加 r.status=1,opuser 联调验证 codes 空/routes 空/接口 403,admin 全链路回归通过
|
||||||
|
|
||||||
|
2026-08-26 | CFG | 修复 GoLand 直接 Run 报「找不到 config.yaml」:新增项目级运行配置 .run/service-dev.run.xml(内置 GF_GCFG_FILE=config.dev.yaml + DB_DSN + JWT_SECRET 三环境变量);.run/ 加入 .gitignore(含口令);README 补「Local startup」小节(终端 .env.dev / GoLand 运行配置两种方式)
|
||||||
|
|
||||||
|
2026-08-26 | FIX | 前端登录 502 联调:根因为服务未启动(10100 无监听);按 .env.dev 启动后 login/info/codes/menu-routes 全链路 curl 200,dev server(20100/20101/20102/20103)代理全部恢复
|
||||||
2026-08-26 | CFG | CHANGELOG 纳入 git 管理(.gitignore 放行 .workbuddy/memory/CHANGELOG.md),中央工作空间迁移至 ../workbuddy.xpcool.com(相对路径索引)
|
2026-08-26 | CFG | CHANGELOG 纳入 git 管理(.gitignore 放行 .workbuddy/memory/CHANGELOG.md),中央工作空间迁移至 ../workbuddy.xpcool.com(相对路径索引)
|
||||||
|
|
||||||
2026-08-26 | CHG | 统一 API 前缀 /api/service/(admin→/api/service/admin、user→/api/service/user、open→/api/service/open);服务端口统一 10100(dev/prod/test);DB admin_menu 权限映射前缀同步;前端 vite 代理与 api 文件同步适配(全 POST + 动作式路径),路由回归 29 条一致
|
2026-08-26 | CHG | 统一 API 前缀 /api/service/(admin→/api/service/admin、user→/api/service/user、open→/api/service/open);服务端口统一 10100(dev/prod/test);DB admin_menu 权限映射前缀同步;前端 vite 代理与 api 文件同步适配(全 POST + 动作式路径),路由回归 29 条一致
|
||||||
|
|||||||
188
internal/service/admin/admin/login/login.go
Normal file
188
internal/service/admin/admin/login/login.go
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
package admin_admin_login
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gogf/gf/v2/errors/gerror"
|
||||||
|
"github.com/gogf/gf/v2/os/gtime"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"service.xpcool.com/internal/consts"
|
||||||
|
"service.xpcool.com/internal/dao"
|
||||||
|
"service.xpcool.com/internal/library/jwt"
|
||||||
|
"service.xpcool.com/internal/library/response"
|
||||||
|
"service.xpcool.com/internal/model/do"
|
||||||
|
"service.xpcool.com/internal/model/dto"
|
||||||
|
"service.xpcool.com/internal/model/entity"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IAdminAuth 管理端认证服务接口。
|
||||||
|
type IAdminAuth interface {
|
||||||
|
Login(context.Context, dto.AdminLoginInput) (*dto.TokenPair, uint64, error)
|
||||||
|
Refresh(context.Context, string) (*dto.TokenPair, uint64, error)
|
||||||
|
HasPermission(context.Context, uint64, string) (bool, error)
|
||||||
|
Info(context.Context, uint64) (*dto.AdminInfo, error)
|
||||||
|
Codes(context.Context, uint64) ([]string, error)
|
||||||
|
// PermissionForPath 解析接口所需的权限码,
|
||||||
|
// 从 admin_menu(type=2 行)按 "<METHOD> <path>" 匹配得到。
|
||||||
|
PermissionForPath(context.Context, string, string) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
var localAdminAuth IAdminAuth
|
||||||
|
|
||||||
|
func AdminAuth() IAdminAuth {
|
||||||
|
if localAdminAuth == nil {
|
||||||
|
panic("AdminAuth implementation not registered")
|
||||||
|
}
|
||||||
|
return localAdminAuth
|
||||||
|
}
|
||||||
|
func RegisterAdminAuth(i IAdminAuth) { localAdminAuth = i }
|
||||||
|
|
||||||
|
type adminAuth struct{ tokens *jwt.Service }
|
||||||
|
|
||||||
|
func NewAdminAuth(tokens *jwt.Service) IAdminAuth { return &adminAuth{tokens} }
|
||||||
|
func (s *adminAuth) Login(ctx context.Context, in dto.AdminLoginInput) (*dto.TokenPair, uint64, error) {
|
||||||
|
// 管理端只允许账号密码登录,状态异常或密码错误均返回统一错误,避免枚举账号。
|
||||||
|
var a entity.AdminUser
|
||||||
|
if err := dao.AdminUser.Ctx(ctx).Where(do.AdminUser{Username: in.Username}).Scan(&a); err != nil {
|
||||||
|
return nil, 0, gerror.Wrap(err, "query administrator")
|
||||||
|
}
|
||||||
|
if a.Id == 0 {
|
||||||
|
return nil, 0, response.Error(consts.CodeAdminNotFound, "administrator not found")
|
||||||
|
}
|
||||||
|
if a.Status != 1 || bcrypt.CompareHashAndPassword([]byte(a.PasswordHash), []byte(in.Password)) != nil {
|
||||||
|
return nil, 0, response.Error(consts.CodeAdminPasswordWrong, "username or password incorrect")
|
||||||
|
}
|
||||||
|
// 通过 issue 签发令牌对并把刷新令牌 JTI 落库,保证后续可刷新、可撤销。
|
||||||
|
return s.issue(ctx, a.Id, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh 用有效的刷新令牌轮换管理员令牌对:校验 → 撤销旧会话 → 签发新对并落库。
|
||||||
|
func (s *adminAuth) Refresh(ctx context.Context, refresh string) (*dto.TokenPair, uint64, error) {
|
||||||
|
c, err := s.tokens.Parse(refresh, "refresh", "admin")
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, response.Error(consts.CodeUnauthorized, "invalid refresh token")
|
||||||
|
}
|
||||||
|
// 单次使用:撤销旧刷新会话并校验影响行数,重复使用旧令牌直接拒绝。
|
||||||
|
result, err := dao.AuthRefreshSession.Ctx(ctx).Where(do.AuthRefreshSession{Jti: c.JTI}).WhereNull("revoked_at").Data(do.AuthRefreshSession{RevokedAt: gtime.Now()}).Update()
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, gerror.Wrap(err, "撤销旧刷新令牌失败")
|
||||||
|
}
|
||||||
|
affected, err := result.RowsAffected()
|
||||||
|
if err != nil || affected != 1 {
|
||||||
|
return nil, 0, response.Error(consts.CodeUnauthorized, "刷新令牌已失效")
|
||||||
|
}
|
||||||
|
return s.issue(ctx, c.Subject, c.Terminal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// issue 签发令牌对并把刷新令牌的 JTI 落库,支持撤销与设备会话追踪。
|
||||||
|
func (s *adminAuth) issue(ctx context.Context, id uint64, terminal string) (*dto.TokenPair, uint64, error) {
|
||||||
|
access, refresh, exp, err := s.tokens.Issue(id, "admin", terminal)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, gerror.Wrap(err, "issue token")
|
||||||
|
}
|
||||||
|
claims, err := s.tokens.Parse(refresh, "refresh", "admin")
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, gerror.Wrap(err, "解析新刷新令牌失败")
|
||||||
|
}
|
||||||
|
if _, err = dao.AuthRefreshSession.Ctx(ctx).Data(do.AuthRefreshSession{
|
||||||
|
SubjectId: id, Scope: "admin", Terminal: terminal,
|
||||||
|
Jti: claims.JTI, ExpiredAt: gtime.NewFromTimeStamp(claims.ExpireAt),
|
||||||
|
}).Insert(); err != nil {
|
||||||
|
return nil, 0, gerror.Wrap(err, "保存刷新令牌会话失败")
|
||||||
|
}
|
||||||
|
return &dto.TokenPair{AccessToken: access, RefreshToken: refresh, ExpiresIn: exp}, id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *adminAuth) HasPermission(ctx context.Context, adminID uint64, permission string) (bool, error) {
|
||||||
|
// 多角色权限通过管理员-角色-菜单四表关联查询,菜单中的 permission 即接口权限标识。
|
||||||
|
// 必须过滤角色启用状态 r.status=1,否则被禁用角色绑定的权限仍然生效。
|
||||||
|
count, err := dao.AdminUserRole.Ctx(ctx).As("ur").LeftJoin("admin_role r", "ur.role_id=r.id").LeftJoin("admin_role_menu rm", "ur.role_id=rm.role_id").LeftJoin("admin_menu m", "rm.menu_id=m.id").Where("ur.admin_user_id", adminID).Where("m.permission", permission).Where("m.status", 1).Where("r.status", 1).Count()
|
||||||
|
if err != nil {
|
||||||
|
return false, gerror.Wrap(err, "check permission")
|
||||||
|
}
|
||||||
|
return count > 0, nil
|
||||||
|
}
|
||||||
|
func (s *adminAuth) Info(ctx context.Context, adminID uint64) (*dto.AdminInfo, error) {
|
||||||
|
var a entity.AdminUser
|
||||||
|
if err := dao.AdminUser.Ctx(ctx).Where(do.AdminUser{Id: adminID}).Scan(&a); err != nil {
|
||||||
|
return nil, gerror.Wrap(err, "query admin info")
|
||||||
|
}
|
||||||
|
if a.Id == 0 {
|
||||||
|
return nil, response.Error(consts.CodeAdminNotFound, "administrator not found")
|
||||||
|
}
|
||||||
|
roles, err := s.roleCodes(ctx, adminID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &dto.AdminInfo{AdminID: a.Id, Username: a.Username, Nickname: a.Nickname, Roles: roles}, nil
|
||||||
|
}
|
||||||
|
func (s *adminAuth) Codes(ctx context.Context, adminID uint64) ([]string, error) {
|
||||||
|
// 权限码 = 该管理员所有启用角色(r.status=1)绑定的菜单 permission(含菜单与按钮级),
|
||||||
|
// 同时用作 vben 前端按钮权限码与后端 X-Permission 校验标识。
|
||||||
|
list, err := dao.AdminUserRole.Ctx(ctx).As("ur").LeftJoin("admin_role r", "ur.role_id=r.id").LeftJoin("admin_role_menu rm", "ur.role_id=rm.role_id").LeftJoin("admin_menu m", "rm.menu_id=m.id").Where("ur.admin_user_id", adminID).Where("m.status", 1).Where("r.status", 1).WhereGT("m.permission", "").Fields("DISTINCT m.permission").Array()
|
||||||
|
if err != nil {
|
||||||
|
return nil, gerror.Wrap(err, "query access codes")
|
||||||
|
}
|
||||||
|
codes := make([]string, 0, len(list))
|
||||||
|
for _, v := range list {
|
||||||
|
codes = append(codes, v.String())
|
||||||
|
}
|
||||||
|
return codes, nil
|
||||||
|
}
|
||||||
|
func (s *adminAuth) roleCodes(ctx context.Context, adminID uint64) ([]string, error) {
|
||||||
|
list, err := dao.AdminUserRole.Ctx(ctx).As("ur").LeftJoin("admin_role r", "ur.role_id=r.id").Where("ur.admin_user_id", adminID).Where("r.status", 1).Fields("DISTINCT r.code").Array()
|
||||||
|
if err != nil {
|
||||||
|
return nil, gerror.Wrap(err, "query admin roles")
|
||||||
|
}
|
||||||
|
codes := make([]string, 0, len(list))
|
||||||
|
for _, v := range list {
|
||||||
|
codes = append(codes, v.String())
|
||||||
|
}
|
||||||
|
return codes, nil
|
||||||
|
}
|
||||||
|
func (s *adminAuth) PermissionForPath(ctx context.Context, method, path string) (string, error) {
|
||||||
|
// 从菜单表 type=2(按钮/API)行反查当前请求所需的权限码。
|
||||||
|
// 未配置映射的接口一律拒绝访问(返回空则中间件拦截)。
|
||||||
|
var list []entity.AdminMenu
|
||||||
|
if err := dao.AdminMenu.Ctx(ctx).Where(do.AdminMenu{Type: 2, Status: 1}).Scan(&list); err != nil {
|
||||||
|
return "", gerror.Wrap(err, "query permission mappings")
|
||||||
|
}
|
||||||
|
req := method + " " + path
|
||||||
|
for _, m := range list {
|
||||||
|
if matchRoute(m.Path, req) {
|
||||||
|
return m.Permission, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchRoute 匹配 "METHOD /path" 模式,{id} 视为动态段。
|
||||||
|
func matchRoute(pattern, req string) bool {
|
||||||
|
if pattern == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteByte('^')
|
||||||
|
for i := 0; i < len(pattern); i++ {
|
||||||
|
c := pattern[i]
|
||||||
|
switch {
|
||||||
|
case c == '{':
|
||||||
|
if j := strings.IndexByte(pattern[i:], '}'); j > 0 {
|
||||||
|
b.WriteString("[^/]+")
|
||||||
|
i += j
|
||||||
|
} else {
|
||||||
|
b.WriteString(regexp.QuoteMeta(string(c)))
|
||||||
|
}
|
||||||
|
case c == ' ' || c == '/' || c == '-' || c == '_' || c == '.' ||
|
||||||
|
(c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9'):
|
||||||
|
b.WriteByte(c)
|
||||||
|
default:
|
||||||
|
b.WriteString(regexp.QuoteMeta(string(c)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.WriteByte('$')
|
||||||
|
ok, err := regexp.MatchString(b.String(), req)
|
||||||
|
return err == nil && ok
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package menu
|
package admin_system_menu
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@ -14,7 +14,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type IAdminMenu interface {
|
type IAdminMenu interface {
|
||||||
// Routes returns the visible menu tree of an admin as vben route items.
|
// Routes 返回管理员的可见菜单树(vben 路由项)。
|
||||||
Routes(context.Context, uint64) ([]*dto.RouteItem, error)
|
Routes(context.Context, uint64) ([]*dto.RouteItem, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -32,8 +32,8 @@ func AdminMenu() IAdminMenu {
|
|||||||
}
|
}
|
||||||
func RegisterAdminMenu(i IAdminMenu) { localAdminMenu = i }
|
func RegisterAdminMenu(i IAdminMenu) { localAdminMenu = i }
|
||||||
|
|
||||||
// routeName converts a permission code like "system:admin" to a unique
|
// routeName 将权限码(如 "system:admin")转换为唯一的
|
||||||
// vben route name, e.g. "SystemAdmin".
|
// vben 路由名,如 "SystemAdmin"。
|
||||||
func routeName(permission string) string {
|
func routeName(permission string) string {
|
||||||
if permission == "" {
|
if permission == "" {
|
||||||
return ""
|
return ""
|
||||||
@ -42,14 +42,16 @@ func routeName(permission string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *adminMenu) Routes(ctx context.Context, adminID uint64) ([]*dto.RouteItem, error) {
|
func (s *adminMenu) Routes(ctx context.Context, adminID uint64) ([]*dto.RouteItem, error) {
|
||||||
// 当前管理员所有启用角色可见的菜单(type=1),含按钮行但不作为路由节点。
|
// 当前管理员所有启用角色(r.status=1)可见的菜单(type=1),含按钮行但不作为路由节点。
|
||||||
var list []entity.AdminMenu
|
var list []entity.AdminMenu
|
||||||
if err := dao.AdminUserRole.Ctx(ctx).As("ur").
|
if err := dao.AdminUserRole.Ctx(ctx).As("ur").
|
||||||
|
LeftJoin("admin_role r", "ur.role_id=r.id").
|
||||||
LeftJoin("admin_role_menu rm", "ur.role_id=rm.role_id").
|
LeftJoin("admin_role_menu rm", "ur.role_id=rm.role_id").
|
||||||
LeftJoin("admin_menu m", "rm.menu_id=m.id").
|
LeftJoin("admin_menu m", "rm.menu_id=m.id").
|
||||||
Where("ur.admin_user_id", adminID).
|
Where("ur.admin_user_id", adminID).
|
||||||
Where("m.type", 1).
|
Where("m.type", 1).
|
||||||
Where("m.status", 1).
|
Where("m.status", 1).
|
||||||
|
Where("r.status", 1).
|
||||||
Where("m.deleted_at IS NULL").
|
Where("m.deleted_at IS NULL").
|
||||||
Fields("m.*").
|
Fields("m.*").
|
||||||
Scan(&list); err != nil {
|
Scan(&list); err != nil {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user