后端: - 种子数据:超级管理员(admin/admin123)、super_admin 角色、22 条菜单(含按钮权限码)、角色绑定 - admin_menu 扩展 icon/component/hidden 字段(003_schema_ext.sql),手动补齐 entity/do/table - auth 扩展:/auth/info、/auth/codes(仅登录),路由拆分公开/Profile/受保护三组 - 菜单路由:/menu/routes 返回 vben backend 动态路由树(按角色过滤、排序) - RBAC 管理:/admins、/roles、/menus CRUD(含角色绑定、重置密码、菜单授权) - 日志监控:/log/files、/log/tail(尾部读取+关键词过滤+路径穿越防护) - 安全加固:接口鉴权改为「方法+路径→权限码」自动映射,杜绝任意权限码越权 - 修复:gf v2.10.2 不自动替换→cmd 注入;MySQL driver 需显式引入 contrib - 全链路冒烟测试通过(含越权拒绝 30003)
130 lines
5.0 KiB
Go
130 lines
5.0 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"regexp"
|
||
"strings"
|
||
|
||
"github.com/gogf/gf/v2/errors/gerror"
|
||
"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"
|
||
)
|
||
|
||
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")
|
||
}
|
||
access, refresh, exp, err := s.tokens.Issue(a.Id, "admin", "")
|
||
if err != nil {
|
||
return nil, 0, gerror.Wrap(err, "issue token")
|
||
}
|
||
return &dto.TokenPair{AccessToken: access, RefreshToken: refresh, ExpiresIn: exp}, a.Id, nil
|
||
}
|
||
func (s *adminAuth) HasPermission(ctx context.Context, adminID uint64, permission string) (bool, error) {
|
||
// 多角色权限通过管理员-角色-菜单三表关联查询,菜单中的 permission 即接口权限标识。
|
||
count, err := dao.AdminUserRole.Ctx(ctx).As("ur").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).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) {
|
||
// 权限码 = 该管理员所有启用角色绑定的菜单 permission(含菜单与按钮级),
|
||
// 同时用作 vben 前端按钮权限码与后端 X-Permission 校验标识。
|
||
list, err := dao.AdminUserRole.Ctx(ctx).As("ur").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).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
|
||
}
|