feat(auth): Improve permission system and role management

- Add Scope and Level fields to UserPermissionInfo response
    - Include role-based permissions in /auth/me endpoint
    - Implement diff-based permission sync for role initialization
    - Remove Scope field from Role model queries (create, patch, grant)
    - Add permission-based route guards in UI (perm: '*')
    - Fix register to return error on default role assignment failure
    - Fix token refresh to only fetch user when token exists
    - Fix code formatting in api/init.go (remove extra spaces)
master
veypi 3 weeks ago
parent e83444df4c
commit 92156dcd53

@ -16,8 +16,10 @@ import (
// UserPermissionInfo 用户权限信息 // UserPermissionInfo 用户权限信息
type UserPermissionInfo struct { type UserPermissionInfo struct {
Scope string `json:"scope"`
PermissionID string `json:"permission_id"` PermissionID string `json:"permission_id"`
ResourceID string `json:"resource_id"` ResourceID string `json:"resource_id"`
Level int `json:"level"`
} }
// UserInfoWithPerms 带权限的用户信息 // UserInfoWithPerms 带权限的用户信息
@ -45,16 +47,31 @@ func me(x *vigo.X) (*UserInfoWithPerms, error) {
// 获取用户权限列表 // 获取用户权限列表
var perms []models.Permission var perms []models.Permission
// 1. 获取用户直接拥有的权限
if err := cfg.DB().Where("user_id = ?", userID).Find(&perms).Error; err != nil { if err := cfg.DB().Where("user_id = ?", userID).Find(&perms).Error; err != nil {
return nil, vigo.ErrInternalServer.WithError(err) return nil, vigo.ErrInternalServer.WithError(err)
} }
// 2. 获取用户角色的权限
var roleIDs []string
cfg.DB().Model(&models.UserRole{}).Where("user_id = ?", userID).Pluck("role_id", &roleIDs)
if len(roleIDs) > 0 {
var rolePerms []models.Permission
if err := cfg.DB().Where("role_id IN ?", roleIDs).Find(&rolePerms).Error; err != nil {
return nil, vigo.ErrInternalServer.WithError(err)
}
perms = append(perms, rolePerms...)
}
// 转换权限格式 // 转换权限格式
userPerms := make([]UserPermissionInfo, 0, len(perms)) userPerms := make([]UserPermissionInfo, 0, len(perms))
for _, p := range perms { for _, p := range perms {
userPerms = append(userPerms, UserPermissionInfo{ userPerms = append(userPerms, UserPermissionInfo{
Scope: p.Scope,
PermissionID: p.PermissionID, PermissionID: p.PermissionID,
ResourceID: "", // ResourceID is no longer a separate field in Permission model, using PermissionID hierarchy ResourceID: "", // ResourceID is no longer a separate field in Permission model, using PermissionID hierarchy
Level: p.Level,
}) })
} }

@ -155,7 +155,7 @@ func register(x *vigo.X, req *RegisterRequest) (*AuthResponse, error) {
// 记录错误但允许注册继续,或者回滚 // 记录错误但允许注册继续,或者回滚
// 这里简单处理,继续流程,用户可能需要管理员手动授权 // 这里简单处理,继续流程,用户可能需要管理员手动授权
// 或者返回错误 // 或者返回错误
// return nil, vigo.ErrInternalServer.WithError(err) return nil, vigo.ErrInternalServer.WithError(err)
} }
// 生成token // 生成token

@ -24,21 +24,21 @@ var Router = vigo.NewRouter()
// PublicInfoResponse 公开信息响应 // PublicInfoResponse 公开信息响应
// 不需要登录即可访问,用于前端初始化 // 不需要登录即可访问,用于前端初始化
type PublicInfoResponse struct { type PublicInfoResponse struct {
AppName string `json:"app_name"` AppName string `json:"app_name"`
AppID string `json:"app_id"` AppID string `json:"app_id"`
OAuthProviders []OAuthProviderInfo `json:"oauth_providers"` OAuthProviders []OAuthProviderInfo `json:"oauth_providers"`
LoginMethods []string `json:"login_methods"` LoginMethods []string `json:"login_methods"`
PasswordFields []string `json:"password_fields"` PasswordFields []string `json:"password_fields"`
RegRequireEmail bool `json:"reg_require_email"` RegRequireEmail bool `json:"reg_require_email"`
RegRequirePhone bool `json:"reg_require_phone"` RegRequirePhone bool `json:"reg_require_phone"`
CaptchaEnabled bool `json:"captcha_enabled"` CaptchaEnabled bool `json:"captcha_enabled"`
EmailEnabled bool `json:"email_enabled"` EmailEnabled bool `json:"email_enabled"`
SMSEnabled bool `json:"sms_enabled"` SMSEnabled bool `json:"sms_enabled"`
} }
// OAuthProviderInfo OAuth提供商公开信息 // OAuthProviderInfo OAuth提供商公开信息
type OAuthProviderInfo struct { type OAuthProviderInfo struct {
Code string `json:"code"` Code string `json:"code"`
Name string `json:"name"` Name string `json:"name"`
Icon string `json:"icon"` Icon string `json:"icon"`
@ -72,7 +72,7 @@ func init() {
} }
// getPublicInfo 获取公开配置信息 // getPublicInfo 获取公开配置信息
func getPublicInfo(x *vigo.X) (*PublicInfoResponse, error) { func getPublicInfo(x *vigo.X) (*PublicInfoResponse, error) {
resp := &PublicInfoResponse{} resp := &PublicInfoResponse{}
// 应用配置 // 应用配置

@ -16,7 +16,7 @@ type CreateReq struct {
func create(x *vigo.X, req *CreateReq) (*models.Role, error) { func create(x *vigo.X, req *CreateReq) (*models.Role, error) {
// Check if role code already exists // Check if role code already exists
var count int64 var count int64
if err := cfg.DB().Model(&models.Role{}).Where("code = ? AND scope = ?", req.Code, req.Scope).Count(&count).Error; err != nil { if err := cfg.DB().Model(&models.Role{}).Where("code = ?", req.Code).Count(&count).Error; err != nil {
return nil, vigo.ErrInternalServer.WithError(err) return nil, vigo.ErrInternalServer.WithError(err)
} }
if count > 0 { if count > 0 {
@ -24,7 +24,6 @@ func create(x *vigo.X, req *CreateReq) (*models.Role, error) {
} }
role := &models.Role{ role := &models.Role{
Scope: req.Scope,
Code: req.Code, Code: req.Code,
Name: req.Name, Name: req.Name,
IsSystem: false, // Default to false for user created roles IsSystem: false, // Default to false for user created roles

@ -27,28 +27,20 @@ func patch(x *vigo.X, req *PatchReq) (*models.Role, error) {
updates := map[string]interface{}{} updates := map[string]interface{}{}
// Check if code or scope is being updated // Check if code is being updated
if req.Code != nil || req.Scope != nil { if req.Code != nil {
newCode := role.Code newCode := *req.Code
if req.Code != nil {
newCode = *req.Code
}
newScope := role.Scope
if req.Scope != nil {
newScope = *req.Scope
}
// Check for uniqueness if changed // Check for uniqueness if changed
if newCode != role.Code || newScope != role.Scope { if newCode != role.Code {
var count int64 var count int64
if err := cfg.DB().Model(&models.Role{}).Where("code = ? AND scope = ? AND id != ?", newCode, newScope, role.ID).Count(&count).Error; err != nil { if err := cfg.DB().Model(&models.Role{}).Where("code = ? AND id != ?", newCode, role.ID).Count(&count).Error; err != nil {
return nil, vigo.ErrInternalServer.WithError(err) return nil, vigo.ErrInternalServer.WithError(err)
} }
if count > 0 { if count > 0 {
return nil, vigo.ErrAlreadyExists.WithArgs("Role Code in Scope") return nil, vigo.ErrAlreadyExists.WithArgs("Role Code")
} }
updates["code"] = newCode updates["code"] = newCode
updates["scope"] = newScope
} }
} }

@ -21,6 +21,7 @@ func getPermissions(x *vigo.X, req *GetPermissionsReq) ([]models.Permission, err
type UpdatePermissionsReq struct { type UpdatePermissionsReq struct {
RoleID string `src:"path@id" desc:"Role ID"` RoleID string `src:"path@id" desc:"Role ID"`
Scope string `json:"scope" src:"query" default:"vb" desc:"Permission Scope"`
PermissionIDs []string `json:"permission_ids" src:"json" desc:"List of Permission IDs"` PermissionIDs []string `json:"permission_ids" src:"json" desc:"List of Permission IDs"`
} }
@ -35,8 +36,8 @@ func updatePermissions(x *vigo.X, req *UpdatePermissionsReq) error {
} }
return cfg.DB().Transaction(func(tx *gorm.DB) error { return cfg.DB().Transaction(func(tx *gorm.DB) error {
// Delete existing permissions // Delete existing permissions for this role AND scope
if err := tx.Where("role_id = ?", req.RoleID).Delete(&models.Permission{}).Error; err != nil { if err := tx.Where("role_id = ? AND scope = ?", req.RoleID, req.Scope).Delete(&models.Permission{}).Error; err != nil {
return err return err
} }
@ -45,7 +46,7 @@ func updatePermissions(x *vigo.X, req *UpdatePermissionsReq) error {
permissions := make([]models.Permission, 0, len(req.PermissionIDs)) permissions := make([]models.Permission, 0, len(req.PermissionIDs))
for _, pid := range req.PermissionIDs { for _, pid := range req.PermissionIDs {
permissions = append(permissions, models.Permission{ permissions = append(permissions, models.Permission{
Scope: role.Scope, Scope: req.Scope,
RoleID: &req.RoleID, RoleID: &req.RoleID,
PermissionID: pid, PermissionID: pid,
Level: 7, // Default to Admin level to ensure it passes checks Level: 7, // Default to Admin level to ensure it passes checks

@ -228,7 +228,7 @@ func (a *appAuth) Revoke(ctx context.Context, userID, permissionID string) error
// GrantRole 授予角色 // GrantRole 授予角色
func (a *appAuth) GrantRole(ctx context.Context, userID, roleCode string) error { func (a *appAuth) GrantRole(ctx context.Context, userID, roleCode string) error {
var role models.Role var role models.Role
if err := cfg.DB().Where("code = ? AND scope = ?", roleCode, a.scope).First(&role).Error; err != nil { if err := cfg.DB().Where("code = ?", roleCode).First(&role).Error; err != nil {
return err return err
} }
@ -250,7 +250,7 @@ func (a *appAuth) GrantRole(ctx context.Context, userID, roleCode string) error
// RevokeRole 撤销角色 // RevokeRole 撤销角色
func (a *appAuth) RevokeRole(ctx context.Context, userID, roleCode string) error { func (a *appAuth) RevokeRole(ctx context.Context, userID, roleCode string) error {
var role models.Role var role models.Role
if err := cfg.DB().Where("code = ? AND scope = ?", roleCode, a.scope).First(&role).Error; err != nil { if err := cfg.DB().Where("code = ?", roleCode).First(&role).Error; err != nil {
return err return err
} }
@ -380,11 +380,10 @@ func (a *appAuth) init() error {
for code, def := range a.roleDefs { for code, def := range a.roleDefs {
// 1. 确保角色存在 // 1. 确保角色存在
var role models.Role var role models.Role
err := db.Where("code = ? AND scope = ?", code, a.scope).First(&role).Error err := db.Where("code = ?", code).First(&role).Error
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
role = models.Role{ role = models.Role{
Scope: a.scope,
Code: code, Code: code,
Name: def.name, Name: def.name,
IsSystem: true, IsSystem: true,
@ -398,13 +397,19 @@ func (a *appAuth) init() error {
} }
} }
// 2. 同步角色权限 // 2. 同步角色权限 (Diff Sync)
// 简单起见,先清除旧的,再插入新的(生产环境可能需要更精细的 diff // ID格式: scope:roleCode:permissionID:level
// 但 Permission 表是 mixed 的,不能随便删。 var targetIDs []string
// 这里我们需要根据 RoleID 删除该角色的所有权限
if err := db.Where("role_id = ?", role.ID).Delete(&models.Permission{}).Error; err != nil { // 获取该角色当前scope下的所有权限ID用于快速比对
var existingIDs []string
if err := db.Model(&models.Permission{}).Where("role_id = ? AND scope = ?", role.ID, a.scope).Pluck("id", &existingIDs).Error; err != nil {
return err return err
} }
existingMap := make(map[string]bool)
for _, id := range existingIDs {
existingMap[id] = true
}
for _, policy := range def.policies { for _, policy := range def.policies {
// policy 格式: "permissionID:level" // policy 格式: "permissionID:level"
@ -412,19 +417,41 @@ func (a *appAuth) init() error {
if len(parts) < 2 { if len(parts) < 2 {
continue continue
} }
// 最后一个部分是 level前面是 permissionID
levelStr := parts[len(parts)-1] levelStr := parts[len(parts)-1]
permID := strings.Join(parts[:len(parts)-1], ":") permID := strings.Join(parts[:len(parts)-1], ":")
var level int var level int
fmt.Sscanf(levelStr, "%d", &level) fmt.Sscanf(levelStr, "%d", &level)
perm := models.Permission{ // 生成确定性 ID
Scope: a.scope, id := fmt.Sprintf("%s:%s:%s:%d", a.scope, role.Code, permID, level)
RoleID: &role.ID, targetIDs = append(targetIDs, id)
PermissionID: permID,
Level: level, // 检查是否存在
if !existingMap[id] {
// 不存在,创建新权限
newPerm := models.Permission{
Scope: a.scope,
RoleID: &role.ID,
PermissionID: permID,
Level: level,
}
newPerm.ID = id
if err := db.Create(&newPerm).Error; err != nil {
return err
}
}
}
// 3. 清理不再需要的权限
if len(targetIDs) > 0 {
if err := db.Unscoped().Where("role_id = ? AND scope = ? AND id NOT IN ?", role.ID, a.scope, targetIDs).
Delete(&models.Permission{}).Error; err != nil {
return err
} }
if err := db.Create(&perm).Error; err != nil { } else {
// 如果没有策略,删除所有
if err := db.Unscoped().Where("role_id = ? AND scope = ?", role.ID, a.scope).
Delete(&models.Permission{}).Error; err != nil {
return err return err
} }
} }
@ -446,19 +473,19 @@ func (a *appAuth) getUserPermissions(userID string) ([]models.Permission, error)
// 2. 角色权限 // 2. 角色权限
// 查用户角色 // 查用户角色
// UserRole 关联的是 RoleIDRole 表有 Scope // UserRole 关联的是 RoleID
// 我们需要关联查询: UserRole -> Role (where scope=a.scope) // Role 表已经没有 Scope所以这里查出用户拥有的所有角色ID
var roleIDs []string var roleIDs []string
if err := db.Table("user_roles"). if err := db.Table("user_roles").
Joins("JOIN roles ON roles.id = user_roles.role_id"). Where("user_id = ?", userID).
Where("user_roles.user_id = ? AND roles.scope = ?", userID, a.scope). Pluck("role_id", &roleIDs).Error; err != nil {
Pluck("user_roles.role_id", &roleIDs).Error; err != nil {
return nil, err return nil, err
} }
if len(roleIDs) > 0 { if len(roleIDs) > 0 {
var rolePerms []models.Permission var rolePerms []models.Permission
if err := db.Where("role_id IN ?", roleIDs).Find(&rolePerms).Error; err != nil { // 查询这些角色在当前 scope 下拥有的权限
if err := db.Where("role_id IN ? AND scope = ?", roleIDs, a.scope).Find(&rolePerms).Error; err != nil {
return nil, err return nil, err
} }
perms = append(perms, rolePerms...) perms = append(perms, rolePerms...)

@ -48,13 +48,12 @@ export default async ($env) => {
} }
} }
// Role Check // Permission Check
if (roles && roles.length > 0) { if (to.meta.perm) {
const hasRole = roles.some(role => vbase.hasRole(role)); if (!vbase.PermAdmin(to.meta.perm)) {
console.log(roles, hasRole, vbase.user) console.warn('Access denied: requires permission', to.meta.perm);
if (!hasRole) { $env.$router.push('/403');
// $env.$router.push('/403'); return false;
// return false;
} }
} }
} }

@ -11,7 +11,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--spacing-sm); gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-xl); padding: var(--spacing-xs) var(--spacing-sm);
border-radius: var(--radius-xl); border-radius: var(--radius-xl);
transition: all var(--transition-base); transition: all var(--transition-base);
cursor: pointer; cursor: pointer;
@ -123,7 +123,7 @@
</style> </style>
<body> <body>
<div class="header-user ml-auto" v-if="user.id" @click="toggleDropdown"> <div class="header-user" v-if="user.id" @click="toggleDropdown">
<img :src="user.avatar" class="user-avatar" alt="用户头像"> <img :src="user.avatar" class="user-avatar" alt="用户头像">
<span class="user-name">{{ user.nickname || user.username }}</span> <span class="user-name">{{ user.nickname || user.username }}</span>
<span class="dropdown-arrow" :class="{open: dropdownOpen}"></span> <span class="dropdown-arrow" :class="{open: dropdownOpen}"></span>
@ -173,7 +173,6 @@
</script> </script>
<script> <script>
console.log(user.avatar)
// 点击外部区域关闭下拉菜单 // 点击外部区域关闭下拉菜单
document.addEventListener('click', (event) => { document.addEventListener('click', (event) => {
if (!$node.contains(event.target)) { if (!$node.contains(event.target)) {

@ -12,10 +12,10 @@ const routes = [
}, },
// Role Management // Role Management
{ path: '/roles', component: '/page/sys/role/index.html', layout: 'default', meta: { auth: true } }, { path: '/roles', component: '/page/sys/role/index.html', layout: 'default', meta: { auth: true, perm: '*' } },
// Settings Management // Settings Management
{ path: '/settings', component: '/page/sys/settings.html', layout: 'default', meta: { auth: true } }, { path: '/settings', component: '/page/sys/settings.html', layout: 'default', meta: { auth: true, perm: '*' } },
// User System // User System
{ path: '/profile', component: '/page/user/profile.html', layout: 'default', meta: { auth: true } }, { path: '/profile', component: '/page/user/profile.html', layout: 'default', meta: { auth: true } },
@ -23,12 +23,12 @@ const routes = [
path: '/users', path: '/users',
component: '/page/sys/user/index.html', component: '/page/sys/user/index.html',
layout: 'default', layout: 'default',
meta: { auth: true, roles: ['admin'] } meta: { auth: true, perm: '*' }
}, },
// OAuth Management // OAuth Management
{ path: '/oauth/apps', component: '/page/sys/oauth/index.html', layout: 'default', meta: { auth: true } }, { path: '/oauth/apps', component: '/page/sys/oauth/index.html', layout: 'default', meta: { auth: true, perm: '*' } },
{ path: '/oauth/providers', component: '/page/sys/oauth/providers.html', layout: 'default', meta: { auth: true } }, { path: '/oauth/providers', component: '/page/sys/oauth/providers.html', layout: 'default', meta: { auth: true, perm: '*' } },
// Errors // Errors
{ path: '/403', component: '/page/403.html' }, { path: '/403', component: '/page/403.html' },

@ -29,6 +29,9 @@ class VBase {
this._token = localStorage.getItem(this.tokenKey) || ''; this._token = localStorage.getItem(this.tokenKey) || '';
this._refreshToken = localStorage.getItem(this.refreshTokenKey) || ''; this._refreshToken = localStorage.getItem(this.refreshTokenKey) || '';
this._user = JSON.parse(localStorage.getItem(this.userKey) || 'null'); this._user = JSON.parse(localStorage.getItem(this.userKey) || 'null');
if (this._token) {
this.fetchUser()
}
} }
// ========== Getters ========== // ========== Getters ==========
@ -90,7 +93,8 @@ class VBase {
if (data.access_token) { if (data.access_token) {
this.token = data.access_token; this.token = data.access_token;
if (data.refresh_token) this.refreshToken = data.refresh_token; if (data.refresh_token) this.refreshToken = data.refresh_token;
this.user = data.user; this.user = data.user
this.fetchUser()
return true; return true;
} }
return false; return false;
@ -111,6 +115,7 @@ class VBase {
this.token = data.access_token || data.token; this.token = data.access_token || data.token;
if (data.refresh_token) this.refreshToken = data.refresh_token; if (data.refresh_token) this.refreshToken = data.refresh_token;
if (data.user) this.user = data.user; if (data.user) this.user = data.user;
this.fetchUser()
} }
return data; return data;
@ -133,7 +138,7 @@ class VBase {
if (data.access_token || data.token) { if (data.access_token || data.token) {
this.token = data.access_token || data.token; this.token = data.access_token || data.token;
if (data.refresh_token) this.refreshToken = data.refresh_token; if (data.refresh_token) this.refreshToken = data.refresh_token;
if (data.user) this.user = data.user; this.fetchUser()
} }
return data; return data;
@ -157,6 +162,7 @@ class VBase {
this.token = data.access_token || data.token; this.token = data.access_token || data.token;
if (data.refresh_token) this.refreshToken = data.refresh_token; if (data.refresh_token) this.refreshToken = data.refresh_token;
if (data.user) this.user = data.user; if (data.user) this.user = data.user;
this.fetchUser()
} }
return data; return data;

Loading…
Cancel
Save