service.xpcool.com/internal/service/admin/system/auth/auth.go
夏犀麟 6e3ef67709 refactor(service): 重构服务层结构并更新导入路径
- 将 admin 相关服务移动到 internal/service/admin 目录下
- 更新控制器中的服务导入路径引用
- 移除已合并的服务文件
- 添加统一工作约定文档
- 更新 API 接口定义的包路径
2026-08-26 15:40:13 +08:00

183 lines
7.4 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 auth
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 resolves the permission code required by an endpoint
// from admin_menu (type=2 rows) by matching "<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")
}
// 单次使用:撤销旧刷新会话,防止令牌被重复使用。
if _, err = dao.AuthRefreshSession.Ctx(ctx).Where(do.AuthRefreshSession{Jti: c.JTI}).WhereNull("revoked_at").Data(do.AuthRefreshSession{RevokedAt: gtime.Now()}).Update(); err != nil {
return nil, 0, gerror.Wrap(err, "撤销旧刷新令牌失败")
}
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 即接口权限标识。
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
}