test: improve test stability and documentation

- Add 'clean_run.sh' script to reset database and restart server for clean test environment
    - Update 'README.md' with detailed troubleshooting guide and pitfalls
    - Add '04_org_load_middleware.sh' to test LoadOrg middleware functionality
    - Update 'run_all.sh' to include new middleware test
    - Fix BASE_URL handling in 'lib.sh' and test scripts to support custom environments
    - Update '02_resource_perm.sh' to fix admin permission checks
    - Remove debug logging from 'auth.go'
master
veypi 1 week ago
parent 1f380587a9
commit f7c4f1ee86

@ -7,6 +7,7 @@
package auth package auth
import ( import (
baseAuth "github.com/veypi/vbase/auth"
"github.com/veypi/vbase/cfg" "github.com/veypi/vbase/cfg"
"github.com/veypi/vbase/libs/crypto" "github.com/veypi/vbase/libs/crypto"
"github.com/veypi/vbase/models" "github.com/veypi/vbase/models"
@ -15,7 +16,7 @@ import (
// me 获取当前用户信息 // me 获取当前用户信息
func me(x *vigo.X) (*UserInfo, error) { func me(x *vigo.X) (*UserInfo, error) {
userID := getCurrentUserID(x) userID := baseAuth.GetUserID(x)
if userID == "" { if userID == "" {
return nil, vigo.ErrUnauthorized return nil, vigo.ErrUnauthorized
} }
@ -43,7 +44,7 @@ type UpdateMeRequest struct {
// updateMe 更新当前用户信息 // updateMe 更新当前用户信息
func updateMe(x *vigo.X, req *UpdateMeRequest) (*UserInfo, error) { func updateMe(x *vigo.X, req *UpdateMeRequest) (*UserInfo, error) {
userID := getCurrentUserID(x) userID := baseAuth.GetUserID(x)
if userID == "" { if userID == "" {
return nil, vigo.ErrUnauthorized return nil, vigo.ErrUnauthorized
} }
@ -80,7 +81,7 @@ type ChangePasswordRequest struct {
// changePassword 修改密码 // changePassword 修改密码
func changePassword(x *vigo.X, req *ChangePasswordRequest) error { func changePassword(x *vigo.X, req *ChangePasswordRequest) error {
userID := getCurrentUserID(x) userID := baseAuth.GetUserID(x)
if userID == "" { if userID == "" {
return vigo.ErrUnauthorized return vigo.ErrUnauthorized
} }
@ -108,10 +109,3 @@ func changePassword(x *vigo.X, req *ChangePasswordRequest) error {
return nil return nil
} }
func getCurrentUserID(x *vigo.X) string {
if uid, ok := x.Get("user_id").(string); ok {
return uid
}
return ""
}

@ -83,7 +83,7 @@ func authorizeThirdParty(x *vigo.X, req *AuthorizeRequest) (*AuthorizeResponse,
// 如果是绑定模式,需要当前用户登录 // 如果是绑定模式,需要当前用户登录
if req.BindMode { if req.BindMode {
userID := getCurrentUserID(x) userID := baseauth.GetUserID(x)
if userID == "" { if userID == "" {
return nil, vigo.ErrUnauthorized.WithString("login required for bind mode") return nil, vigo.ErrUnauthorized.WithString("login required for bind mode")
} }
@ -318,7 +318,7 @@ type UnbindRequest struct {
// unbindThirdParty 解除第三方账号绑定 // unbindThirdParty 解除第三方账号绑定
func unbindThirdParty(x *vigo.X, req *UnbindRequest) error { func unbindThirdParty(x *vigo.X, req *UnbindRequest) error {
userID := getCurrentUserID(x) userID := baseauth.GetUserID(x)
if userID == "" { if userID == "" {
return vigo.ErrUnauthorized return vigo.ErrUnauthorized
} }
@ -342,7 +342,7 @@ type BindingInfo struct {
// listBindings 获取当前用户的第三方绑定列表 // listBindings 获取当前用户的第三方绑定列表
func listBindings(x *vigo.X) ([]BindingInfo, error) { func listBindings(x *vigo.X) ([]BindingInfo, error) {
userID := getCurrentUserID(x) userID := baseauth.GetUserID(x)
if userID == "" { if userID == "" {
return nil, vigo.ErrUnauthorized return nil, vigo.ErrUnauthorized
} }

@ -7,6 +7,7 @@ package oauth
import ( import (
"time" "time"
"github.com/veypi/vbase/auth"
"github.com/veypi/vbase/cfg" "github.com/veypi/vbase/cfg"
"github.com/veypi/vbase/libs/cache" "github.com/veypi/vbase/libs/cache"
"github.com/veypi/vbase/libs/crypto" "github.com/veypi/vbase/libs/crypto"
@ -39,7 +40,7 @@ func authorize(x *vigo.X, req *AuthorizeRequest) (*AuthorizeResponse, error) {
} }
// 获取当前用户 // 获取当前用户
userID := getCurrentUserID(x) userID := auth.GetUserID(x)
if userID == "" { if userID == "" {
return nil, vigo.ErrUnauthorized return nil, vigo.ErrUnauthorized
} }
@ -54,7 +55,7 @@ func authorize(x *vigo.X, req *AuthorizeRequest) (*AuthorizeResponse, error) {
"redirect_uri": req.RedirectURI, "redirect_uri": req.RedirectURI,
"scope": req.Scope, "scope": req.Scope,
} }
if err := cache.SetObject(cache.OAuthCodeKey(code), authData, time.Minute * 10); err != nil { if err := cache.SetObject(cache.OAuthCodeKey(code), authData, time.Minute*10); err != nil {
return nil, vigo.ErrInternalServer.WithError(err) return nil, vigo.ErrInternalServer.WithError(err)
} }
@ -63,10 +64,3 @@ func authorize(x *vigo.X, req *AuthorizeRequest) (*AuthorizeResponse, error) {
State: req.State, State: req.State,
}, nil }, nil
} }
func getCurrentUserID(x *vigo.X) string {
if uid, ok := x.Get("user_id").(string); ok {
return uid
}
return ""
}

@ -5,6 +5,7 @@
package oauth package oauth
import ( import (
"github.com/veypi/vbase/auth"
"github.com/veypi/vbase/cfg" "github.com/veypi/vbase/cfg"
"github.com/veypi/vbase/libs/crypto" "github.com/veypi/vbase/libs/crypto"
"github.com/veypi/vbase/models" "github.com/veypi/vbase/models"
@ -12,8 +13,8 @@ import (
) )
type ListClientsRequest struct { type ListClientsRequest struct {
Page int `json:"page" src:"query" default:"1"` Page int `json:"page" src:"query" default:"1"`
PageSize int `json:"page_size" src:"query" default:"20"` PageSize int `json:"page_size" src:"query" default:"20"`
} }
type ListClientsResponse struct { type ListClientsResponse struct {
@ -53,10 +54,10 @@ func listClients(x *vigo.X, req *ListClientsRequest) (*ListClientsResponse, erro
} }
type CreateClientRequest struct { type CreateClientRequest struct {
Name string `json:"name" src:"json" desc:"客户端名称"` Name string `json:"name" src:"json" desc:"客户端名称"`
Description string `json:"description" src:"json" desc:"描述"` Description string `json:"description" src:"json" desc:"描述"`
RedirectURIs []string `json:"redirect_uris" src:"json" desc:"允许的重定向URI"` RedirectURIs []string `json:"redirect_uris" src:"json" desc:"允许的重定向URI"`
AllowedScopes string `json:"allowed_scopes" src:"json" desc:"允许的授权范围"` AllowedScopes string `json:"allowed_scopes" src:"json" desc:"允许的授权范围"`
} }
type CreateClientResponse struct { type CreateClientResponse struct {
@ -65,7 +66,7 @@ type CreateClientResponse struct {
} }
func createClient(x *vigo.X, req *CreateClientRequest) (*CreateClientResponse, error) { func createClient(x *vigo.X, req *CreateClientRequest) (*CreateClientResponse, error) {
ownerID := getCurrentUserID(x) ownerID := auth.GetUserID(x)
if ownerID == "" { if ownerID == "" {
return nil, vigo.ErrUnauthorized return nil, vigo.ErrUnauthorized
} }

@ -5,6 +5,7 @@
package oauth package oauth
import ( import (
"github.com/veypi/vbase/auth"
"github.com/veypi/vbase/cfg" "github.com/veypi/vbase/cfg"
"github.com/veypi/vbase/models" "github.com/veypi/vbase/models"
"github.com/veypi/vigo" "github.com/veypi/vigo"
@ -13,7 +14,7 @@ import (
// UserInfo OIDC用户信息 // UserInfo OIDC用户信息
func userInfo(x *vigo.X) (map[string]any, error) { func userInfo(x *vigo.X) (map[string]any, error) {
// 从token中解析用户ID // 从token中解析用户ID
userID := getCurrentUserID(x) userID := auth.GetUserID(x)
if userID == "" { if userID == "" {
return nil, vigo.ErrUnauthorized return nil, vigo.ErrUnauthorized
} }
@ -24,13 +25,13 @@ func userInfo(x *vigo.X) (map[string]any, error) {
} }
return map[string]any{ return map[string]any{
"sub": user.ID, "sub": user.ID,
"name": user.Nickname, "name": user.Nickname,
"nickname": user.Nickname, "nickname": user.Nickname,
"preferred_username": user.Username, "preferred_username": user.Username,
"email": user.Email, "email": user.Email,
"picture": user.Avatar, "picture": user.Avatar,
"email_verified": user.EmailVerified, "email_verified": user.EmailVerified,
}, nil }, nil
} }

@ -28,7 +28,7 @@ func create(x *vigo.X, req *CreateRequest) (*models.Org, error) {
} }
// 获取当前用户ID作为所有者 // 获取当前用户ID作为所有者
ownerID := getCurrentUserID(x) ownerID := auth.GetUserID(x)
if ownerID == "" { if ownerID == "" {
return nil, vigo.ErrUnauthorized return nil, vigo.ErrUnauthorized
} }
@ -110,10 +110,3 @@ func create(x *vigo.X, req *CreateRequest) (*models.Org, error) {
return org, nil return org, nil
} }
func getCurrentUserID(x *vigo.X) string {
if uid, ok := x.Get("user_id").(string); ok {
return uid
}
return ""
}

@ -14,18 +14,10 @@ var Router = vigo.NewRouter()
func init() { func init() {
Router.Get("/", "组织列表", auth.VBaseAuth.Perm("org:read"), list) Router.Get("/", "组织列表", auth.VBaseAuth.Perm("org:read"), list)
Router.Post("/", "创建组织", auth.VBaseAuth.Perm("org:create"), create) Router.Post("/", "创建组织", auth.VBaseAuth.Perm("org:create"), create)
Router.Get("/{org_id}", "获取组织详情", setOrgID, auth.VBaseAuth.Perm("org:read"), get) Router.Get("/{org_id}", "获取组织详情", auth.VBaseAuth.LoadOrg, auth.VBaseAuth.Perm("org:read"), get)
Router.Patch("/{org_id}", "更新组织", setOrgID, auth.VBaseAuth.Perm("org:update"), patch) Router.Patch("/{org_id}", "更新组织", auth.VBaseAuth.LoadOrg, auth.VBaseAuth.Perm("org:update"), patch)
Router.Delete("/{org_id}", "删除组织", setOrgID, auth.VBaseAuth.Perm("org:delete"), del) Router.Delete("/{org_id}", "删除组织", auth.VBaseAuth.LoadOrg, auth.VBaseAuth.Perm("org:delete"), del)
Router.Get("/tree", "组织树", auth.VBaseAuth.Perm("org:read"), tree) Router.Get("/tree", "组织树", auth.VBaseAuth.Perm("org:read"), tree)
Router.Get("/{org_id}/members", "组织成员列表", setOrgID, auth.VBaseAuth.Perm("org:read"), listMembers) Router.Get("/{org_id}/members", "组织成员列表", auth.VBaseAuth.LoadOrg, auth.VBaseAuth.Perm("org:read"), listMembers)
Router.Post("/{org_id}/members", "添加组织成员", setOrgID, auth.VBaseAuth.Perm("org:update"), addMember) Router.Post("/{org_id}/members", "添加组织成员", auth.VBaseAuth.LoadOrg, auth.VBaseAuth.Perm("org:update"), addMember)
}
func setOrgID(x *vigo.X) error {
orgID := x.PathParams.Get("org_id")
if orgID != "" {
x.Set("org_id", orgID)
}
return nil
} }

@ -7,6 +7,7 @@
package settings package settings
import ( import (
"github.com/veypi/vbase/auth"
"github.com/veypi/vigo" "github.com/veypi/vigo"
) )
@ -14,7 +15,8 @@ var Router = vigo.NewRouter()
func init() { func init() {
// 获取设置列表(支持按分类过滤) // 获取设置列表(支持按分类过滤)
Router.Get("/", "获取设置列表", list) // 只有拥有所有权限的可以修改配置表
Router.Get("/", "获取设置列表", auth.VBaseAuth.Perm("*:*"), list)
// 批量更新设置 // 批量更新设置
Router.Put("/", "批量更新设置", update) Router.Put("/", "批量更新设置", auth.VBaseAuth.Perm("*:*"), update)
} }

@ -7,7 +7,6 @@
package settings package settings
import ( import (
"github.com/veypi/vbase/auth"
"github.com/veypi/vbase/cfg" "github.com/veypi/vbase/cfg"
"github.com/veypi/vbase/models" "github.com/veypi/vbase/models"
"github.com/veypi/vigo" "github.com/veypi/vigo"
@ -38,20 +37,11 @@ func update(x *vigo.X, req *UpdateRequest) (*UpdateResponse, error) {
userID = u.(string) userID = u.(string)
} }
// 检查用户是否为管理员(检查 setting:update 权限)
isAdmin, err := auth.VBaseAuth.CheckPermission(x.Context(), userID, "", "setting:update", "")
if err != nil {
return nil, vigo.ErrInternalServer.WithError(err)
}
if !isAdmin {
return nil, vigo.ErrForbidden.WithString("only admin can update settings")
}
// 使用事务确保批量更新的原子性 // 使用事务确保批量更新的原子性
db := cfg.DB() db := cfg.DB()
updated := 0 updated := 0
err = db.Transaction(func(tx *gorm.DB) error { err := db.Transaction(func(tx *gorm.DB) error {
for _, item := range req.Settings { for _, item := range req.Settings {
var s models.Setting var s models.Setting
if err := tx.Where("`key` = ?", item.Key).First(&s).Error; err != nil { if err := tx.Where("`key` = ?", item.Key).First(&s).Error; err != nil {

@ -7,6 +7,7 @@
package user package user
import ( import (
"github.com/veypi/vbase/auth"
"github.com/veypi/vbase/cfg" "github.com/veypi/vbase/cfg"
"github.com/veypi/vbase/models" "github.com/veypi/vbase/models"
"github.com/veypi/vigo" "github.com/veypi/vigo"
@ -19,6 +20,14 @@ type GetRequest struct {
// get 获取用户详情 // get 获取用户详情
func get(x *vigo.X, req *GetRequest) (*models.User, error) { func get(x *vigo.X, req *GetRequest) (*models.User, error) {
// 手动鉴权: 只能查看自己的信息,或者是管理员
uid := auth.GetUserID(x)
if uid != req.UserID {
if !auth.VBaseAuth.CheckPerm(x.Context(), uid, auth.GetOrgID(x), "user:read", "") {
return nil, vigo.ErrForbidden
}
}
var user models.User var user models.User
if err := cfg.DB().First(&user, "id = ?", req.UserID).Error; err != nil { if err := cfg.DB().First(&user, "id = ?", req.UserID).Error; err != nil {
return nil, vigo.ErrNotFound return nil, vigo.ErrNotFound

@ -14,15 +14,16 @@ import (
var Router = vigo.NewRouter() var Router = vigo.NewRouter()
func init() { func init() {
// 管理员 管理用户权限
Router.Get("/", "用户列表", auth.VBaseAuth.Perm("user:read"), list) Router.Get("/", "用户列表", auth.VBaseAuth.Perm("user:read"), list)
Router.Post("/", "创建用户", auth.VBaseAuth.Perm("user:admin"), create) Router.Post("/", "创建用户", auth.VBaseAuth.Perm("user:admin"), create)
Router.Get("/{user_id}", "获取用户详情", auth.VBaseAuth.PermWithOwner("user:read", "user_id"), get) Router.Get("/{user_id}", "获取用户详情", auth.VBaseAuth.Perm("user:read"), get)
Router.Patch("/{user_id}", "更新用户", auth.VBaseAuth.PermWithOwner("user:update", "user_id"), patch) Router.Patch("/{user_id}", "更新用户", patch)
Router.Delete("/{user_id}", "删除用户", auth.VBaseAuth.Perm("user:admin"), del) Router.Delete("/{user_id}", "删除用户", auth.VBaseAuth.Perm("user:admin"), del)
Router.Patch("/{user_id}/status", "更新用户状态", auth.VBaseAuth.Perm("user:admin"), updateStatus) Router.Patch("/{user_id}/status", "更新用户状态", auth.VBaseAuth.Perm("user:admin"), updateStatus)
Router.Get("/{user_id}/roles", "Get User Roles", auth.VBaseAuth.PermWithOwner("user:read", "user_id"), getRoles) Router.Get("/{user_id}/roles", "Get User Roles", auth.VBaseAuth.Perm("user:read"), getRoles)
Router.Put("/{user_id}/roles", "Update User Roles", auth.VBaseAuth.Perm("user:admin"), updateRoles) Router.Put("/{user_id}/roles", "Update User Roles", auth.VBaseAuth.Perm("user:admin"), updateRoles)
Router.Get("/{user_id}/permissions", "Get User Permissions", auth.VBaseAuth.PermWithOwner("user:read", "user_id"), getPermissions) Router.Get("/{user_id}/permissions", "Get User Permissions", auth.VBaseAuth.Perm("user:read"), getPermissions)
Router.Put("/{user_id}/permissions", "Update User Permissions", auth.VBaseAuth.Perm("user:admin"), updatePermissions) Router.Put("/{user_id}/permissions", "Update User Permissions", auth.VBaseAuth.Perm("user:admin"), updatePermissions)
} }

@ -7,6 +7,7 @@
package user package user
import ( import (
"github.com/veypi/vbase/auth"
"github.com/veypi/vbase/cfg" "github.com/veypi/vbase/cfg"
"github.com/veypi/vbase/models" "github.com/veypi/vbase/models"
"github.com/veypi/vigo" "github.com/veypi/vigo"
@ -23,6 +24,14 @@ type PatchRequest struct {
// patch 更新用户 // patch 更新用户
func patch(x *vigo.X, req *PatchRequest) (*models.User, error) { func patch(x *vigo.X, req *PatchRequest) (*models.User, error) {
// 手动鉴权: 只能修改自己的信息,或者是管理员
uid := auth.GetUserID(x)
if uid != req.UserID {
if !auth.VBaseAuth.CheckPerm(x.Context(), uid, auth.GetOrgID(x), "user:update", "") {
return nil, vigo.ErrForbidden
}
}
var user models.User var user models.User
if err := cfg.DB().First(&user, "id = ?", req.UserID).Error; err != nil { if err := cfg.DB().First(&user, "id = ?", req.UserID).Error; err != nil {
return nil, vigo.ErrNotFound return nil, vigo.ErrNotFound

@ -8,6 +8,7 @@ package auth
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"regexp" "regexp"
"strings" "strings"
@ -18,17 +19,56 @@ import (
"github.com/veypi/vbase/models" "github.com/veypi/vbase/models"
"github.com/veypi/vigo" "github.com/veypi/vigo"
"github.com/veypi/vigo/contrib/event" "github.com/veypi/vigo/contrib/event"
"gorm.io/gorm"
) )
const (
// CtxKeyUserID 用户ID上下文键
CtxKeyUserID = "auth:user_id"
// CtxKeyOrgID 组织ID上下文键
CtxKeyOrgID = "auth:org_id"
// CtxKeyOrgRoles 组织角色上下文键
CtxKeyOrgRoles = "auth:org_roles"
// RoleCodeAdmin 管理员角色代码
RoleCodeAdmin = "admin"
// RoleCodeUser 普通用户角色代码
RoleCodeUser = "user"
)
// ========== 辅助函数 ==========
func GetUserID(x *vigo.X) string {
if userID, ok := x.Get(CtxKeyUserID).(string); ok {
return userID
}
return ""
}
func GetOrgID(x *vigo.X) string {
if orgID, ok := x.Get(CtxKeyOrgID).(string); ok {
return orgID
}
return ""
}
func GetOrgRoles(x *vigo.X) []string {
if roles, ok := x.Get(CtxKeyOrgRoles).([]string); ok {
return roles
}
return nil
}
// Auth 权限管理接口 // Auth 权限管理接口
type Auth interface { type Auth interface {
UserID(x *vigo.X) string
OrgID(x *vigo.X) string
// 加载组织信息 (中间件/手动调用)
LoadOrg(x *vigo.X) error
// ========== 中间件生成 ========== // ========== 中间件生成 ==========
// 基础权限检查 // 基础权限检查
Perm(permissionID string) func(*vigo.X) error Perm(permissionID string) func(*vigo.X) error
// 资源所有者权限
PermWithOwner(permissionID, ownerKey string) func(*vigo.X) error
// 特定资源权限检查 (自动从 Path/Query 获取资源ID) // 特定资源权限检查 (自动从 Path/Query 获取资源ID)
PermOnResource(permissionID, resourceKey string) func(*vigo.X) error PermOnResource(permissionID, resourceKey string) func(*vigo.X) error
@ -61,7 +101,8 @@ type Auth interface {
// ========== 权限查询 ========== // ========== 权限查询 ==========
// 检查权限 // 检查权限
CheckPermission(ctx context.Context, userID, orgID, permissionID, resourceID string) (bool, error) CheckPermission(ctx context.Context, userID, orgID, permissionID, resourceID string) bool
CheckPerm(ctx context.Context, userID, orgID, permissionID, resourceID string) bool
// 列出用户权限 // 列出用户权限
ListUserPermissions(ctx context.Context, userID, orgID string) ([]models.UserPermissionResult, error) ListUserPermissions(ctx context.Context, userID, orgID string) ([]models.UserPermissionResult, error)
@ -81,8 +122,9 @@ var VBaseAuth = Factory.New("vb")
func init() { func init() {
// 为 VBaseAuth 添加默认角色 // 为 VBaseAuth 添加默认角色
VBaseAuth.AddRole("admin", "管理员", "*:*")
VBaseAuth.AddRole("user", "普通用户", VBaseAuth.AddRole(RoleCodeAdmin, "管理员", "*:*")
VBaseAuth.AddRole(RoleCodeUser, "普通用户",
"user:read", "user:read",
"org:read", "org:read",
"org:create", "org:create",
@ -386,65 +428,82 @@ func (a *appAuth) initRole(roleCode string) error {
// ========== 中间件实现 ========== // ========== 中间件实现 ==========
func (a *appAuth) Perm(permissionID string) func(*vigo.X) error { func (a *appAuth) UserID(x *vigo.X) string {
validatePermissionID(permissionID) return GetUserID(x)
return func(x *vigo.X) error { }
userID := getUserID(x)
if userID == "" {
return vigo.ErrUnauthorized
}
orgID := getOrgID(x) func (a *appAuth) OrgID(x *vigo.X) string {
if err := a.checkPermission(x.Context(), userID, orgID, permissionID, ""); err != nil { return GetOrgID(x)
return err }
func (a *appAuth) LoadOrg(x *vigo.X) error {
orgID := x.Request.Header.Get("X-Org-ID")
if orgID == "" {
orgID = x.Request.URL.Query().Get("org_id")
}
if orgID == "" {
orgID = x.PathParams.Get("org_id")
}
if orgID == "" {
// 没有指定组织
return vigo.ErrInvalidArg.WithString("missing org_id")
}
userID := GetUserID(x)
if userID == "" {
return vigo.ErrUnauthorized
}
// 检查用户是否为组织成员
var member models.OrgMember
err := cfg.DB().Where("user_id = ? AND org_id = ? AND status = ?", userID, orgID, models.MemberStatusActive).First(&member).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return vigo.ErrForbidden.WithString("not a member of this organization")
} }
return nil return vigo.ErrInternalServer.WithError(err)
} }
x.Set(CtxKeyOrgID, orgID)
return nil
} }
func (a *appAuth) PermWithOwner(permissionID, ownerKey string) func(*vigo.X) error { func (a *appAuth) Perm(permissionID string) func(*vigo.X) error {
validatePermissionID(permissionID) validatePermissionID(permissionID)
return func(x *vigo.X) error { return func(x *vigo.X) error {
userID := getUserID(x) userID := GetUserID(x)
if userID == "" { if userID == "" {
return vigo.ErrUnauthorized return vigo.ErrUnauthorized
} }
orgID := getOrgID(x) orgID := GetOrgID(x)
// 获取资源所有者ID
// 优先从Path/Query获取因为Context中的可能是登录用户ID
ownerID := x.PathParams.Get(ownerKey)
if ownerID == "" {
ownerID = x.Request.URL.Query().Get(ownerKey)
}
if ownerID == "" {
ownerID, _ = x.Get(ownerKey).(string)
}
// 如果是所有者,直接放行
if ownerID == userID {
return nil
}
// 不是所有者,检查是否有权限
if err := a.checkPermission(x.Context(), userID, orgID, permissionID, ""); err != nil { if err := a.checkPermission(x.Context(), userID, orgID, permissionID, ""); err != nil {
return err return err
} }
return nil return nil
} }
} }
// PermOnResource 检查当前用户对特定资源实例是否有指定权限 (ACL)
//
// 鉴权逻辑:
// 1. 全局权限检查: 如果用户拥有全局权限 (如 "user:*", "*:*"),直接通过。
// 2. 实例权限检查: 检查 user_permissions 表中是否有 (permissionID, resourceID) 的记录。
//
// 最佳实践:
// - 配合 GrantResourcePerm 使用: 在创建资源时,必须显式赋予创建者权限。
// - 适用于高价值、需共享的资源 (如 User, Org, Project)。
// - 对于私有/高频资源 (如 Order, Log),建议使用 Manual Check (在业务逻辑中直接检查 OwnerID)。
func (a *appAuth) PermOnResource(permissionID, resourceKey string) func(*vigo.X) error { func (a *appAuth) PermOnResource(permissionID, resourceKey string) func(*vigo.X) error {
validatePermissionID(permissionID) validatePermissionID(permissionID)
return func(x *vigo.X) error { return func(x *vigo.X) error {
userID := getUserID(x) userID := GetUserID(x)
if userID == "" { if userID == "" {
return vigo.ErrUnauthorized return vigo.ErrUnauthorized
} }
orgID := getOrgID(x) orgID := GetOrgID(x)
// 尝试从 PathParams 获取 // 尝试从 PathParams 获取
resourceID := x.PathParams.Get(resourceKey) resourceID := x.PathParams.Get(resourceKey)
@ -462,7 +521,7 @@ func (a *appAuth) PermOnResource(permissionID, resourceKey string) func(*vigo.X)
// 内部辅助检查方法,返回 error 以便于统一处理错误响应 // 内部辅助检查方法,返回 error 以便于统一处理错误响应
func (a *appAuth) checkPermission(ctx context.Context, userID, orgID, permissionID, resourceID string) error { func (a *appAuth) checkPermission(ctx context.Context, userID, orgID, permissionID, resourceID string) error {
ok, err := a.CheckPermission(ctx, userID, orgID, permissionID, resourceID) ok, err := a.checkPermissionDB(ctx, userID, orgID, permissionID, resourceID)
if err != nil { if err != nil {
return vigo.ErrInternalServer.WithError(err) return vigo.ErrInternalServer.WithError(err)
} }
@ -477,28 +536,18 @@ func (a *appAuth) PermAny(permissionIDs ...string) func(*vigo.X) error {
validatePermissionID(pid) validatePermissionID(pid)
} }
return func(x *vigo.X) error { return func(x *vigo.X) error {
userID := getUserID(x) userID := GetUserID(x)
if userID == "" { if userID == "" {
return vigo.ErrUnauthorized return vigo.ErrUnauthorized
} }
orgID := GetOrgID(x)
orgID := getOrgID(x)
var lastErr error
for _, pid := range permissionIDs { for _, pid := range permissionIDs {
if err := a.checkPermission(x.Context(), userID, orgID, pid, ""); err == nil { if err := a.checkPermission(x.Context(), userID, orgID, pid, ""); err == nil {
return nil return nil
} else {
lastErr = err
} }
} }
return vigo.ErrNoPermission
if lastErr != nil {
// 如果是 Forbidden 错误,返回 Forbidden
// 否则返回最后一个错误
// 这里简单处理,如果所有都失败,返回 Forbidden
return vigo.ErrForbidden
}
return vigo.ErrForbidden
} }
} }
@ -507,12 +556,11 @@ func (a *appAuth) PermAll(permissionIDs ...string) func(*vigo.X) error {
validatePermissionID(pid) validatePermissionID(pid)
} }
return func(x *vigo.X) error { return func(x *vigo.X) error {
userID := getUserID(x) userID := GetUserID(x)
if userID == "" { if userID == "" {
return vigo.ErrUnauthorized return vigo.ErrUnauthorized
} }
orgID := GetOrgID(x)
orgID := getOrgID(x)
for _, pid := range permissionIDs { for _, pid := range permissionIDs {
if err := a.checkPermission(x.Context(), userID, orgID, pid, ""); err != nil { if err := a.checkPermission(x.Context(), userID, orgID, pid, ""); err != nil {
@ -708,36 +756,13 @@ func (a *appAuth) RevokeAll(ctx context.Context, userID, orgID string) error {
// ========== 权限查询实现 ========== // ========== 权限查询实现 ==========
func (a *appAuth) CheckPermission(ctx context.Context, userID, orgID, permissionID, resourceID string) (bool, error) { func (a *appAuth) CheckPermission(ctx context.Context, userID, orgID, permissionID, resourceID string) bool {
if strings.Count(permissionID, ":") == 1 { ok, _ := a.checkPermissionDB(ctx, userID, orgID, permissionID, resourceID)
permissionID = fmt.Sprintf("%s:%s", a.scope, permissionID) return ok
} }
// Check cache
var cacheKey string
if cache.IsEnabled() {
ver := getUserPermVersion(userID)
cacheKey = fmt.Sprintf("auth:check:%s:%s:%s:%s:%s", userID, ver, orgID, permissionID, resourceID)
if val, err := cache.Get(cacheKey); err == nil {
return val == "1", nil
}
}
result, err := a.checkPermissionDB(ctx, userID, orgID, permissionID, resourceID)
if err != nil {
return false, err
}
// Cache result
if cache.IsEnabled() {
val := "0"
if result {
val = "1"
}
cache.Set(cacheKey, val, 5*time.Minute)
}
return result, nil func (a *appAuth) CheckPerm(ctx context.Context, userID, orgID, permissionID, resourceID string) bool {
return a.CheckPermission(ctx, userID, orgID, permissionID, resourceID)
} }
func (a *appAuth) checkPermissionDB(ctx context.Context, userID, orgID, permissionID, resourceID string) (bool, error) { func (a *appAuth) checkPermissionDB(ctx context.Context, userID, orgID, permissionID, resourceID string) (bool, error) {
@ -813,6 +838,8 @@ func (a *appAuth) checkPermissionDB(ctx context.Context, userID, orgID, permissi
if resourceID != "" { if resourceID != "" {
query = query.Where("resource_id = ? OR resource_id = '*'", resourceID) query = query.Where("resource_id = ? OR resource_id = '*'", resourceID)
} else {
query = query.Where("resource_id = '*'")
} }
if err := query.Count(&userPermCount).Error; err != nil { if err := query.Count(&userPermCount).Error; err != nil {
@ -905,7 +932,7 @@ func (a *appAuth) isAdmin(ctx context.Context, userID, orgID string) (bool, erro
// 检查用户是否有管理员角色 // 检查用户是否有管理员角色
var adminRoleIDs []string var adminRoleIDs []string
if err := cfg.DB().Model(&models.Role{}). if err := cfg.DB().Model(&models.Role{}).
Where("code = 'admin'"). Where("code = ?", RoleCodeAdmin).
Pluck("id", &adminRoleIDs).Error; err != nil { Pluck("id", &adminRoleIDs).Error; err != nil {
return false, err return false, err
} }
@ -924,22 +951,6 @@ func (a *appAuth) isAdmin(ctx context.Context, userID, orgID string) (bool, erro
return count > 0, nil return count > 0, nil
} }
// 从 context 获取用户ID
func getUserID(x *vigo.X) string {
if userID, ok := x.Get("user_id").(string); ok {
return userID
}
return ""
}
// 从 context 获取组织ID
func getOrgID(x *vigo.X) string {
if orgID, ok := x.Get("org_id").(string); ok {
return orgID
}
return ""
}
// ========== Cache Helpers ========== // ========== Cache Helpers ==========
func getUserPermVersion(userID string) string { func getUserPermVersion(userID string) string {

@ -5,28 +5,16 @@
package auth package auth
import ( import (
"encoding/json"
"fmt"
"strings" "strings"
"time"
"github.com/veypi/vbase/cfg"
"github.com/veypi/vbase/libs/cache" "github.com/veypi/vbase/libs/cache"
"github.com/veypi/vbase/libs/jwt" "github.com/veypi/vbase/libs/jwt"
"github.com/veypi/vbase/models"
"github.com/veypi/vigo" "github.com/veypi/vigo"
) )
// orgMemberCache 组织成员身份缓存结构
type orgMemberCache struct {
IsMember bool `json:"is_member"`
RoleCodes []string `json:"role_codes"`
}
// AuthMiddleware 统一认证中间件 // AuthMiddleware 统一认证中间件
// 1. JWT认证: 解析token验证有效性设置用户信息 // 仅处理 JWT 认证,设置 CtxKeyUserID
// 2. 组织上下文: 如果请求包含org_id验证用户成员身份设置组织信息 // 组织信息的加载需按需调用 Auth.LoadOrg(x)
// 使用Redis缓存组织成员身份和角色信息减少数据库查询
func AuthMiddleware() func(*vigo.X) error { func AuthMiddleware() func(*vigo.X) error {
return func(x *vigo.X) error { return func(x *vigo.X) error {
// === 1. JWT 认证部分 === // === 1. JWT 认证部分 ===
@ -53,74 +41,7 @@ func AuthMiddleware() func(*vigo.X) error {
} }
// 将用户信息存入上下文 // 将用户信息存入上下文
x.Set("user_id", claims.UserID) x.Set(CtxKeyUserID, claims.UserID)
x.Set("user_name", claims.Username)
x.Set("user_orgs", claims.Orgs)
x.Set("token_claims", claims)
// === 2. 组织上下文部分 ===
orgID := x.Request.Header.Get("X-Org-ID")
if orgID == "" {
orgID = x.Request.URL.Query().Get("org_id")
}
if orgID == "" {
// 没有指定组织,仅完成用户认证
return nil
}
// 尝试从缓存获取组织成员信息
var roleCodes []string
var isMember bool
if cache.IsEnabled() {
cacheKey := fmt.Sprintf("auth:org_member:%s:%s", claims.UserID, orgID)
cachedData, err := cache.Get(cacheKey)
if err == nil && cachedData != "" {
var cached orgMemberCache
if err := json.Unmarshal([]byte(cachedData), &cached); err == nil {
isMember = cached.IsMember
roleCodes = cached.RoleCodes
}
}
}
// 缓存未命中,查询数据库
if roleCodes == nil {
// 验证用户是否为组织成员
var member models.OrgMember
err := cfg.DB().Where("org_id = ? AND user_id = ? AND status = ?",
orgID, claims.UserID, models.MemberStatusActive).First(&member).Error
isMember = err == nil
if isMember {
// 查询用户的角色
cfg.DB().Model(&models.UserRole{}).
Joins("JOIN roles ON user_roles.role_id = roles.id").
Where("user_roles.user_id = ? AND user_roles.org_id = ?", claims.UserID, orgID).
Pluck("roles.code", &roleCodes)
}
// 写入缓存
if cache.IsEnabled() {
cacheData := orgMemberCache{
IsMember: isMember,
RoleCodes: roleCodes,
}
if data, err := json.Marshal(cacheData); err == nil {
cacheKey := fmt.Sprintf("auth:org_member:%s:%s", claims.UserID, orgID)
// 缓存5分钟
cache.Set(cacheKey, string(data), 5*time.Minute)
}
}
}
if !isMember {
return vigo.ErrForbidden.WithString("you are not a member of this organization")
}
x.Set("org_id", orgID)
x.Set("org_roles", roleCodes)
return nil return nil
} }

@ -0,0 +1,156 @@
# 鉴权与授权模块设计 (Authentication & Authorization)
`auth` 模块为 VBase 应用提供了灵活且强大的权限控制系统。它摒弃了隐式的“所有权猜测”,转而采用**显式授权**ACL和**手动鉴权**相结合的策略,以确保系统的安全性和可维护性。
## 1. 核心鉴权机制概览
我们提供三种层级的鉴权方式,分别适用于不同的业务场景:
| 方式 | 描述 | 适用场景 | 示例 |
| :--- | :--- | :--- | :--- |
| **全局中间件** (`Perm`) | 检查用户是否拥有某种**全局能力**(基于角色)。 | 管理后台、列表查询、无需特定资源ID的操作。 | `user:read` (查看所有用户) |
| **简单手动鉴权** (Manual) | 在业务代码中直接检查资源字段(如 `OwnerID`)。 | **高频/私有资源**(如订单、日志),逻辑简单且追求高性能。 | 修改自己的订单 |
| **复杂资源鉴权** (`PermOnResource`) | 基于数据库 ACL 表检查用户对**特定实例**的权限。 | **高价值/共享资源**(如项目、组织),需要精细控制和跨用户授权。 | 修改项目成员 |
---
## 2. 基础:上下文与中间件
### 2.1 认证中间件 (AuthMiddleware)
认证中间件仅负责验证 JWT Token 的有效性,并将核心的用户 ID 注入到请求上下文中。为了保持高性能,它**不会**预加载用户详情、角色或组织信息。
### 2.2 上下文访问
请使用 `auth` 包提供的辅助函数来访问上下文中的认证信息:
```go
func MyHandler(x *vigo.X) {
// 1. 获取当前用户 ID (核心,所有认证请求都可用)
userID := auth.GetUserID(x)
if userID == "" {
// 未登录
return vigo.ErrUnauthorized
}
// 2. 获取当前组织 ID
// 注意:仅当路由使用了 LoadOrg 中间件,或手动调用了 LoadOrg 后才可用
orgID := auth.GetOrgID(x)
// 3. 获取在当前组织中的角色列表
// 注意:仅当路由使用了 LoadOrg 中间件后可用
roles := auth.GetOrgRoles(x)
}
```
### 2.3 组织上下文加载 (LoadOrg)
对于需要组织上下文的接口(如 `/api/orgs/{org_id}/...`),使用 `LoadOrg` 中间件按需加载组织信息。
- **行为**: 自动从 Path (`{org_id}`), Query (`?org_id=`), Header (`X-Org-ID`) 中解析组织 ID。
- **验证**: 检查当前用户是否为该组织的有效成员。
- **注入**: 将 OrgID 和用户在该组织的角色注入上下文。
```go
// 路由注册示例
// 对于需要组织上下文的接口,显式添加 LoadOrg 中间件
Router.Get("/{org_id}", "获取组织详情", auth.VBaseAuth.LoadOrg, auth.VBaseAuth.Perm("org:read"), get)
```
---
## 3. 场景一:全局/类别权限 (Perm 中间件)
用于控制“谁能进入这个门”。不关心具体操作哪个数据,只关心你有没有这个资格。
- **原理**: 检查用户的角色Role是否包含指定权限。
- **配置**: 通常在路由定义时使用。
```go
// 只有管理员 (拥有 user:delete 权限) 可以删除用户
Router.Delete("/{user_id}", auth.VBaseAuth.Perm("user:delete"), DeleteUser)
// 只有拥有 user:read 权限的人可以查看列表
Router.Get("/", auth.VBaseAuth.Perm("user:read"), ListUsers)
```
---
## 4. 场景二:简单手动鉴权 (Manual Check)
对于大多数业务场景(如电商订单、个人博客文章),资源的所有权非常明确且单一。此时,直接在业务代码中判断 `UserID` 字段是最简单、最高效的方式。
**推荐范式:**
```go
// 更新文章 (PATCH /articles/{id})
func UpdateArticle(x *vigo.X) {
// 1. 获取 ID
articleID := x.PathParams.Get("id")
currentUserID := auth.GetUserID(x)
// 2. 查库获取资源
article := db.GetArticle(articleID)
// 3. 【核心鉴权逻辑】
// 允许条件:(我是作者) OR (我是管理员/有特权)
if article.OwnerID != currentUserID {
// 如果不是作者,再检查是否有全局权限进行兜底
if !auth.VBaseAuth.CheckPerm(x.Context(), currentUserID, "", "article:update", "") {
return vigo.ErrForbidden
}
}
// 4. 执行更新
db.Save(article)
}
```
**优点**:
- **零开销**: 不需要额外的 ACL 表查询。
- **逻辑清晰**: 鉴权逻辑与业务逻辑紧密结合,不易出错。
---
## 5. 场景三:复杂资源鉴权 (PermOnResource 中间件)
对于像“项目协作”、“共享文档”这样需要多人协作、权限动态分配的资源,简单的字段判断就不够用了。这时我们需要引入 ACL (Access Control List)。
### 5.1 使用方法
**路由配置**:
告诉中间件去检查 `user_permissions` 表,看当前用户对这个 `project_id` 是否有 `project:update` 权限。
```go
// 这里的 "project_id" 是 URL 路径参数的 key
Router.Patch("/{project_id}", auth.VBaseAuth.PermOnResource("project:update", "project_id"), UpdateProject)
```
### 5.2 授权 (Grant)
在创建资源时,必须**显式**授予创建者权限。
```go
func CreateProject(x *vigo.X) {
// ... 创建项目逻辑 ...
project := db.CreateProject(...)
// 【关键】赋予创建者对该资源的全部权限
// 权限ID "project:*" 表示对该项目的所有操作权限
auth.VBaseAuth.GrantResourcePerm(x.Context(), currentUserID, orgID, "project:*", project.ID)
}
```
---
## 6. 设计决策记录 (ADR)
### 6.1 废弃隐式所有权推断 (PermWithOwner)
我们移除了曾尝试自动推断资源所有权的 `PermWithOwner` 中间件。因为在中间件层无法准确知道资源的 `OwnerID` 字段名,也无法处理复杂的“多所有者”场景,这导致了安全漏洞和难以调试的错误。现在我们推荐使用 **Manual Check** 模式。
### 6.2 上下文精简
为了减少内存占用和数据库压力Request Context (`vigo.X`) 中现在**仅**包含最核心的 `UserID`。其他的非核心信息(如组织信息、详细的用户资料)都改为**按需加载**On-Demand或通过特定中间件`LoadOrg`)加载。
### 6.3 组织信息解耦
组织信息的获取逻辑已从核心 `AuthMiddleware` 中剥离,移至 `LoadOrg` 中间件。这意味着不涉及组织业务的接口(如个人资料、系统设置)不再承担加载组织信息的额外开销。

@ -79,7 +79,7 @@ type UserRole struct {
// 外键关联 // 外键关联
User User `json:"user,omitempty" gorm:"foreignKey:UserID;references:ID"` User User `json:"user,omitempty" gorm:"foreignKey:UserID;references:ID"`
Org *Org `json:"org,omitempty" gorm:"foreignKey:OrgID;references:ID"` Org *Org `json:"org,omitempty" gorm:"foreignKey:OrgID;references:ID"`
Role Role `json:"role,omitempty" gorm:"foreignKey:RoleID;references:ID"` Role Role `json:"role,omitempty" gorm:"foreignKey:RoleID;references:ID"`
} }
@ -99,7 +99,7 @@ type UserPermission struct {
// 外键关联 // 外键关联
User User `json:"user,omitempty" gorm:"foreignKey:UserID;references:ID"` User User `json:"user,omitempty" gorm:"foreignKey:UserID;references:ID"`
Org *Org `json:"org,omitempty" gorm:"foreignKey:OrgID;references:ID"` Org *Org `json:"org,omitempty" gorm:"foreignKey:OrgID;references:ID"`
Permission Permission `json:"permission,omitempty" gorm:"foreignKey:PermissionID;references:ID"` Permission Permission `json:"permission,omitempty" gorm:"foreignKey:PermissionID;references:ID"`
} }

@ -0,0 +1,109 @@
#!/bin/bash
#
# 03_org_ops.sh
#
# 功能:测试组织相关操作,验证 LoadOrg 中间件及权限
#
set -e
# 加载公共库
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/lib.sh"
test_start "组织操作与 LoadOrg 测试"
# 检查服务
check_service
# ==========================================
# 准备环境
# ==========================================
COMMON_PASS="password123"
# 使用一个新的后缀以避免冲突
TEST_SUFFIX="$(date +%s)_org"
USER1_NAME="u1_${TEST_SUFFIX}"
USER2_NAME="u2_${TEST_SUFFIX}"
# 注册用户
step "1. 注册测试用户"
RES=$(register_user "$USER1_NAME" "$COMMON_PASS" "${USER1_NAME}@test.com")
check_http_code "$RES" "200"
USER1_TOKEN=$(get_token "$RES")
USER1_ID=$(get_user_id "$RES")
RES=$(register_user "$USER2_NAME" "$COMMON_PASS" "${USER2_NAME}@test.com")
check_http_code "$RES" "200"
USER2_TOKEN=$(get_token "$RES")
USER2_ID=$(get_user_id "$RES")
# ==========================================
# 测试用例
# ==========================================
# 1. 创建组织
step "2. User1 创建组织"
ORG_CODE="org_${TEST_SUFFIX}"
RES=$(api_post "/api/orgs" "{\"name\": \"Test Org\", \"code\": \"$ORG_CODE\", \"description\": \"Test Desc\"}" "$USER1_TOKEN")
check_http_code "$RES" "200"
ORG_ID=$(echo "$RES" | jq -r '.id')
info "Org ID: $ORG_ID"
if [ -z "$ORG_ID" ] || [ "$ORG_ID" == "null" ]; then
error "创建组织失败"
exit 1
fi
# 2. 获取组织详情 (测试 LoadOrg + Perm)
step "3. User1 获取组织详情 (预期: 成功)"
RES=$(api_get "/api/orgs/$ORG_ID" "$USER1_TOKEN")
check_http_code "$RES" "200"
NAME=$(echo "$RES" | jq -r '.name')
if [ "$NAME" == "Test Org" ]; then
check_success "获取组织详情成功"
else
error "获取组织详情失败, name=$NAME"
fi
# 3. 更新组织 (测试 LoadOrg + Perm update)
step "4. User1 更新组织 (预期: 成功)"
RES=$(api_patch "/api/orgs/$ORG_ID" "{\"name\": \"Updated Org\"}" "$USER1_TOKEN")
check_http_code "$RES" "200"
NAME=$(echo "$RES" | jq -r '.name')
if [ "$NAME" == "Updated Org" ]; then
check_success "更新组织成功"
else
error "更新组织失败, name=$NAME"
fi
# 4. User2 获取组织详情 (预期: 失败/403 - 不是成员)
# LoadOrg checks membership. User2 is not a member.
step "5. User2 获取组织详情 (预期: 失败 403 Forbidden)"
RES=$(api_get "/api/orgs/$ORG_ID" "$USER2_TOKEN")
code=$(echo "$RES" | jq -r '.code // 200')
if [[ "$code" == "403"* ]]; then
check_success "User2 访问被拒绝 (Code: $code)"
else
error "User2 竟然访问成功了! Code: $code"
info "Response: $RES"
fi
# 5. User1 添加 User2 为成员
step "6. User1 添加 User2 为成员"
RES=$(api_post "/api/orgs/$ORG_ID/members" "{\"user_id\": \"$USER2_ID\", \"role_codes\": [\"member\"]}" "$USER1_TOKEN")
check_http_code "$RES" "200"
check_success "添加成员成功"
# 6. User2 获取组织详情 (预期: 成功 - 现已是成员)
step "7. User2 (成员) 获取组织详情 (预期: 成功)"
RES=$(api_get "/api/orgs/$ORG_ID" "$USER2_TOKEN")
check_http_code "$RES" "200"
NAME=$(echo "$RES" | jq -r '.name')
if [ "$NAME" == "Updated Org" ]; then
check_success "User2 获取组织详情成功"
else
error "User2 获取组织详情失败"
fi
test_end

@ -2,32 +2,62 @@
本目录包含 VBase 的集成测试脚本,使用 bash + curl 测试 API 功能。 本目录包含 VBase 的集成测试脚本,使用 bash + curl 测试 API 功能。
## ⚠️ 重要注意事项 (必读)
1. **环境重置与管理员权限**
- 系统设计为:**数据库重置后第一个注册的用户自动获得 Admin (超级管理员) 权限**。
- 后续注册的用户默认为普通 User 权限。
- 很多权限测试(如 `02_resource_perm.sh`)依赖于这个机制。如果数据库未清理,新注册的用户可能只是普通用户,导致 Admin 权限测试失败。
- **强烈建议使用 `clean_run.sh` 脚本运行测试**。该脚本会自动清理数据库 (`/tmp/vb.sqlite`) 并重启服务,确保环境一致性。
2. **BASE_URL 配置**
- 测试脚本依赖 `BASE_URL` 环境变量连接服务。
- 如果你的系统环境变量中设置了其他的 `BASE_URL`(例如生产环境地址),直接运行脚本可能会导致连接错误或误操作。
- `clean_run.sh` 会强制设置 `BASE_URL=http://localhost:4000`,确保测试在本地隔离环境中运行。
## 目录结构 ## 目录结构
``` ```text
scripts/tests/ scripts/tests/
├── README.md # 本说明文件 ├── README.md # 本说明文件
├── lib.sh # 公共函数库 ├── lib.sh # 公共函数库
├── 00_none_auth.sh # 未登录访问测试 ├── clean_run.sh # [推荐] 一键清理环境、重启服务并运行所有测试
├── 01_setup_users.sh # 用户初始化与基础认证 ├── run_all.sh # 运行所有测试脚本 (不负责清理环境)
├── 02_resource_perm.sh # 资源权限交叉测试 ├── 00_none_auth.sh # 1. 未登录访问测试
├── 03_org_permission.sh # 组织权限测试 ├── 01_setup_users.sh # 2. 用户初始化与基础认证
└── run_all.sh # 运行所有测试 ├── 02_resource_perm.sh # 3. 资源权限交叉测试
├── 03_org_permission.sh # 4. 组织权限测试
└── 04_org_load_middleware.sh # 5. LoadOrg 中间件测试
```
## 推荐运行方式
### 方式一:一键清理并运行 (强烈推荐)
最稳健的测试方式,自动处理数据库清理、服务重启和环境变量配置:
```bash
# 在项目根目录下或 scripts/tests 目录下运行
bash scripts/tests/clean_run.sh
``` ```
## 前置条件 ### 方式二:手动运行
1. 服务必须已启动: 如果你需要手动运行单个脚本进行调试,请确保:
1. 服务已启动 (`go run cli/main.go ...`)。
2. `BASE_URL` 指向正确的本地服务。
3. 如果测试依赖 Admin 权限,请确保你使用的 Token 属于 Admin 用户。
```bash
# 1. 设置环境变量
export BASE_URL=http://localhost:4000
export TEST_TIMESTAMP=$(date +%s) # 生成统一时间戳,避免数据冲突
```bash # 2. 运行特定测试
rm /tmp/vb.sqlite && go run cli/main.go -db.type=sqlite -db.dsn /tmp/vb.sqlite -p 4000 bash scripts/tests/02_resource_perm.sh
``` ```
2. 手动查询后端接口列表
```bash
curl -sSf http://localhost:4000/_api.json
```
## 测试脚本说明 ## 测试脚本详情
### 00_none_auth.sh ### 00_none_auth.sh
@ -40,20 +70,16 @@ scripts/tests/
**测试内容**:用户初始化与基础功能验证 **测试内容**:用户初始化与基础功能验证
- 注册三个核心账户Admin, User1, User2 - 注册三个核心账户Admin, User1, User2
- 验证注册与登录流程 - 验证注册与登录流程
- 使用临时账户验证: - 验证基础功能修改个人信息、修改密码、Token 刷新、用户登出
- 修改个人信息
- 修改密码(验证旧密码失效、新密码生效)
- Token 刷新
- 用户登出
### 02_resource_perm.sh ### 02_resource_perm.sh
**测试内容**:跨用户资源访问权限验证 **测试内容**:跨用户资源访问权限验证
- 场景 1: Admin 修改任意用户信息 (允许) - 场景 1: Admin 修改任意用户信息 (允许)
- 场景 2: User1 修改自己信息 (允许) - 场景 2: User1 修改自己信息 (允许)
- 场景 3: User1 修改 User2 信息 (禁止) - 场景 3: User1 修改 User2 信息 (禁止 403)
- 场景 4: User1 修改 Admin 信息 (禁止) - 场景 4: User1 修改 Admin 信息 (禁止 403)
- 场景 5: User2 修改 User1 信息 (禁止) - 场景 5: User2 修改 User1 信息 (禁止 403)
### 03_org_permission.sh ### 03_org_permission.sh
@ -64,44 +90,75 @@ scripts/tests/
- 普通成员不能修改组织信息 - 普通成员不能修改组织信息
- 只有 admin/owner 可以修改组织 - 只有 admin/owner 可以修改组织
### run_all.sh ### 04_org_load_middleware.sh
**测试内容**LoadOrg 中间件验证
- 验证 `X-Org-ID` 头部的处理
- 验证用户是否为组织成员的检查逻辑
- 验证非成员访问组织资源被拒绝 (403)
- 验证成员访问组织资源被允许 (200)
## 测试环境变量参考
| 变量 | 默认值 | 说明 |
| :--- | :--- | :--- |
| `BASE_URL` | `http://localhost:4000` | 测试服务的 API 地址 |
| `TEST_TIMESTAMP` | `date +%s` | 测试运行的时间戳,用于生成唯一的用户名/邮箱 |
**功能**:运行所有测试 ## 常见问题与踩坑记录 (Troubleshooting)
- 按顺序执行所有测试脚本
- 遇到错误时停止
- 输出测试摘要
- 统一时间戳 `TEST_TIMESTAMP`,确保跨脚本的用户数据一致性
## 使用方法 ### 1. 权限测试失败Admin 用户无法修改他人信息
### 运行所有测试 (推荐) **现象**
运行 `02_resource_perm.sh` 时,提示 `Admin 修改 User1 失败` 或返回 403。
**原因**
VBase 的逻辑是**系统初始化的第一个用户自动获得 Admin 权限**。如果你在测试前没有清理数据库,数据库中可能已经存在其他用户,导致你测试脚本中注册的 "Admin" 用户实际上只获得了普通 User 权限。
**解决**
使用 `clean_run.sh` 运行测试,或者手动删除数据库文件 `/tmp/vb.sqlite` 后重启服务。
### 2. 连接被拒绝 (Connection refused) 或 404
**现象**
测试脚本提示无法连接到服务器,或者返回非预期的 404。
**原因**
- 服务未启动。
- `BASE_URL` 环境变量配置错误(例如系统环境中设置了生产环境地址)。
**解决**
- 确保服务正在运行且监听端口与 `BASE_URL` 一致(默认 4000
- 强制指定 `BASE_URL``export BASE_URL=http://localhost:4000`。
### 3. 端口被占用 (Address already in use)
**现象**
运行 `go run cli/main.go ...` 时提示端口 4000 被占用。
**原因**
之前的测试服务没有正常关闭,或者有其他服务占用了该端口。
**解决**
查找并关闭占用端口的进程:
```bash ```bash
cd scripts/tests lsof -i :4000
bash run_all.sh kill -9 <PID>
``` ```
`clean_run.sh` 脚本已内置了此清理逻辑。
### 4. 数据不一致 / 用户不存在
### 运行单个测试 **现象**
手动运行多个脚本时,提示用户不存在或登录失败。
**原因**
测试脚本使用 `TEST_TIMESTAMP` 生成唯一的用户名(如 `user1_1712345678`)。如果你分别运行两个脚本且没有固定 `TEST_TIMESTAMP`,第二个脚本会生成新的时间戳,导致无法找到第一个脚本创建的用户。
**解决**
在手动运行一系列脚本前,先导出时间戳:
```bash ```bash
# 必须先设置时间戳以避免冲突 (可选)
export TEST_TIMESTAMP=$(date +%s) export TEST_TIMESTAMP=$(date +%s)
# 运行特定测试
bash 00_none_auth.sh
bash 01_setup_users.sh bash 01_setup_users.sh
bash 02_resource_perm.sh bash 02_resource_perm.sh
``` ```
## 测试环境变量
| 变量 | 默认值 | 说明 |
| ---------------- | ----------------------- | ------------------------------ |
| `BASE_URL` | `http://localhost:4000` | API 基础地址 |
| `TEST_TIMESTAMP` | 自动生成 | 测试时间戳,用于生成唯一用户名 |
## 注意事项
1. 测试脚本会创建真实数据,建议在测试数据库上运行
2. 测试失败时会立即退出(`set -e`
3. `run_all.sh` 会自动导出 `TEST_TIMESTAMP`,手动运行单个脚本时建议手动设置,否则每次运行都会生成新用户。

@ -0,0 +1,58 @@
#!/bin/bash
#
# clean_run.sh
#
# Clean environment and run all tests
#
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR/../.."
echo "Stopping existing server on port 4000..."
PID=$(lsof -t -i:4000 || true)
if [ -n "$PID" ]; then
kill $PID || true
wait $PID 2>/dev/null || true
echo "Server stopped."
else
echo "No server running on port 4000."
fi
echo "Cleaning database..."
rm -f /tmp/vb.sqlite
echo "Starting server..."
# Run in background
go run cli/main.go -db.type=sqlite -db.dsn /tmp/vb.sqlite -p 4000 > /tmp/vb_server.log 2>&1 &
SERVER_PID=$!
echo "Server PID: $SERVER_PID"
echo "Waiting for server to start..."
# Wait for port 4000 to be open
max_retries=30
count=0
while ! nc -z localhost 4000; do
sleep 1
((count++))
if [ $count -ge $max_retries ]; then
echo "Server failed to start in $max_retries seconds."
cat /tmp/vb_server.log
kill $SERVER_PID || true
exit 1
fi
done
echo "Server started successfully."
# Run tests
echo "Running tests..."
BASE_URL=http://localhost:4000 bash scripts/tests/run_all.sh
EXIT_CODE=$?
echo "Stopping server..."
kill $SERVER_PID || true
exit $EXIT_CODE

@ -283,6 +283,7 @@ test_start() {
echo "" echo ""
echo "========================================" echo "========================================"
echo "测试: $name" echo "测试: $name"
echo "BASE_URL: $BASE_URL"
echo "========================================" echo "========================================"
} }

@ -31,6 +31,7 @@ TESTS=(
"01_setup_users.sh:用户初始化与基础认证测试" "01_setup_users.sh:用户初始化与基础认证测试"
"02_resource_perm.sh:资源权限交叉验证测试" "02_resource_perm.sh:资源权限交叉验证测试"
"03_org_permission.sh:组织权限测试" "03_org_permission.sh:组织权限测试"
"04_org_load_middleware.sh:LoadOrg 中间件测试"
) )
PASSED=0 PASSED=0

Loading…
Cancel
Save