service.xpcool.com/internal/service/admin_auth.go
夏犀麟 8fdb25e6bd feat(admin): 后台登录/菜单/按钮级权限/RBAC管理与服务器日志监控
后端:
- 种子数据:超级管理员(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)
2026-08-24 22:35:54 +08:00

130 lines
5.0 KiB
Go
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.

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
}