diff --git a/README.md b/README.md index 326dbdd..a234ad9 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,11 @@ # VBase 基于 vhtml/vigo 框架实现,提供用户认证、数据库存储、文件存储等功能。 + +## 测试 + +```bash +//重置数据库 +go run cli/main.go db drop && go run cli/main.go db migrate +go run cli/main.go -p 4000 +``` \ No newline at end of file diff --git a/api/auth/login.go b/api/auth/login.go index 9a81034..e73ea6d 100644 --- a/api/auth/login.go +++ b/api/auth/login.go @@ -37,11 +37,11 @@ type AuthResponse struct { // UserInfo 用户信息 type UserInfo struct { - ID string `json:"id"` - Username string `json:"username"` - Nickname string `json:"nickname"` - Email string `json:"email"` - Avatar string `json:"avatar"` + ID string `json:"id"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Email *string `json:"email"` + Avatar string `json:"avatar"` } // login 用户登录 @@ -81,12 +81,17 @@ func login(x *vigo.X, req *LoginRequest) (*AuthResponse, error) { }) } + emailStr := "" + if user.Email != nil { + emailStr = *user.Email + } + tokenPair, err := jwt.GenerateTokenPair( user.ID, user.Username, user.Nickname, user.Avatar, - user.Email, + emailStr, orgClaims, ) if err != nil { @@ -171,12 +176,17 @@ func refresh(x *vigo.X, req *RefreshRequest) (*AuthResponse, error) { } // 生成新token + emailStr := "" + if user.Email != nil { + emailStr = *user.Email + } + tokenPair, err := jwt.GenerateTokenPair( user.ID, user.Username, user.Nickname, user.Avatar, - user.Email, + emailStr, orgClaims, ) if err != nil { diff --git a/api/auth/register.go b/api/auth/register.go index 024738a..8572406 100644 --- a/api/auth/register.go +++ b/api/auth/register.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/libs/jwt" @@ -47,11 +48,20 @@ func register(x *vigo.X, req *RegisterRequest) (*AuthResponse, error) { } // 创建用户 + var email *string + if req.Email != "" { + email = &req.Email + } + var phone *string + if req.Phone != "" { + phone = &req.Phone + } + user := &models.User{ Username: req.Username, Password: hashedPassword, - Email: req.Email, - Phone: req.Phone, + Email: email, + Phone: phone, Nickname: req.Nickname, Status: models.UserStatusActive, } @@ -64,13 +74,26 @@ func register(x *vigo.X, req *RegisterRequest) (*AuthResponse, error) { return nil, vigo.ErrInternalServer.WithError(err) } + // 授予默认角色 "user" + if err := baseauth.VBaseAuth.GrantRole(x.Context(), user.ID, "", "user"); err != nil { + // 记录错误但允许注册继续,或者回滚 + // 这里简单处理,继续流程,用户可能需要管理员手动授权 + // 或者返回错误 + // return nil, vigo.ErrInternalServer.WithError(err) + } + // 生成token + emailStr := "" + if user.Email != nil { + emailStr = *user.Email + } + tokenPair, err := jwt.GenerateTokenPair( user.ID, user.Username, user.Nickname, user.Avatar, - user.Email, + emailStr, nil, // 新用户无组织 ) if err != nil { diff --git a/api/auth/thirdparty.go b/api/auth/thirdparty.go index 3947214..99a1f56 100644 --- a/api/auth/thirdparty.go +++ b/api/auth/thirdparty.go @@ -16,6 +16,7 @@ import ( "strings" "time" + baseauth "github.com/veypi/vbase/auth" "github.com/veypi/vbase/cfg" "github.com/veypi/vbase/libs/cache" "github.com/veypi/vbase/libs/crypto" @@ -260,11 +261,20 @@ func bindWithRegister(x *vigo.X, req *BindWithRegisterRequest) (*AuthResponse, e randomPassword := generateRandomPassword(16) hashedPassword, _ := crypto.HashPassword(randomPassword, cfg.Config.Security.BcryptCost) + var email *string + if req.Email != "" { + email = &req.Email + } + var phone *string + if req.Phone != "" { + phone = &req.Phone + } + user := &models.User{ Username: req.Username, Password: hashedPassword, - Email: req.Email, - Phone: req.Phone, + Email: email, + Phone: phone, Nickname: userInfo.Name, Avatar: userInfo.Avatar, Status: models.UserStatusActive, @@ -278,6 +288,11 @@ func bindWithRegister(x *vigo.X, req *BindWithRegisterRequest) (*AuthResponse, e return nil, vigo.ErrInternalServer.WithError(err) } + // 授予默认角色 "user" + if err := baseauth.VBaseAuth.GrantRole(x.Context(), user.ID, "", "user"); err != nil { + // 记录错误但允许流程继续 + } + // 绑定第三方身份 if err := bindIdentity(user.ID, userInfo.Provider, userInfo); err != nil { return nil, err @@ -685,7 +700,12 @@ func generateAuthResponse(x *vigo.X, user *models.User) (*AuthResponse, error) { user.Username, user.Nickname, user.Avatar, - user.Email, + func() string { + if user.Email != nil { + return *user.Email + } + return "" + }(), orgClaims, ) if err != nil { diff --git a/api/org/create.go b/api/org/create.go index 7841ce0..232a54f 100644 --- a/api/org/create.go +++ b/api/org/create.go @@ -48,15 +48,25 @@ func create(x *vigo.X, req *CreateRequest) (*models.Org, error) { } // 授予创建者 admin 角色 - if err := auth.VBaseAuth.GrantRole(x.Context(), models.GrantRoleRequest{ - UserID: ownerID, - OrgID: org.ID, - RoleCode: "admin", - }); err != nil { + if err := auth.VBaseAuth.GrantRole(x.Context(), ownerID, org.ID, "admin"); err != nil { // 最好回滚,这里简化处理 return nil, vigo.ErrInternalServer.WithError(err) } + member := &models.OrgMember{ + OrgID: org.ID, + UserID: ownerID, + RoleIDs: "admin", + Status: models.MemberStatusActive, + JoinedAt: org.CreatedAt.Format("2006-01-02 15:04:05"), + } + if err := cfg.DB().Create(member).Error; err != nil { + // 回滚 + auth.VBaseAuth.RevokeRole(x.Context(), ownerID, org.ID, "admin") + cfg.DB().Delete(org) + return nil, vigo.ErrInternalServer.WithError(err) + } + return org, nil } diff --git a/api/org/member.go b/api/org/member.go index 9e5547b..90fb211 100644 --- a/api/org/member.go +++ b/api/org/member.go @@ -18,10 +18,10 @@ type ListMembersRequest struct { type MemberInfo struct { models.OrgMember - Username string `json:"username"` - Nickname string `json:"nickname"` - Avatar string `json:"avatar"` - Email string `json:"email"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` + Email *string `json:"email"` } type ListMembersResponse struct { diff --git a/api/user/create.go b/api/user/create.go index d4ec69d..e7db816 100644 --- a/api/user/create.go +++ b/api/user/create.go @@ -7,6 +7,7 @@ package user import ( + "github.com/veypi/vbase/auth" "github.com/veypi/vbase/cfg" "github.com/veypi/vbase/libs/crypto" "github.com/veypi/vbase/models" @@ -47,11 +48,20 @@ func create(x *vigo.X, req *CreateRequest) (*models.User, error) { } // 创建用户 + var email *string + if req.Email != "" { + email = &req.Email + } + var phone *string + if req.Phone != "" { + phone = &req.Phone + } + user := &models.User{ Username: req.Username, Password: hashedPassword, - Email: req.Email, - Phone: req.Phone, + Email: email, + Phone: phone, Nickname: req.Nickname, Status: req.Status, } @@ -64,5 +74,10 @@ func create(x *vigo.X, req *CreateRequest) (*models.User, error) { return nil, vigo.ErrInternalServer.WithError(err) } + // 授予默认角色 "user" + if err := auth.VBaseAuth.GrantRole(x.Context(), user.ID, "", "user"); err != nil { + // 记录错误但允许流程继续 + } + return user, nil } diff --git a/api/user/list.go b/api/user/list.go index e0aae96..3b7caaa 100644 --- a/api/user/list.go +++ b/api/user/list.go @@ -16,8 +16,8 @@ import ( type ListRequest struct { Page int `json:"page" src:"query" default:"1"` PageSize int `json:"page_size" src:"query" default:"20"` - Keyword string `json:"keyword" src:"query" desc:"搜索关键词"` - Status *int `json:"status" src:"query" desc:"状态筛选"` + Keyword *string `json:"keyword" src:"query" desc:"搜索关键词"` + Status *int `json:"status" src:"query" desc:"状态筛选"` } // ListResponse 列表响应 @@ -34,9 +34,9 @@ func list(x *vigo.X, req *ListRequest) (*ListResponse, error) { db := cfg.DB().Model(&models.User{}) // 搜索关键词 - if req.Keyword != "" { + if req.Keyword != nil && *req.Keyword != "" { db = db.Where("username LIKE ? OR nickname LIKE ? OR email LIKE ?", - "%"+req.Keyword+"%", "%"+req.Keyword+"%", "%"+req.Keyword+"%") + "%"+*req.Keyword+"%", "%"+*req.Keyword+"%", "%"+*req.Keyword+"%") } // 状态筛选 diff --git a/auth/auth.go b/auth/auth.go index 7d92d1a..ba142a0 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -26,6 +26,9 @@ type Auth interface { // 资源所有者权限 PermWithOwner(permissionID, ownerKey string) func(*vigo.X) error + // 特定资源权限检查 (自动从 Path/Query 获取资源ID) + PermOnResource(permissionID, resourceKey string) func(*vigo.X) error + // 满足任一权限 PermAny(permissionIDs []string) func(*vigo.X) error @@ -34,13 +37,13 @@ type Auth interface { // ========== 权限管理 ========== // 授予角色 - GrantRole(ctx context.Context, req models.GrantRoleRequest) error + GrantRole(ctx context.Context, userID, orgID, roleCode string) error // 撤销角色 RevokeRole(ctx context.Context, userID, orgID, roleCode string) error // 授予特定资源权限 - GrantResourcePerm(ctx context.Context, req models.GrantResourcePermRequest) error + GrantResourcePerm(ctx context.Context, userID, orgID, permissionID, resourceID string) error // 撤销特定资源权限 RevokeResourcePerm(ctx context.Context, userID, orgID, permissionID, resourceID string) error @@ -50,7 +53,7 @@ type Auth interface { // ========== 权限查询 ========== // 检查权限 - CheckPermission(ctx context.Context, req models.CheckPermRequest) (bool, error) + CheckPermission(ctx context.Context, userID, orgID, permissionID, resourceID string) (bool, error) // 列出用户权限 ListUserPermissions(ctx context.Context, userID, orgID string) ([]models.UserPermissionResult, error) @@ -240,11 +243,7 @@ func (a *appAuth) Perm(permissionID string) func(*vigo.X) error { orgID := getOrgID(x) - ok, err := a.CheckPermission(x.Context(), models.CheckPermRequest{ - UserID: userID, - OrgID: orgID, - PermissionID: permissionID, - }) + ok, err := a.CheckPermission(x.Context(), userID, orgID, permissionID, "") if err != nil { return err } @@ -265,11 +264,7 @@ func (a *appAuth) PermWithOwner(permissionID, ownerKey string) func(*vigo.X) err orgID := getOrgID(x) // 先检查是否有权限 - ok, err := a.CheckPermission(x.Context(), models.CheckPermRequest{ - UserID: userID, - OrgID: orgID, - PermissionID: permissionID, - }) + ok, err := a.CheckPermission(x.Context(), userID, orgID, permissionID, "") if err != nil { return err } @@ -296,6 +291,37 @@ func (a *appAuth) PermWithOwner(permissionID, ownerKey string) func(*vigo.X) err } } +func (a *appAuth) PermOnResource(permissionID, resourceKey string) func(*vigo.X) error { + return func(x *vigo.X) error { + userID := getUserID(x) + if userID == "" { + return vigo.ErrUnauthorized + } + + orgID := getOrgID(x) + + // 尝试从 PathParams 获取 + resourceID := x.PathParams.Get(resourceKey) + if resourceID == "" { + // 尝试从 Query 获取 + resourceID = x.Request.URL.Query().Get(resourceKey) + } + + // 如果没有获取到 resourceID,仍然进行检查 (resourceID="") + // 这意味着检查用户是否拥有该权限的一般访问权 (例如通过角色获得) + // 如果想要强制检查特定资源,调用方应该确保 resourceKey 能获取到值 + + ok, err := a.CheckPermission(x.Context(), userID, orgID, permissionID, resourceID) + if err != nil { + return err + } + if !ok { + return vigo.ErrForbidden + } + return nil + } +} + func (a *appAuth) PermAny(permissionIDs []string) func(*vigo.X) error { return func(x *vigo.X) error { userID := getUserID(x) @@ -306,11 +332,7 @@ func (a *appAuth) PermAny(permissionIDs []string) func(*vigo.X) error { orgID := getOrgID(x) for _, permID := range permissionIDs { - ok, err := a.CheckPermission(x.Context(), models.CheckPermRequest{ - UserID: userID, - OrgID: orgID, - PermissionID: permID, - }) + ok, err := a.CheckPermission(x.Context(), userID, orgID, permID, "") if err != nil { return err } @@ -333,11 +355,7 @@ func (a *appAuth) PermAll(permissionIDs []string) func(*vigo.X) error { orgID := getOrgID(x) for _, permID := range permissionIDs { - ok, err := a.CheckPermission(x.Context(), models.CheckPermRequest{ - UserID: userID, - OrgID: orgID, - PermissionID: permID, - }) + ok, err := a.CheckPermission(x.Context(), userID, orgID, permID, "") if err != nil { return err } @@ -352,32 +370,32 @@ func (a *appAuth) PermAll(permissionIDs []string) func(*vigo.X) error { // ========== 权限管理实现 ========== -func (a *appAuth) GrantRole(ctx context.Context, req models.GrantRoleRequest) error { +func (a *appAuth) GrantRole(ctx context.Context, userID, orgID, roleCode string) error { // 查找角色 var role models.Role - query := cfg.DB().Where("code = ?", req.RoleCode) - if req.OrgID != "" { - query = query.Where("org_id = ?", req.OrgID) + query := cfg.DB().Where("code = ?", roleCode) + if orgID != "" { + query = query.Where("org_id = ?", orgID) } else { query = query.Where("org_id = ''") } if err := query.First(&role).Error; err != nil { // 如果指定了 OrgID 但没找到,尝试查找全局角色 - if req.OrgID != "" { - query = cfg.DB().Where("code = ? AND org_id = ''", req.RoleCode) + if orgID != "" { + query = cfg.DB().Where("code = ? AND org_id = ''", roleCode) if err := query.First(&role).Error; err != nil { - return fmt.Errorf("role not found: %s", req.RoleCode) + return fmt.Errorf("role not found: %s", roleCode) } } else { - return fmt.Errorf("role not found: %s", req.RoleCode) + return fmt.Errorf("role not found: %s", roleCode) } } // 检查是否已存在 var count int64 cfg.DB().Model(&models.UserRole{}). - Where("user_id = ? AND org_id = ? AND role_id = ?", req.UserID, req.OrgID, role.ID). + Where("user_id = ? AND org_id = ? AND role_id = ?", userID, orgID, role.ID). Count(&count) if count > 0 { @@ -385,10 +403,10 @@ func (a *appAuth) GrantRole(ctx context.Context, req models.GrantRoleRequest) er } userRole := models.UserRole{ - UserID: req.UserID, - OrgID: req.OrgID, + UserID: userID, + OrgID: orgID, RoleID: role.ID, - ExpireAt: req.ExpireAt, + ExpireAt: nil, // 默认不过期 } return cfg.DB().Create(&userRole).Error @@ -419,38 +437,43 @@ func (a *appAuth) RevokeRole(ctx context.Context, userID, orgID, roleCode string Delete(&models.UserRole{}).Error } -func (a *appAuth) GrantResourcePerm(ctx context.Context, req models.GrantResourcePermRequest) error { +func (a *appAuth) GrantResourcePerm(ctx context.Context, userID, orgID, permissionID, resourceID string) error { + if strings.Count(permissionID, ":") == 1 { + permissionID = fmt.Sprintf("%s:%s", a.appKey, permissionID) + } // 检查权限是否存在 var perm models.Permission - if err := cfg.DB().Where("id = ?", req.PermissionID).First(&perm).Error; err != nil { - return fmt.Errorf("permission not found: %s", req.PermissionID) + if err := cfg.DB().Where("id = ?", permissionID).First(&perm).Error; err != nil { + return fmt.Errorf("permission not found: %s", permissionID) } // 检查是否已存在 var existing models.UserPermission err := cfg.DB().Where("user_id = ? AND org_id = ? AND permission_id = ? AND resource_id = ?", - req.UserID, req.OrgID, req.PermissionID, req.ResourceID). + userID, orgID, permissionID, resourceID). First(&existing).Error if err == nil { - // 更新过期时间 - existing.ExpireAt = req.ExpireAt - return cfg.DB().Save(&existing).Error + // 已存在 + return nil } userPerm := models.UserPermission{ - UserID: req.UserID, - OrgID: req.OrgID, - PermissionID: req.PermissionID, - ResourceID: req.ResourceID, - ExpireAt: req.ExpireAt, - GrantedBy: req.GrantedBy, + UserID: userID, + OrgID: orgID, + PermissionID: permissionID, + ResourceID: resourceID, + ExpireAt: nil, // 默认不过期 + GrantedBy: "", // 默认空 } return cfg.DB().Create(&userPerm).Error } func (a *appAuth) RevokeResourcePerm(ctx context.Context, userID, orgID, permissionID, resourceID string) error { + if strings.Count(permissionID, ":") == 1 { + permissionID = fmt.Sprintf("%s:%s", a.appKey, permissionID) + } return cfg.DB().Where("user_id = ? AND org_id = ? AND permission_id = ? AND resource_id = ?", userID, orgID, permissionID, resourceID). Delete(&models.UserPermission{}).Error @@ -474,37 +497,70 @@ func (a *appAuth) RevokeAll(ctx context.Context, userID, orgID string) error { // ========== 权限查询实现 ========== -func (a *appAuth) CheckPermission(ctx context.Context, req models.CheckPermRequest) (bool, error) { - // 1. 检查用户是否有该权限的角色 +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.appKey, permissionID) + } + + fmt.Printf("[DEBUG] CheckPermission: userID=%s, orgID=%s, permID=%s, resID=%s\n", userID, orgID, permissionID, resourceID) + + // 1. 检查用户是否有该权限的角色(包括当前组织角色和系统全局角色) var roleIDs []string - if err := cfg.DB().Model(&models.UserRole{}). - Where("user_id = ? AND org_id = ? AND (expire_at IS NULL OR expire_at > ?)", - req.UserID, req.OrgID, time.Now()). - Pluck("role_id", &roleIDs).Error; err != nil { + roleQuery := cfg.DB().Model(&models.UserRole{}). + Where("user_id = ? AND (expire_at IS NULL OR expire_at > ?)", userID, time.Now()) + + if orgID != "" { + roleQuery = roleQuery.Where("org_id = ? OR org_id = ''", orgID) + } else { + roleQuery = roleQuery.Where("org_id = ''") + } + + if err := roleQuery.Pluck("role_id", &roleIDs).Error; err != nil { + fmt.Printf("[DEBUG] CheckPermission: failed to get roles: %v\n", err) return false, err } + fmt.Printf("[DEBUG] CheckPermission: roleIDs=%v\n", roleIDs) if len(roleIDs) > 0 { + // 构造可能的通配符权限ID + permsToCheck := []string{permissionID} + parts := strings.Split(permissionID, ":") + if len(parts) == 3 { + // app:resource:* + permsToCheck = append(permsToCheck, fmt.Sprintf("%s:%s:*", parts[0], parts[1])) + // app:*:* + permsToCheck = append(permsToCheck, fmt.Sprintf("%s:*:*", parts[0])) + } + // 检查这些角色是否有所需权限 var count int64 if err := cfg.DB().Model(&models.RolePermission{}). - Where("role_id IN ? AND permission_id = ?", roleIDs, req.PermissionID). + Where("role_id IN ? AND permission_id IN ?", roleIDs, permsToCheck). Count(&count).Error; err != nil { return false, err } + fmt.Printf("[DEBUG] CheckPermission: role perm count=%d checked=%v\n", count, permsToCheck) if count > 0 { return true, nil } } // 2. 检查用户是否有特定的资源权限 + // 构造可能的通配符权限ID (同上) + permsToCheck := []string{permissionID} + parts := strings.Split(permissionID, ":") + if len(parts) == 3 { + permsToCheck = append(permsToCheck, fmt.Sprintf("%s:%s:*", parts[0], parts[1])) + permsToCheck = append(permsToCheck, fmt.Sprintf("%s:*:*", parts[0])) + } + var userPermCount int64 query := cfg.DB().Model(&models.UserPermission{}). - Where("user_id = ? AND org_id = ? AND permission_id = ? AND (expire_at IS NULL OR expire_at > ?)", - req.UserID, req.OrgID, req.PermissionID, time.Now()) + Where("user_id = ? AND org_id = ? AND permission_id IN ? AND (expire_at IS NULL OR expire_at > ?)", + userID, orgID, permsToCheck, time.Now()) - if req.ResourceID != "" { - query = query.Where("resource_id = ? OR resource_id = '*'", req.ResourceID) + if resourceID != "" { + query = query.Where("resource_id = ? OR resource_id = '*'", resourceID) } if err := query.Count(&userPermCount).Error; err != nil { diff --git a/docs/integration.md b/docs/integration.md index ec538f2..9b0a06c 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -158,3 +158,103 @@ func MyHandler(x *vigo.X) error { // ... 业务逻辑 } ``` + +### 2.5 权限管理高级用法 + +`AppAuth` 实例提供了丰富的接口来管理和检查权限。 + +#### 2.5.1 复杂权限检查中间件 + +除了基础的 `Perm` 和 `PermWithOwner`,还支持复合权限检查: + +```go +// 要求同时拥有 user:read 和 order:read 权限 +Router.Get("/stats", "统计数据", AppAuth.PermAll([]string{"user:read", "order:read"}), getStats) + +// 只要拥有 user:read 或 user:admin 其中之一即可 +Router.Get("/users", "用户列表", AppAuth.PermAny([]string{"user:read", "user:admin"}), listUsers) +``` + +#### 2.5.2 代码中动态检查权限 + +有时需要在 Handler 内部根据业务逻辑进行动态鉴权: + +```go +func someHandler(x *vigo.X) error { + userID := x.Get("user_id").(string) + orgID := x.Get("org_id").(string) + + // 检查是否有 "report:export" 权限 + // 注意:resourceID 为空时检查通用权限,指定 resourceID 时检查特定资源权限 + allowed, err := AppAuth.CheckPermission(x.Context(), userID, orgID, "report:export", "") + + if err != nil || !allowed { + return vigo.ErrForbidden + } + + // ... +} +``` + +#### 2.5.3 授予和撤销角色 + +您可以在业务逻辑中动态授予或撤销用户角色。 +**注意**:`orgID` 可以为空字符串,表示授予系统级(全局)角色。 + +```go +// 授予用户 "editor" 角色 (在指定组织下) +err := AppAuth.GrantRole(ctx, targetUserID, orgID, "editor") + +// 撤销角色 +err := AppAuth.RevokeRole(ctx, targetUserID, orgID, "editor") +``` + +#### 2.5.4 细粒度资源授权 + +如果角色机制不够灵活,可以直接授予用户对特定资源的权限: + +```go +// 授予用户对 ID 为 "123" 的文章的 "read" 权限 +err := AppAuth.GrantResourcePerm(ctx, targetUserID, orgID, "article:read", "123") + +// 撤销资源权限 +err := AppAuth.RevokeResourcePerm(ctx, targetUserID, orgID, "article:read", "123") +``` + +#### 2.5.5 检查资源权限 + +授予了细粒度资源权限后,可以通过以下两种方式进行检查: + +1. **使用中间件 (推荐)** + + `PermOnResource` 中间件会自动从路径参数或查询参数中获取资源ID,并检查用户是否有权访问该特定资源。 + + ```go + // 自动从路径参数 "id" 获取资源ID (如 /articles/123) + // 如果用户拥有 "article:read" 角色权限,或者被单独授予了对 "123" 的 "article:read" 权限,均可通过检查 + Router.Get("/articles/{id}", "获取文章", AppAuth.PermOnResource("article:read", "id"), getArticle) + ``` + +2. **手动检查** + + 在 Handler 中手动调用 `CheckPermission`。 + + ```go + func getArticle(x *vigo.X) error { + articleID := x.PathParams.Get("id") + userID := x.Get("user_id").(string) + orgID := x.Get("org_id").(string) + + // 检查权限 + allowed, err := AppAuth.CheckPermission(x.Context(), userID, orgID, "article:read", articleID) + if err != nil { + return err + } + if !allowed { + return vigo.ErrForbidden + } + + // ... + } + ``` + diff --git a/models/auth.go b/models/auth.go index 79119b5..cd2ee8a 100644 --- a/models/auth.go +++ b/models/auth.go @@ -66,10 +66,10 @@ func (RolePermission) TableName() string { // UserRole 用户角色关联表 type UserRole struct { Base - UserID string `json:"user_id" gorm:"index;size:36" desc:"用户ID"` - OrgID string `json:"org_id" gorm:"index;size:36" desc:"组织ID"` - RoleID string `json:"role_id" gorm:"index;size:36" desc:"角色ID"` - ExpireAt time.Time `json:"expire_at" desc:"过期时间(可选)"` + UserID string `json:"user_id" gorm:"index;size:36" desc:"用户ID"` + OrgID string `json:"org_id" gorm:"index;size:36" desc:"组织ID"` + RoleID string `json:"role_id" gorm:"index;size:36" desc:"角色ID"` + ExpireAt *time.Time `json:"expire_at" desc:"过期时间(可选)"` } func (UserRole) TableName() string { @@ -79,12 +79,12 @@ func (UserRole) TableName() string { // UserPermission 用户特定资源权限表(数据级权限) type UserPermission struct { Base - UserID string `json:"user_id" gorm:"index;size:36" desc:"用户ID"` - OrgID string `json:"org_id" gorm:"index;size:36" desc:"组织ID"` - PermissionID string `json:"permission_id" gorm:"index;size:100" desc:"权限ID"` - ResourceID string `json:"resource_id" gorm:"index;size:100" desc:"具体资源ID,* 表示所有"` - ExpireAt time.Time `json:"expire_at" desc:"过期时间(可选)"` - GrantedBy string `json:"granted_by" gorm:"size:36" desc:"授权人ID"` + UserID string `json:"user_id" gorm:"index;size:36" desc:"用户ID"` + OrgID string `json:"org_id" gorm:"index;size:36" desc:"组织ID"` + PermissionID string `json:"permission_id" gorm:"index;size:100" desc:"权限ID"` + ResourceID string `json:"resource_id" gorm:"index;size:100" desc:"具体资源ID,* 表示所有"` + ExpireAt *time.Time `json:"expire_at" desc:"过期时间(可选)"` + GrantedBy string `json:"granted_by" gorm:"size:36" desc:"授权人ID"` } func (UserPermission) TableName() string { @@ -108,20 +108,20 @@ type RoleDefinition struct { // GrantRoleRequest 授予角色请求 type GrantRoleRequest struct { - UserID string `json:"user_id" desc:"用户ID"` - OrgID string `json:"org_id" desc:"组织ID"` - RoleCode string `json:"role_code" desc:"角色代码"` - ExpireAt time.Time `json:"expire_at" desc:"过期时间(可选)"` + UserID string `json:"user_id" desc:"用户ID"` + OrgID string `json:"org_id" desc:"组织ID"` + RoleCode string `json:"role_code" desc:"角色代码"` + ExpireAt *time.Time `json:"expire_at" desc:"过期时间(可选)"` } // GrantResourcePermRequest 授予资源权限请求 type GrantResourcePermRequest struct { - UserID string `json:"user_id" desc:"用户ID"` - OrgID string `json:"org_id" desc:"组织ID"` - PermissionID string `json:"permission_id" desc:"权限ID"` - ResourceID string `json:"resource_id" desc:"资源实例ID,* 表示所有"` - ExpireAt time.Time `json:"expire_at" desc:"过期时间(可选)"` - GrantedBy string `json:"granted_by" desc:"授权人ID"` + UserID string `json:"user_id" desc:"用户ID"` + OrgID string `json:"org_id" desc:"组织ID"` + PermissionID string `json:"permission_id" desc:"权限ID"` + ResourceID string `json:"resource_id" desc:"资源实例ID,* 表示所有"` + ExpireAt *time.Time `json:"expire_at" desc:"过期时间(可选)"` + GrantedBy string `json:"granted_by" desc:"授权人ID"` } // CheckPermRequest 检查权限请求 diff --git a/models/user.go b/models/user.go index 21c7f23..17f6f67 100644 --- a/models/user.go +++ b/models/user.go @@ -17,8 +17,8 @@ type User struct { Password string `json:"-" gorm:"size:255"` // bcrypt hash Nickname string `json:"nickname" gorm:"size:50"` Avatar string `json:"avatar" gorm:"size:500"` - Email string `json:"email" gorm:"uniqueIndex;size:100"` - Phone string `json:"phone" gorm:"uniqueIndex;size:20"` + Email *string `json:"email" gorm:"uniqueIndex;size:100"` + Phone *string `json:"phone" gorm:"uniqueIndex;size:20"` Status int `json:"status" gorm:"default:1"` // 0:禁用 1:正常 2:未激活 EmailVerified bool `json:"email_verified" gorm:"default:false"` PhoneVerified bool `json:"phone_verified" gorm:"default:false"` diff --git a/scripts/start.sh b/scripts/start.sh new file mode 100644 index 0000000..8c9cdfe --- /dev/null +++ b/scripts/start.sh @@ -0,0 +1,10 @@ +#! /bin/sh +# +# start.sh +# Copyright (C) 2026 veypi +# +# Distributed under terms of the MIT license. +# + +go run cli/main.go db drop -y + diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..aaf6143 --- /dev/null +++ b/test.sh @@ -0,0 +1,224 @@ +#!/bin/bash + +# Configuration +BASE_URL="http://localhost:4000" +TIMESTAMP=$(date +%s) +USERNAME="user_$TIMESTAMP" +PASSWORD="password123" +EMAIL="${USERNAME}@example.com" +ORG_CODE="org_$TIMESTAMP" +ORG_NAME="Org $TIMESTAMP" + +echo "Testing against $BASE_URL" +echo "User: $USERNAME" +echo "Org: $ORG_CODE" + +# Helper function to check for errors +check_error() { + if [ $? -ne 0 ]; then + echo "Error: $1" + exit 1 + fi +} + +check_http_code() { + RESPONSE=$1 + EXPECTED=$2 + + if [ -z "$RESPONSE" ] || [ "$RESPONSE" == "null" ]; then + if [ "$EXPECTED" == "200" ]; then + return 0 + else + echo "Expected code $EXPECTED, got empty response" + exit 1 + fi + fi + + # Check if .code exists and is a number. If not, assume 200. + CODE=$(echo "$RESPONSE" | jq -r 'if (.code | type) == "number" then .code else 200 end') + + if [ "$CODE" != "$EXPECTED" ] && [ "$EXPECTED" != "200" ]; then + echo "Expected code $EXPECTED, got $CODE" + echo "Response: $RESPONSE" + exit 1 + fi + # Handle implicit 200 (when code field is missing or not a number) + if [ "$EXPECTED" == "200" ] && [ "$CODE" != "200" ] && [ "$CODE" != "0" ]; then + echo "Expected code 200, got $CODE" + echo "Response: $RESPONSE" + exit 1 + fi +} + +echo "==================================================" +echo "1. Registering User..." +REGISTER_RES=$(curl -s -X POST "$BASE_URL/api/auth/register" \ + -H "Content-Type: application/json" \ + -d "{\"username\": \"$USERNAME\", \"password\": \"$PASSWORD\", \"email\": \"$EMAIL\"}") +echo "Register Response: $REGISTER_RES" +check_http_code "$REGISTER_RES" 200 + +echo "==================================================" +echo "2. Logging in..." +LOGIN_RES=$(curl -s -X POST "$BASE_URL/api/auth/login" \ + -H "Content-Type: application/json" \ + -d "{\"username\": \"$USERNAME\", \"password\": \"$PASSWORD\"}") +echo "Login Response: $LOGIN_RES" +check_http_code "$LOGIN_RES" 200 + +ACCESS_TOKEN=$(echo "$LOGIN_RES" | jq -r '.access_token') +REFRESH_TOKEN=$(echo "$LOGIN_RES" | jq -r '.refresh_token') + +if [ -z "$ACCESS_TOKEN" ] || [ "$ACCESS_TOKEN" == "null" ]; then + echo "Failed to get access token" + exit 1 +fi + +echo "Got Access Token" + +echo "==================================================" +echo "3. Get User Info (Me)..." +ME_RES=$(curl -s -X GET "$BASE_URL/api/auth/me" \ + -H "Authorization: Bearer $ACCESS_TOKEN") +echo "Me Response: $ME_RES" +check_http_code "$ME_RES" 200 +USER_ID=$(echo "$ME_RES" | jq -r '.id') +echo "User ID: $USER_ID" + +echo "==================================================" +echo "4. Update User Info (Patch Me)..." +UPDATE_ME_RES=$(curl -s -X PATCH "$BASE_URL/api/auth/me" \ + -H "Authorization: Bearer $ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"nickname\": \"Updated Nickname\"}") +echo "Update Me Response: $UPDATE_ME_RES" +check_http_code "$UPDATE_ME_RES" 200 +NEW_NICKNAME=$(echo "$UPDATE_ME_RES" | jq -r '.nickname') +if [ "$NEW_NICKNAME" != "Updated Nickname" ]; then + echo "Nickname update failed" + exit 1 +fi + +echo "==================================================" +echo "5. Change Password..." +CHANGE_PW_RES=$(curl -s -X POST "$BASE_URL/api/auth/me/change-password" \ + -H "Authorization: Bearer $ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"old_password\": \"$PASSWORD\", \"new_password\": \"newpassword123\"}") +echo "Change Password Response: $CHANGE_PW_RES" +check_http_code "$CHANGE_PW_RES" 200 + +# Verify login with new password +echo "Verifying new password..." +LOGIN_NEW_RES=$(curl -s -X POST "$BASE_URL/api/auth/login" \ + -H "Content-Type: application/json" \ + -d "{\"username\": \"$USERNAME\", \"password\": \"newpassword123\"}") +check_http_code "$LOGIN_NEW_RES" 200 +echo "Login with new password successful" + +# Get new token +ACCESS_TOKEN=$(echo "$LOGIN_NEW_RES" | jq -r '.access_token') +REFRESH_TOKEN=$(echo "$LOGIN_NEW_RES" | jq -r '.refresh_token') + +echo "==================================================" +echo "6. Refresh Token..." +REFRESH_RES=$(curl -s -X POST "$BASE_URL/api/auth/refresh" \ + -H "Authorization: Bearer $ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"refresh_token\": \"$REFRESH_TOKEN\"}") +echo "Refresh Response: $REFRESH_RES" +check_http_code "$REFRESH_RES" 200 +NEW_ACCESS_TOKEN=$(echo "$REFRESH_RES" | jq -r '.access_token') +if [ -z "$NEW_ACCESS_TOKEN" ] || [ "$NEW_ACCESS_TOKEN" == "null" ]; then + echo "Failed to refresh token" + exit 1 +fi +ACCESS_TOKEN=$NEW_ACCESS_TOKEN +echo "Token Refreshed" + +echo "==================================================" +echo "7. Create Organization..." +CREATE_ORG_RES=$(curl -s -X POST "$BASE_URL/api/orgs" \ + -H "Authorization: Bearer $ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"name\": \"$ORG_NAME\", \"code\": \"$ORG_CODE\", \"description\": \"Test Description\"}") +echo "Create Org Response: $CREATE_ORG_RES" +check_http_code "$CREATE_ORG_RES" 200 +ORG_ID=$(echo "$CREATE_ORG_RES" | jq -r '.id') +echo "Org ID: $ORG_ID" + +echo "==================================================" +echo "8. Get Organization..." +GET_ORG_RES=$(curl -s -X GET "$BASE_URL/api/orgs/$ORG_ID" \ + -H "Authorization: Bearer $ACCESS_TOKEN") +# Need to pass X-Org-ID or use context? +# The get endpoint logic: Router.Get("/{org_id}", ..., setOrgID, auth.VBaseAuth.Perm("org:read"), get) +# setOrgID sets org_id from path param. +# Perm checks permission for that org_id. +# User should have admin role in that org. +echo "Get Org Response: $GET_ORG_RES" +check_http_code "$GET_ORG_RES" 200 + +echo "==================================================" +echo "9. Update Organization..." +UPDATE_ORG_RES=$(curl -s -X PATCH "$BASE_URL/api/orgs/$ORG_ID" \ + -H "Authorization: Bearer $ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"name\": \"${ORG_NAME}_Updated\"}") +echo "Update Org Response: $UPDATE_ORG_RES" +check_http_code "$UPDATE_ORG_RES" 200 +UPDATED_NAME=$(echo "$UPDATE_ORG_RES" | jq -r '.name') +if [ "$UPDATED_NAME" != "${ORG_NAME}_Updated" ]; then + echo "Failed to update organization name" + exit 1 +fi + +echo "==================================================" +echo "10. List Org Members..." +MEMBERS_RES=$(curl -s -X GET "$BASE_URL/api/orgs/$ORG_ID/members" \ + -H "Authorization: Bearer $ACCESS_TOKEN") +echo "List Members Response: $MEMBERS_RES" +check_http_code "$MEMBERS_RES" 200 + +# Verify member count is at least 1 (the owner) +TOTAL=$(echo "$MEMBERS_RES" | jq -r '.total') +if [ "$TOTAL" -lt 1 ]; then + echo "Expected at least 1 member, got $TOTAL" + exit 1 +fi + + +echo "==================================================" +echo "11. List Users..." +USERS_RES=$(curl -s -X GET "$BASE_URL/api/users" \ + -H "Authorization: Bearer $ACCESS_TOKEN") +echo "List Users Response: $USERS_RES" +check_http_code "$USERS_RES" 200 + +echo "==================================================" +echo "12. Delete Organization..." +DELETE_ORG_RES=$(curl -s -X DELETE "$BASE_URL/api/orgs/$ORG_ID" \ + -H "Authorization: Bearer $ACCESS_TOKEN") +echo "Delete Org Response: $DELETE_ORG_RES" +check_http_code "$DELETE_ORG_RES" 200 + +# Verify deletion +VERIFY_RES=$(curl -s -X GET "$BASE_URL/api/orgs/$ORG_ID" \ + -H "Authorization: Bearer $ACCESS_TOKEN") +echo "Verify Delete Response: $VERIFY_RES" +# Expect 404 +CODE=$(echo "$VERIFY_RES" | jq -r '.code') +if [ "$CODE" != "404" ]; then + echo "Organization not deleted properly, got code $CODE" + exit 1 +fi + +echo "==================================================" +echo "13. Logout..." +LOGOUT_RES=$(curl -s -X POST "$BASE_URL/api/auth/logout" \ + -H "Authorization: Bearer $ACCESS_TOKEN") +echo "Logout Response: $LOGOUT_RES" +check_http_code "$LOGOUT_RES" 200 + +echo "==================================================" +echo "All Tests Passed Successfully!"