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 " ". 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 }