diff --git a/api/auth/me.go b/api/auth/me.go index 9c90cfa..c316f25 100644 --- a/api/auth/me.go +++ b/api/auth/me.go @@ -7,6 +7,7 @@ package auth import ( + baseAuth "github.com/veypi/vbase/auth" "github.com/veypi/vbase/cfg" "github.com/veypi/vbase/libs/crypto" "github.com/veypi/vbase/models" @@ -15,7 +16,7 @@ import ( // me 获取当前用户信息 func me(x *vigo.X) (*UserInfo, error) { - userID := getCurrentUserID(x) + userID := baseAuth.GetUserID(x) if userID == "" { return nil, vigo.ErrUnauthorized } @@ -43,7 +44,7 @@ type UpdateMeRequest struct { // updateMe 更新当前用户信息 func updateMe(x *vigo.X, req *UpdateMeRequest) (*UserInfo, error) { - userID := getCurrentUserID(x) + userID := baseAuth.GetUserID(x) if userID == "" { return nil, vigo.ErrUnauthorized } @@ -80,7 +81,7 @@ type ChangePasswordRequest struct { // changePassword 修改密码 func changePassword(x *vigo.X, req *ChangePasswordRequest) error { - userID := getCurrentUserID(x) + userID := baseAuth.GetUserID(x) if userID == "" { return vigo.ErrUnauthorized } @@ -108,10 +109,3 @@ func changePassword(x *vigo.X, req *ChangePasswordRequest) error { return nil } - -func getCurrentUserID(x *vigo.X) string { - if uid, ok := x.Get("user_id").(string); ok { - return uid - } - return "" -} diff --git a/api/auth/thirdparty.go b/api/auth/thirdparty.go index ebf02f2..0c36223 100644 --- a/api/auth/thirdparty.go +++ b/api/auth/thirdparty.go @@ -83,7 +83,7 @@ func authorizeThirdParty(x *vigo.X, req *AuthorizeRequest) (*AuthorizeResponse, // 如果是绑定模式,需要当前用户登录 if req.BindMode { - userID := getCurrentUserID(x) + userID := baseauth.GetUserID(x) if userID == "" { return nil, vigo.ErrUnauthorized.WithString("login required for bind mode") } @@ -318,7 +318,7 @@ type UnbindRequest struct { // unbindThirdParty 解除第三方账号绑定 func unbindThirdParty(x *vigo.X, req *UnbindRequest) error { - userID := getCurrentUserID(x) + userID := baseauth.GetUserID(x) if userID == "" { return vigo.ErrUnauthorized } @@ -342,7 +342,7 @@ type BindingInfo struct { // listBindings 获取当前用户的第三方绑定列表 func listBindings(x *vigo.X) ([]BindingInfo, error) { - userID := getCurrentUserID(x) + userID := baseauth.GetUserID(x) if userID == "" { return nil, vigo.ErrUnauthorized } diff --git a/api/oauth/authorize.go b/api/oauth/authorize.go index f1bc52b..63c166f 100644 --- a/api/oauth/authorize.go +++ b/api/oauth/authorize.go @@ -7,6 +7,7 @@ package oauth import ( "time" + "github.com/veypi/vbase/auth" "github.com/veypi/vbase/cfg" "github.com/veypi/vbase/libs/cache" "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 == "" { return nil, vigo.ErrUnauthorized } @@ -54,7 +55,7 @@ func authorize(x *vigo.X, req *AuthorizeRequest) (*AuthorizeResponse, error) { "redirect_uri": req.RedirectURI, "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) } @@ -63,10 +64,3 @@ func authorize(x *vigo.X, req *AuthorizeRequest) (*AuthorizeResponse, error) { State: req.State, }, nil } - -func getCurrentUserID(x *vigo.X) string { - if uid, ok := x.Get("user_id").(string); ok { - return uid - } - return "" -} diff --git a/api/oauth/client.go b/api/oauth/client.go index 399f6b4..053ef52 100644 --- a/api/oauth/client.go +++ b/api/oauth/client.go @@ -5,6 +5,7 @@ package oauth import ( + "github.com/veypi/vbase/auth" "github.com/veypi/vbase/cfg" "github.com/veypi/vbase/libs/crypto" "github.com/veypi/vbase/models" @@ -12,8 +13,8 @@ import ( ) type ListClientsRequest struct { - Page int `json:"page" src:"query" default:"1"` - PageSize int `json:"page_size" src:"query" default:"20"` + Page int `json:"page" src:"query" default:"1"` + PageSize int `json:"page_size" src:"query" default:"20"` } type ListClientsResponse struct { @@ -53,10 +54,10 @@ func listClients(x *vigo.X, req *ListClientsRequest) (*ListClientsResponse, erro } type CreateClientRequest struct { - Name string `json:"name" src:"json" desc:"客户端名称"` - Description string `json:"description" src:"json" desc:"描述"` - RedirectURIs []string `json:"redirect_uris" src:"json" desc:"允许的重定向URI"` - AllowedScopes string `json:"allowed_scopes" src:"json" desc:"允许的授权范围"` + Name string `json:"name" src:"json" desc:"客户端名称"` + Description string `json:"description" src:"json" desc:"描述"` + RedirectURIs []string `json:"redirect_uris" src:"json" desc:"允许的重定向URI"` + AllowedScopes string `json:"allowed_scopes" src:"json" desc:"允许的授权范围"` } type CreateClientResponse struct { @@ -65,7 +66,7 @@ type CreateClientResponse struct { } func createClient(x *vigo.X, req *CreateClientRequest) (*CreateClientResponse, error) { - ownerID := getCurrentUserID(x) + ownerID := auth.GetUserID(x) if ownerID == "" { return nil, vigo.ErrUnauthorized } diff --git a/api/oauth/oidc.go b/api/oauth/oidc.go index ea47326..53dc2ae 100644 --- a/api/oauth/oidc.go +++ b/api/oauth/oidc.go @@ -5,6 +5,7 @@ package oauth import ( + "github.com/veypi/vbase/auth" "github.com/veypi/vbase/cfg" "github.com/veypi/vbase/models" "github.com/veypi/vigo" @@ -13,7 +14,7 @@ import ( // UserInfo OIDC用户信息 func userInfo(x *vigo.X) (map[string]any, error) { // 从token中解析用户ID - userID := getCurrentUserID(x) + userID := auth.GetUserID(x) if userID == "" { return nil, vigo.ErrUnauthorized } @@ -24,13 +25,13 @@ func userInfo(x *vigo.X) (map[string]any, error) { } return map[string]any{ - "sub": user.ID, - "name": user.Nickname, - "nickname": user.Nickname, + "sub": user.ID, + "name": user.Nickname, + "nickname": user.Nickname, "preferred_username": user.Username, - "email": user.Email, - "picture": user.Avatar, - "email_verified": user.EmailVerified, + "email": user.Email, + "picture": user.Avatar, + "email_verified": user.EmailVerified, }, nil } diff --git a/api/org/create.go b/api/org/create.go index 0816970..f62a2f9 100644 --- a/api/org/create.go +++ b/api/org/create.go @@ -28,7 +28,7 @@ func create(x *vigo.X, req *CreateRequest) (*models.Org, error) { } // 获取当前用户ID作为所有者 - ownerID := getCurrentUserID(x) + ownerID := auth.GetUserID(x) if ownerID == "" { return nil, vigo.ErrUnauthorized } @@ -110,10 +110,3 @@ func create(x *vigo.X, req *CreateRequest) (*models.Org, error) { return org, nil } - -func getCurrentUserID(x *vigo.X) string { - if uid, ok := x.Get("user_id").(string); ok { - return uid - } - return "" -} diff --git a/api/org/init.go b/api/org/init.go index 94ff761..73aa30e 100644 --- a/api/org/init.go +++ b/api/org/init.go @@ -14,18 +14,10 @@ var Router = vigo.NewRouter() func init() { Router.Get("/", "组织列表", auth.VBaseAuth.Perm("org:read"), list) Router.Post("/", "创建组织", auth.VBaseAuth.Perm("org:create"), create) - Router.Get("/{org_id}", "获取组织详情", setOrgID, auth.VBaseAuth.Perm("org:read"), get) - Router.Patch("/{org_id}", "更新组织", setOrgID, auth.VBaseAuth.Perm("org:update"), patch) - Router.Delete("/{org_id}", "删除组织", setOrgID, auth.VBaseAuth.Perm("org:delete"), del) + Router.Get("/{org_id}", "获取组织详情", auth.VBaseAuth.LoadOrg, auth.VBaseAuth.Perm("org:read"), get) + Router.Patch("/{org_id}", "更新组织", auth.VBaseAuth.LoadOrg, auth.VBaseAuth.Perm("org:update"), patch) + Router.Delete("/{org_id}", "删除组织", auth.VBaseAuth.LoadOrg, auth.VBaseAuth.Perm("org:delete"), del) Router.Get("/tree", "组织树", auth.VBaseAuth.Perm("org:read"), tree) - Router.Get("/{org_id}/members", "组织成员列表", setOrgID, auth.VBaseAuth.Perm("org:read"), listMembers) - Router.Post("/{org_id}/members", "添加组织成员", setOrgID, 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 + Router.Get("/{org_id}/members", "组织成员列表", auth.VBaseAuth.LoadOrg, auth.VBaseAuth.Perm("org:read"), listMembers) + Router.Post("/{org_id}/members", "添加组织成员", auth.VBaseAuth.LoadOrg, auth.VBaseAuth.Perm("org:update"), addMember) } diff --git a/api/settings/init.go b/api/settings/init.go index 899016f..2a00df5 100644 --- a/api/settings/init.go +++ b/api/settings/init.go @@ -7,6 +7,7 @@ package settings import ( + "github.com/veypi/vbase/auth" "github.com/veypi/vigo" ) @@ -14,7 +15,8 @@ var Router = vigo.NewRouter() func init() { // 获取设置列表(支持按分类过滤) - Router.Get("/", "获取设置列表", list) + // 只有拥有所有权限的可以修改配置表 + Router.Get("/", "获取设置列表", auth.VBaseAuth.Perm("*:*"), list) // 批量更新设置 - Router.Put("/", "批量更新设置", update) + Router.Put("/", "批量更新设置", auth.VBaseAuth.Perm("*:*"), update) } diff --git a/api/settings/update.go b/api/settings/update.go index 8c4edd9..06113d2 100644 --- a/api/settings/update.go +++ b/api/settings/update.go @@ -7,7 +7,6 @@ package settings import ( - "github.com/veypi/vbase/auth" "github.com/veypi/vbase/cfg" "github.com/veypi/vbase/models" "github.com/veypi/vigo" @@ -38,20 +37,11 @@ func update(x *vigo.X, req *UpdateRequest) (*UpdateResponse, error) { 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() updated := 0 - err = db.Transaction(func(tx *gorm.DB) error { + err := db.Transaction(func(tx *gorm.DB) error { for _, item := range req.Settings { var s models.Setting if err := tx.Where("`key` = ?", item.Key).First(&s).Error; err != nil { diff --git a/api/user/get.go b/api/user/get.go index b83222a..de479f1 100644 --- a/api/user/get.go +++ b/api/user/get.go @@ -7,6 +7,7 @@ package user import ( + "github.com/veypi/vbase/auth" "github.com/veypi/vbase/cfg" "github.com/veypi/vbase/models" "github.com/veypi/vigo" @@ -19,6 +20,14 @@ type GetRequest struct { // get 获取用户详情 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 if err := cfg.DB().First(&user, "id = ?", req.UserID).Error; err != nil { return nil, vigo.ErrNotFound diff --git a/api/user/init.go b/api/user/init.go index adf0f70..1df2ad6 100644 --- a/api/user/init.go +++ b/api/user/init.go @@ -14,15 +14,16 @@ import ( var Router = vigo.NewRouter() func init() { + // 管理员 管理用户权限 Router.Get("/", "用户列表", auth.VBaseAuth.Perm("user:read"), list) Router.Post("/", "创建用户", auth.VBaseAuth.Perm("user:admin"), create) - Router.Get("/{user_id}", "获取用户详情", auth.VBaseAuth.PermWithOwner("user:read", "user_id"), get) - Router.Patch("/{user_id}", "更新用户", auth.VBaseAuth.PermWithOwner("user:update", "user_id"), patch) + Router.Get("/{user_id}", "获取用户详情", auth.VBaseAuth.Perm("user:read"), get) + Router.Patch("/{user_id}", "更新用户", patch) Router.Delete("/{user_id}", "删除用户", auth.VBaseAuth.Perm("user:admin"), del) 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.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) } diff --git a/api/user/patch.go b/api/user/patch.go index 58869bc..5a9e177 100644 --- a/api/user/patch.go +++ b/api/user/patch.go @@ -7,6 +7,7 @@ package user import ( + "github.com/veypi/vbase/auth" "github.com/veypi/vbase/cfg" "github.com/veypi/vbase/models" "github.com/veypi/vigo" @@ -23,6 +24,14 @@ type PatchRequest struct { // patch 更新用户 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 if err := cfg.DB().First(&user, "id = ?", req.UserID).Error; err != nil { return nil, vigo.ErrNotFound diff --git a/auth/auth.go b/auth/auth.go index dcb3aff..a343d2e 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -8,6 +8,7 @@ package auth import ( "context" + "errors" "fmt" "regexp" "strings" @@ -18,17 +19,56 @@ import ( "github.com/veypi/vbase/models" "github.com/veypi/vigo" "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 权限管理接口 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 - // 资源所有者权限 - PermWithOwner(permissionID, ownerKey string) func(*vigo.X) error - // 特定资源权限检查 (自动从 Path/Query 获取资源ID) 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) @@ -81,8 +122,9 @@ var VBaseAuth = Factory.New("vb") func init() { // 为 VBaseAuth 添加默认角色 - VBaseAuth.AddRole("admin", "管理员", "*:*") - VBaseAuth.AddRole("user", "普通用户", + + VBaseAuth.AddRole(RoleCodeAdmin, "管理员", "*:*") + VBaseAuth.AddRole(RoleCodeUser, "普通用户", "user:read", "org:read", "org:create", @@ -386,65 +428,82 @@ func (a *appAuth) initRole(roleCode string) error { // ========== 中间件实现 ========== -func (a *appAuth) Perm(permissionID string) func(*vigo.X) error { - validatePermissionID(permissionID) - return func(x *vigo.X) error { - userID := getUserID(x) - if userID == "" { - return vigo.ErrUnauthorized - } +func (a *appAuth) UserID(x *vigo.X) string { + return GetUserID(x) +} - orgID := getOrgID(x) - if err := a.checkPermission(x.Context(), userID, orgID, permissionID, ""); err != nil { - return err +func (a *appAuth) OrgID(x *vigo.X) string { + return GetOrgID(x) +} + +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) return func(x *vigo.X) error { - userID := getUserID(x) + userID := GetUserID(x) if userID == "" { return vigo.ErrUnauthorized } - 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 - } - - // 不是所有者,检查是否有权限 + orgID := GetOrgID(x) if err := a.checkPermission(x.Context(), userID, orgID, permissionID, ""); err != nil { return err } - 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 { validatePermissionID(permissionID) return func(x *vigo.X) error { - userID := getUserID(x) + userID := GetUserID(x) if userID == "" { return vigo.ErrUnauthorized } - orgID := getOrgID(x) + orgID := GetOrgID(x) // 尝试从 PathParams 获取 resourceID := x.PathParams.Get(resourceKey) @@ -462,7 +521,7 @@ func (a *appAuth) PermOnResource(permissionID, resourceKey string) func(*vigo.X) // 内部辅助检查方法,返回 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 { return vigo.ErrInternalServer.WithError(err) } @@ -477,28 +536,18 @@ func (a *appAuth) PermAny(permissionIDs ...string) func(*vigo.X) error { validatePermissionID(pid) } return func(x *vigo.X) error { - userID := getUserID(x) + userID := GetUserID(x) if userID == "" { return vigo.ErrUnauthorized } + orgID := GetOrgID(x) - orgID := getOrgID(x) - var lastErr error for _, pid := range permissionIDs { if err := a.checkPermission(x.Context(), userID, orgID, pid, ""); err == nil { return nil - } else { - lastErr = err } } - - if lastErr != nil { - // 如果是 Forbidden 错误,返回 Forbidden - // 否则返回最后一个错误 - // 这里简单处理,如果所有都失败,返回 Forbidden - return vigo.ErrForbidden - } - return vigo.ErrForbidden + return vigo.ErrNoPermission } } @@ -507,12 +556,11 @@ func (a *appAuth) PermAll(permissionIDs ...string) func(*vigo.X) error { validatePermissionID(pid) } return func(x *vigo.X) error { - userID := getUserID(x) + userID := GetUserID(x) if userID == "" { return vigo.ErrUnauthorized } - - orgID := getOrgID(x) + orgID := GetOrgID(x) for _, pid := range permissionIDs { 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) { - if strings.Count(permissionID, ":") == 1 { - permissionID = fmt.Sprintf("%s:%s", a.scope, permissionID) - } - - // 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) - } +func (a *appAuth) CheckPermission(ctx context.Context, userID, orgID, permissionID, resourceID string) bool { + ok, _ := a.checkPermissionDB(ctx, userID, orgID, permissionID, resourceID) + return ok +} - 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) { @@ -813,6 +838,8 @@ func (a *appAuth) checkPermissionDB(ctx context.Context, userID, orgID, permissi if resourceID != "" { query = query.Where("resource_id = ? OR resource_id = '*'", resourceID) + } else { + query = query.Where("resource_id = '*'") } 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 if err := cfg.DB().Model(&models.Role{}). - Where("code = 'admin'"). + Where("code = ?", RoleCodeAdmin). Pluck("id", &adminRoleIDs).Error; err != nil { return false, err } @@ -924,22 +951,6 @@ func (a *appAuth) isAdmin(ctx context.Context, userID, orgID string) (bool, erro 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 ========== func getUserPermVersion(userID string) string { diff --git a/auth/middleware.go b/auth/middleware.go index 3a1ec01..345d35d 100644 --- a/auth/middleware.go +++ b/auth/middleware.go @@ -5,28 +5,16 @@ package auth import ( - "encoding/json" - "fmt" "strings" - "time" - "github.com/veypi/vbase/cfg" "github.com/veypi/vbase/libs/cache" "github.com/veypi/vbase/libs/jwt" - "github.com/veypi/vbase/models" "github.com/veypi/vigo" ) -// orgMemberCache 组织成员身份缓存结构 -type orgMemberCache struct { - IsMember bool `json:"is_member"` - RoleCodes []string `json:"role_codes"` -} - // AuthMiddleware 统一认证中间件 -// 1. JWT认证: 解析token,验证有效性,设置用户信息 -// 2. 组织上下文: 如果请求包含org_id,验证用户成员身份,设置组织信息 -// 使用Redis缓存组织成员身份和角色信息,减少数据库查询 +// 仅处理 JWT 认证,设置 CtxKeyUserID +// 组织信息的加载需按需调用 Auth.LoadOrg(x) func AuthMiddleware() func(*vigo.X) error { return func(x *vigo.X) error { // === 1. JWT 认证部分 === @@ -53,74 +41,7 @@ func AuthMiddleware() func(*vigo.X) error { } // 将用户信息存入上下文 - x.Set("user_id", 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) + x.Set(CtxKeyUserID, claims.UserID) return nil } diff --git a/docs/auth.md b/docs/auth.md new file mode 100644 index 0000000..7c31f43 --- /dev/null +++ b/docs/auth.md @@ -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` 中间件。这意味着不涉及组织业务的接口(如个人资料、系统设置)不再承担加载组织信息的额外开销。 diff --git a/models/auth.go b/models/auth.go index d96c187..709690f 100644 --- a/models/auth.go +++ b/models/auth.go @@ -79,7 +79,7 @@ type UserRole struct { // 外键关联 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"` } @@ -99,7 +99,7 @@ type UserPermission struct { // 外键关联 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"` } diff --git a/scripts/tests/00_none_auth.sh b/scripts/tests/00_none_auth.sh old mode 100644 new mode 100755 diff --git a/scripts/tests/01_setup_users.sh b/scripts/tests/01_setup_users.sh old mode 100644 new mode 100755 diff --git a/scripts/tests/02_resource_perm.sh b/scripts/tests/02_resource_perm.sh old mode 100644 new mode 100755 diff --git a/scripts/tests/04_org_load_middleware.sh b/scripts/tests/04_org_load_middleware.sh new file mode 100755 index 0000000..05651bf --- /dev/null +++ b/scripts/tests/04_org_load_middleware.sh @@ -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 diff --git a/scripts/tests/README.md b/scripts/tests/README.md index 6d373ad..ccfc490 100644 --- a/scripts/tests/README.md +++ b/scripts/tests/README.md @@ -2,32 +2,62 @@ 本目录包含 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/ -├── README.md # 本说明文件 -├── lib.sh # 公共函数库 -├── 00_none_auth.sh # 未登录访问测试 -├── 01_setup_users.sh # 用户初始化与基础认证 -├── 02_resource_perm.sh # 资源权限交叉测试 -├── 03_org_permission.sh # 组织权限测试 -└── run_all.sh # 运行所有测试 +├── README.md # 本说明文件 +├── lib.sh # 公共函数库 +├── clean_run.sh # [推荐] 一键清理环境、重启服务并运行所有测试 +├── run_all.sh # 运行所有测试脚本 (不负责清理环境) +├── 00_none_auth.sh # 1. 未登录访问测试 +├── 01_setup_users.sh # 2. 用户初始化与基础认证 +├── 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 - rm /tmp/vb.sqlite && go run cli/main.go -db.type=sqlite -db.dsn /tmp/vb.sqlite -p 4000 - ``` -2. 手动查询后端接口列表 - ```bash - curl -sSf http://localhost:4000/_api.json - ``` +# 2. 运行特定测试 +bash scripts/tests/02_resource_perm.sh +``` -## 测试脚本说明 +## 测试脚本详情 ### 00_none_auth.sh @@ -40,20 +70,16 @@ scripts/tests/ **测试内容**:用户初始化与基础功能验证 - 注册三个核心账户:Admin, User1, User2 - 验证注册与登录流程 -- 使用临时账户验证: - - 修改个人信息 - - 修改密码(验证旧密码失效、新密码生效) - - Token 刷新 - - 用户登出 +- 验证基础功能:修改个人信息、修改密码、Token 刷新、用户登出 ### 02_resource_perm.sh **测试内容**:跨用户资源访问权限验证 - 场景 1: Admin 修改任意用户信息 (允许) - 场景 2: User1 修改自己信息 (允许) -- 场景 3: User1 修改 User2 信息 (禁止) -- 场景 4: User1 修改 Admin 信息 (禁止) -- 场景 5: User2 修改 User1 信息 (禁止) +- 场景 3: User1 修改 User2 信息 (禁止 403) +- 场景 4: User1 修改 Admin 信息 (禁止 403) +- 场景 5: User2 修改 User1 信息 (禁止 403) ### 03_org_permission.sh @@ -64,44 +90,75 @@ scripts/tests/ - 普通成员不能修改组织信息 - 只有 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` | 测试运行的时间戳,用于生成唯一的用户名/邮箱 | -**功能**:运行所有测试 -- 按顺序执行所有测试脚本 -- 遇到错误时停止 -- 输出测试摘要 -- 统一时间戳 `TEST_TIMESTAMP`,确保跨脚本的用户数据一致性 +## 常见问题与踩坑记录 (Troubleshooting) -## 使用方法 +### 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 -cd scripts/tests -bash run_all.sh +lsof -i :4000 +kill -9 ``` +`clean_run.sh` 脚本已内置了此清理逻辑。 + +### 4. 数据不一致 / 用户不存在 -### 运行单个测试 +**现象**: +手动运行多个脚本时,提示用户不存在或登录失败。 +**原因**: +测试脚本使用 `TEST_TIMESTAMP` 生成唯一的用户名(如 `user1_1712345678`)。如果你分别运行两个脚本且没有固定 `TEST_TIMESTAMP`,第二个脚本会生成新的时间戳,导致无法找到第一个脚本创建的用户。 + +**解决**: +在手动运行一系列脚本前,先导出时间戳: ```bash -# 必须先设置时间戳以避免冲突 (可选) export TEST_TIMESTAMP=$(date +%s) - -# 运行特定测试 -bash 00_none_auth.sh bash 01_setup_users.sh bash 02_resource_perm.sh ``` - -## 测试环境变量 - -| 变量 | 默认值 | 说明 | -| ---------------- | ----------------------- | ------------------------------ | -| `BASE_URL` | `http://localhost:4000` | API 基础地址 | -| `TEST_TIMESTAMP` | 自动生成 | 测试时间戳,用于生成唯一用户名 | - -## 注意事项 - -1. 测试脚本会创建真实数据,建议在测试数据库上运行 -2. 测试失败时会立即退出(`set -e`) -3. `run_all.sh` 会自动导出 `TEST_TIMESTAMP`,手动运行单个脚本时建议手动设置,否则每次运行都会生成新用户。 diff --git a/scripts/tests/clean_run.sh b/scripts/tests/clean_run.sh new file mode 100755 index 0000000..9d6e68f --- /dev/null +++ b/scripts/tests/clean_run.sh @@ -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 diff --git a/scripts/tests/lib.sh b/scripts/tests/lib.sh index 9086d16..468e5bf 100755 --- a/scripts/tests/lib.sh +++ b/scripts/tests/lib.sh @@ -283,6 +283,7 @@ test_start() { echo "" echo "========================================" echo "测试: $name" + echo "BASE_URL: $BASE_URL" echo "========================================" } diff --git a/scripts/tests/run_all.sh b/scripts/tests/run_all.sh index d901715..ce5c7f3 100755 --- a/scripts/tests/run_all.sh +++ b/scripts/tests/run_all.sh @@ -31,6 +31,7 @@ TESTS=( "01_setup_users.sh:用户初始化与基础认证测试" "02_resource_perm.sh:资源权限交叉验证测试" "03_org_permission.sh:组织权限测试" + "04_org_load_middleware.sh:LoadOrg 中间件测试" ) PASSED=0