- 将 admin 相关服务移动到 internal/service/admin 目录下 - 更新控制器中的服务导入路径引用 - 移除已合并的服务文件 - 添加统一工作约定文档 - 更新 API 接口定义的包路径
183 lines
7.4 KiB
Go
183 lines
7.4 KiB
Go
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
|
||
}
|