v3
veypi 1 week ago
parent 26bd0bfd29
commit 5daab97008

@ -1,3 +1,11 @@
# VBase # VBase
基于 vhtml/vigo 框架实现,提供用户认证、数据库存储、文件存储等功能。 基于 vhtml/vigo 框架实现,提供用户认证、数据库存储、文件存储等功能。
## 测试
```bash
//重置数据库
go run cli/main.go db drop && go run cli/main.go db migrate
go run cli/main.go -p 4000
```

@ -40,7 +40,7 @@ type UserInfo struct {
ID string `json:"id"` ID string `json:"id"`
Username string `json:"username"` Username string `json:"username"`
Nickname string `json:"nickname"` Nickname string `json:"nickname"`
Email string `json:"email"` Email *string `json:"email"`
Avatar string `json:"avatar"` Avatar string `json:"avatar"`
} }
@ -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( tokenPair, err := jwt.GenerateTokenPair(
user.ID, user.ID,
user.Username, user.Username,
user.Nickname, user.Nickname,
user.Avatar, user.Avatar,
user.Email, emailStr,
orgClaims, orgClaims,
) )
if err != nil { if err != nil {
@ -171,12 +176,17 @@ func refresh(x *vigo.X, req *RefreshRequest) (*AuthResponse, error) {
} }
// 生成新token // 生成新token
emailStr := ""
if user.Email != nil {
emailStr = *user.Email
}
tokenPair, err := jwt.GenerateTokenPair( tokenPair, err := jwt.GenerateTokenPair(
user.ID, user.ID,
user.Username, user.Username,
user.Nickname, user.Nickname,
user.Avatar, user.Avatar,
user.Email, emailStr,
orgClaims, orgClaims,
) )
if err != nil { if err != nil {

@ -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/libs/jwt" "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{ user := &models.User{
Username: req.Username, Username: req.Username,
Password: hashedPassword, Password: hashedPassword,
Email: req.Email, Email: email,
Phone: req.Phone, Phone: phone,
Nickname: req.Nickname, Nickname: req.Nickname,
Status: models.UserStatusActive, Status: models.UserStatusActive,
} }
@ -64,13 +74,26 @@ func register(x *vigo.X, req *RegisterRequest) (*AuthResponse, error) {
return nil, vigo.ErrInternalServer.WithError(err) 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 // 生成token
emailStr := ""
if user.Email != nil {
emailStr = *user.Email
}
tokenPair, err := jwt.GenerateTokenPair( tokenPair, err := jwt.GenerateTokenPair(
user.ID, user.ID,
user.Username, user.Username,
user.Nickname, user.Nickname,
user.Avatar, user.Avatar,
user.Email, emailStr,
nil, // 新用户无组织 nil, // 新用户无组织
) )
if err != nil { if err != nil {

@ -16,6 +16,7 @@ import (
"strings" "strings"
"time" "time"
baseauth "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"
@ -260,11 +261,20 @@ func bindWithRegister(x *vigo.X, req *BindWithRegisterRequest) (*AuthResponse, e
randomPassword := generateRandomPassword(16) randomPassword := generateRandomPassword(16)
hashedPassword, _ := crypto.HashPassword(randomPassword, cfg.Config.Security.BcryptCost) 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{ user := &models.User{
Username: req.Username, Username: req.Username,
Password: hashedPassword, Password: hashedPassword,
Email: req.Email, Email: email,
Phone: req.Phone, Phone: phone,
Nickname: userInfo.Name, Nickname: userInfo.Name,
Avatar: userInfo.Avatar, Avatar: userInfo.Avatar,
Status: models.UserStatusActive, Status: models.UserStatusActive,
@ -278,6 +288,11 @@ func bindWithRegister(x *vigo.X, req *BindWithRegisterRequest) (*AuthResponse, e
return nil, vigo.ErrInternalServer.WithError(err) 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 { if err := bindIdentity(user.ID, userInfo.Provider, userInfo); err != nil {
return nil, err return nil, err
@ -685,7 +700,12 @@ func generateAuthResponse(x *vigo.X, user *models.User) (*AuthResponse, error) {
user.Username, user.Username,
user.Nickname, user.Nickname,
user.Avatar, user.Avatar,
user.Email, func() string {
if user.Email != nil {
return *user.Email
}
return ""
}(),
orgClaims, orgClaims,
) )
if err != nil { if err != nil {

@ -48,15 +48,25 @@ func create(x *vigo.X, req *CreateRequest) (*models.Org, error) {
} }
// 授予创建者 admin 角色 // 授予创建者 admin 角色
if err := auth.VBaseAuth.GrantRole(x.Context(), models.GrantRoleRequest{ if err := auth.VBaseAuth.GrantRole(x.Context(), ownerID, org.ID, "admin"); err != nil {
UserID: ownerID,
OrgID: org.ID,
RoleCode: "admin",
}); err != nil {
// 最好回滚,这里简化处理 // 最好回滚,这里简化处理
return nil, vigo.ErrInternalServer.WithError(err) 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 return org, nil
} }

@ -21,7 +21,7 @@ type MemberInfo struct {
Username string `json:"username"` Username string `json:"username"`
Nickname string `json:"nickname"` Nickname string `json:"nickname"`
Avatar string `json:"avatar"` Avatar string `json:"avatar"`
Email string `json:"email"` Email *string `json:"email"`
} }
type ListMembersResponse struct { type ListMembersResponse struct {

@ -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/libs/crypto" "github.com/veypi/vbase/libs/crypto"
"github.com/veypi/vbase/models" "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{ user := &models.User{
Username: req.Username, Username: req.Username,
Password: hashedPassword, Password: hashedPassword,
Email: req.Email, Email: email,
Phone: req.Phone, Phone: phone,
Nickname: req.Nickname, Nickname: req.Nickname,
Status: req.Status, Status: req.Status,
} }
@ -64,5 +74,10 @@ func create(x *vigo.X, req *CreateRequest) (*models.User, error) {
return nil, vigo.ErrInternalServer.WithError(err) return nil, vigo.ErrInternalServer.WithError(err)
} }
// 授予默认角色 "user"
if err := auth.VBaseAuth.GrantRole(x.Context(), user.ID, "", "user"); err != nil {
// 记录错误但允许流程继续
}
return user, nil return user, nil
} }

@ -16,7 +16,7 @@ import (
type ListRequest struct { type ListRequest 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"`
Keyword string `json:"keyword" src:"query" desc:"搜索关键词"` Keyword *string `json:"keyword" src:"query" desc:"搜索关键词"`
Status *int `json:"status" src:"query" desc:"状态筛选"` Status *int `json:"status" src:"query" desc:"状态筛选"`
} }
@ -34,9 +34,9 @@ func list(x *vigo.X, req *ListRequest) (*ListResponse, error) {
db := cfg.DB().Model(&models.User{}) 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 ?", db = db.Where("username LIKE ? OR nickname LIKE ? OR email LIKE ?",
"%"+req.Keyword+"%", "%"+req.Keyword+"%", "%"+req.Keyword+"%") "%"+*req.Keyword+"%", "%"+*req.Keyword+"%", "%"+*req.Keyword+"%")
} }
// 状态筛选 // 状态筛选

@ -26,6 +26,9 @@ type Auth interface {
// 资源所有者权限 // 资源所有者权限
PermWithOwner(permissionID, ownerKey string) func(*vigo.X) error 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 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 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 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) 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) orgID := getOrgID(x)
ok, err := a.CheckPermission(x.Context(), models.CheckPermRequest{ ok, err := a.CheckPermission(x.Context(), userID, orgID, permissionID, "")
UserID: userID,
OrgID: orgID,
PermissionID: permissionID,
})
if err != nil { if err != nil {
return err return err
} }
@ -265,11 +264,7 @@ func (a *appAuth) PermWithOwner(permissionID, ownerKey string) func(*vigo.X) err
orgID := getOrgID(x) orgID := getOrgID(x)
// 先检查是否有权限 // 先检查是否有权限
ok, err := a.CheckPermission(x.Context(), models.CheckPermRequest{ ok, err := a.CheckPermission(x.Context(), userID, orgID, permissionID, "")
UserID: userID,
OrgID: orgID,
PermissionID: permissionID,
})
if err != nil { if err != nil {
return err 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 { func (a *appAuth) PermAny(permissionIDs []string) func(*vigo.X) error {
return func(x *vigo.X) error { return func(x *vigo.X) error {
userID := getUserID(x) userID := getUserID(x)
@ -306,11 +332,7 @@ func (a *appAuth) PermAny(permissionIDs []string) func(*vigo.X) error {
orgID := getOrgID(x) orgID := getOrgID(x)
for _, permID := range permissionIDs { for _, permID := range permissionIDs {
ok, err := a.CheckPermission(x.Context(), models.CheckPermRequest{ ok, err := a.CheckPermission(x.Context(), userID, orgID, permID, "")
UserID: userID,
OrgID: orgID,
PermissionID: permID,
})
if err != nil { if err != nil {
return err return err
} }
@ -333,11 +355,7 @@ func (a *appAuth) PermAll(permissionIDs []string) func(*vigo.X) error {
orgID := getOrgID(x) orgID := getOrgID(x)
for _, permID := range permissionIDs { for _, permID := range permissionIDs {
ok, err := a.CheckPermission(x.Context(), models.CheckPermRequest{ ok, err := a.CheckPermission(x.Context(), userID, orgID, permID, "")
UserID: userID,
OrgID: orgID,
PermissionID: permID,
})
if err != nil { if err != nil {
return err 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 var role models.Role
query := cfg.DB().Where("code = ?", req.RoleCode) query := cfg.DB().Where("code = ?", roleCode)
if req.OrgID != "" { if orgID != "" {
query = query.Where("org_id = ?", req.OrgID) query = query.Where("org_id = ?", orgID)
} else { } else {
query = query.Where("org_id = ''") query = query.Where("org_id = ''")
} }
if err := query.First(&role).Error; err != nil { if err := query.First(&role).Error; err != nil {
// 如果指定了 OrgID 但没找到,尝试查找全局角色 // 如果指定了 OrgID 但没找到,尝试查找全局角色
if req.OrgID != "" { if orgID != "" {
query = cfg.DB().Where("code = ? AND org_id = ''", req.RoleCode) query = cfg.DB().Where("code = ? AND org_id = ''", roleCode)
if err := query.First(&role).Error; err != nil { 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 { } else {
return fmt.Errorf("role not found: %s", req.RoleCode) return fmt.Errorf("role not found: %s", roleCode)
} }
} }
// 检查是否已存在 // 检查是否已存在
var count int64 var count int64
cfg.DB().Model(&models.UserRole{}). 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) Count(&count)
if count > 0 { if count > 0 {
@ -385,10 +403,10 @@ func (a *appAuth) GrantRole(ctx context.Context, req models.GrantRoleRequest) er
} }
userRole := models.UserRole{ userRole := models.UserRole{
UserID: req.UserID, UserID: userID,
OrgID: req.OrgID, OrgID: orgID,
RoleID: role.ID, RoleID: role.ID,
ExpireAt: req.ExpireAt, ExpireAt: nil, // 默认不过期
} }
return cfg.DB().Create(&userRole).Error 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 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 var perm models.Permission
if err := cfg.DB().Where("id = ?", req.PermissionID).First(&perm).Error; err != nil { if err := cfg.DB().Where("id = ?", permissionID).First(&perm).Error; err != nil {
return fmt.Errorf("permission not found: %s", req.PermissionID) return fmt.Errorf("permission not found: %s", permissionID)
} }
// 检查是否已存在 // 检查是否已存在
var existing models.UserPermission var existing models.UserPermission
err := cfg.DB().Where("user_id = ? AND org_id = ? AND permission_id = ? AND resource_id = ?", 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 First(&existing).Error
if err == nil { if err == nil {
// 更新过期时间 // 已存在
existing.ExpireAt = req.ExpireAt return nil
return cfg.DB().Save(&existing).Error
} }
userPerm := models.UserPermission{ userPerm := models.UserPermission{
UserID: req.UserID, UserID: userID,
OrgID: req.OrgID, OrgID: orgID,
PermissionID: req.PermissionID, PermissionID: permissionID,
ResourceID: req.ResourceID, ResourceID: resourceID,
ExpireAt: req.ExpireAt, ExpireAt: nil, // 默认不过期
GrantedBy: req.GrantedBy, GrantedBy: "", // 默认空
} }
return cfg.DB().Create(&userPerm).Error return cfg.DB().Create(&userPerm).Error
} }
func (a *appAuth) RevokeResourcePerm(ctx context.Context, userID, orgID, permissionID, resourceID string) 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 = ?", return cfg.DB().Where("user_id = ? AND org_id = ? AND permission_id = ? AND resource_id = ?",
userID, orgID, permissionID, resourceID). userID, orgID, permissionID, resourceID).
Delete(&models.UserPermission{}).Error 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) { func (a *appAuth) CheckPermission(ctx context.Context, userID, orgID, permissionID, resourceID string) (bool, error) {
// 1. 检查用户是否有该权限的角色 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 var roleIDs []string
if err := cfg.DB().Model(&models.UserRole{}). roleQuery := cfg.DB().Model(&models.UserRole{}).
Where("user_id = ? AND org_id = ? AND (expire_at IS NULL OR expire_at > ?)", Where("user_id = ? AND (expire_at IS NULL OR expire_at > ?)", userID, time.Now())
req.UserID, req.OrgID, time.Now()).
Pluck("role_id", &roleIDs).Error; err != nil { 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 return false, err
} }
fmt.Printf("[DEBUG] CheckPermission: roleIDs=%v\n", roleIDs)
if len(roleIDs) > 0 { 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 var count int64
if err := cfg.DB().Model(&models.RolePermission{}). 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 { Count(&count).Error; err != nil {
return false, err return false, err
} }
fmt.Printf("[DEBUG] CheckPermission: role perm count=%d checked=%v\n", count, permsToCheck)
if count > 0 { if count > 0 {
return true, nil return true, nil
} }
} }
// 2. 检查用户是否有特定的资源权限 // 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 var userPermCount int64
query := cfg.DB().Model(&models.UserPermission{}). query := cfg.DB().Model(&models.UserPermission{}).
Where("user_id = ? AND org_id = ? AND permission_id = ? AND (expire_at IS NULL OR expire_at > ?)", Where("user_id = ? AND org_id = ? AND permission_id IN ? AND (expire_at IS NULL OR expire_at > ?)",
req.UserID, req.OrgID, req.PermissionID, time.Now()) userID, orgID, permsToCheck, time.Now())
if req.ResourceID != "" { if resourceID != "" {
query = query.Where("resource_id = ? OR resource_id = '*'", req.ResourceID) query = query.Where("resource_id = ? OR resource_id = '*'", resourceID)
} }
if err := query.Count(&userPermCount).Error; err != nil { if err := query.Count(&userPermCount).Error; err != nil {

@ -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
}
// ...
}
```

@ -69,7 +69,7 @@ type UserRole struct {
UserID string `json:"user_id" gorm:"index;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"` OrgID string `json:"org_id" gorm:"index;size:36" desc:"组织ID"`
RoleID string `json:"role_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:"过期时间(可选)"` ExpireAt *time.Time `json:"expire_at" desc:"过期时间(可选)"`
} }
func (UserRole) TableName() string { func (UserRole) TableName() string {
@ -83,7 +83,7 @@ type UserPermission struct {
OrgID string `json:"org_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"` PermissionID string `json:"permission_id" gorm:"index;size:100" desc:"权限ID"`
ResourceID string `json:"resource_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:"过期时间(可选)"` ExpireAt *time.Time `json:"expire_at" desc:"过期时间(可选)"`
GrantedBy string `json:"granted_by" gorm:"size:36" desc:"授权人ID"` GrantedBy string `json:"granted_by" gorm:"size:36" desc:"授权人ID"`
} }
@ -111,7 +111,7 @@ type GrantRoleRequest struct {
UserID string `json:"user_id" desc:"用户ID"` UserID string `json:"user_id" desc:"用户ID"`
OrgID string `json:"org_id" desc:"组织ID"` OrgID string `json:"org_id" desc:"组织ID"`
RoleCode string `json:"role_code" desc:"角色代码"` RoleCode string `json:"role_code" desc:"角色代码"`
ExpireAt time.Time `json:"expire_at" desc:"过期时间(可选)"` ExpireAt *time.Time `json:"expire_at" desc:"过期时间(可选)"`
} }
// GrantResourcePermRequest 授予资源权限请求 // GrantResourcePermRequest 授予资源权限请求
@ -120,7 +120,7 @@ type GrantResourcePermRequest struct {
OrgID string `json:"org_id" desc:"组织ID"` OrgID string `json:"org_id" desc:"组织ID"`
PermissionID string `json:"permission_id" desc:"权限ID"` PermissionID string `json:"permission_id" desc:"权限ID"`
ResourceID string `json:"resource_id" desc:"资源实例ID* 表示所有"` ResourceID string `json:"resource_id" desc:"资源实例ID* 表示所有"`
ExpireAt time.Time `json:"expire_at" desc:"过期时间(可选)"` ExpireAt *time.Time `json:"expire_at" desc:"过期时间(可选)"`
GrantedBy string `json:"granted_by" desc:"授权人ID"` GrantedBy string `json:"granted_by" desc:"授权人ID"`
} }

@ -17,8 +17,8 @@ type User struct {
Password string `json:"-" gorm:"size:255"` // bcrypt hash Password string `json:"-" gorm:"size:255"` // bcrypt hash
Nickname string `json:"nickname" gorm:"size:50"` Nickname string `json:"nickname" gorm:"size:50"`
Avatar string `json:"avatar" gorm:"size:500"` Avatar string `json:"avatar" gorm:"size:500"`
Email string `json:"email" gorm:"uniqueIndex;size:100"` Email *string `json:"email" gorm:"uniqueIndex;size:100"`
Phone string `json:"phone" gorm:"uniqueIndex;size:20"` Phone *string `json:"phone" gorm:"uniqueIndex;size:20"`
Status int `json:"status" gorm:"default:1"` // 0:禁用 1:正常 2:未激活 Status int `json:"status" gorm:"default:1"` // 0:禁用 1:正常 2:未激活
EmailVerified bool `json:"email_verified" gorm:"default:false"` EmailVerified bool `json:"email_verified" gorm:"default:false"`
PhoneVerified bool `json:"phone_verified" gorm:"default:false"` PhoneVerified bool `json:"phone_verified" gorm:"default:false"`

@ -0,0 +1,10 @@
#! /bin/sh
#
# start.sh
# Copyright (C) 2026 veypi <i@veypi.com>
#
# Distributed under terms of the MIT license.
#
go run cli/main.go db drop -y

@ -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!"
Loading…
Cancel
Save