fix(auth):修复管理员权限问题

This commit is contained in:
chengqian committed 2026-06-02 18:33:48 +08:00
1 parent 9601678d46
commit fa70d8790c
11 files changed
+180 -80

No files matched your search

+2
View File
@@ -47,6 +47,8 @@ type SensitiveLogListReq struct {
PageSize int `json:"page_size" d:"20"`
UserID int `json:"user_id" dc:"用户ID"`
ResourceType string `json:"resource_type" dc:"资源类型"`
StartDate string `json:"start_date" dc:"开始日期 YYYY-MM-DD"`
EndDate string `json:"end_date" dc:"结束日期 YYYY-MM-DD"`
}
type SensitiveLogListRes struct {
+24 -31
View File
@@ -20,36 +20,12 @@ import (
func (s *sAdmin) ListUsers(ctx context.Context, req *v1.AdminUserListReq) (*v1.AdminUserListRes, error) {
page, pageSize := common.NormalizePagination(req.Page, req.PageSize)
m := dao.SysAdminUsers.Ctx(ctx)
if req.Keyword != "" {
keyword := "%" + strings.TrimSpace(req.Keyword) + "%"
m = m.Where("username LIKE ? OR email LIKE ?", keyword, keyword)
}
if req.Role != "" {
m = m.Where("role", req.Role)
}
if req.Status != "" {
m = m.Where("status", req.Status)
}
total, err := m.Count()
total, err := buildUserFilters(dao.SysAdminUsers.Ctx(ctx), req.Keyword, req.Role, req.Status).Count()
if err != nil {
return nil, err
}
// Rebuild model for data query (Count() modifies internal state)
m = dao.SysAdminUsers.Ctx(ctx)
if req.Keyword != "" {
keyword := "%" + strings.TrimSpace(req.Keyword) + "%"
m = m.Where("username LIKE ? OR email LIKE ?", keyword, keyword)
}
if req.Role != "" {
m = m.Where("role", req.Role)
}
if req.Status != "" {
m = m.Where("status", req.Status)
}
m := buildUserFilters(dao.SysAdminUsers.Ctx(ctx), req.Keyword, req.Role, req.Status)
var users []struct {
Id int64 `json:"id"`
@@ -156,6 +132,22 @@ func (s *sAdmin) CreateUser(ctx context.Context, req *v1.AdminUserCreateReq) (*v
// UpdateUser updates an admin user.
func (s *sAdmin) UpdateUser(ctx context.Context, req *v1.AdminUserUpdateReq) (*v1.AdminUserUpdateRes, error) {
// Prevent modification of super_admin accounts
var targetUser *struct {
Role string `json:"role"`
}
err := dao.SysAdminUsers.Ctx(ctx).
Where("id", req.Id).Scan(&targetUser)
if err = common.IgnoreScanNoRows(err); err != nil {
return nil, err
}
if targetUser == nil {
return nil, common.NewNotFoundError("管理员")
}
if targetUser.Role == "super_admin" {
return nil, common.NewBadRequestError("不能修改超级管理员信息")
}
data := do.SysAdminUsers{}
if req.DisplayName != nil {
data.DisplayName = *req.DisplayName
@@ -180,7 +172,7 @@ func (s *sAdmin) UpdateUser(ctx context.Context, req *v1.AdminUserUpdateReq) (*v
data.Role = *req.Role
}
_, err := dao.SysAdminUsers.Ctx(ctx).Where("id", req.Id).Update(data)
_, err = dao.SysAdminUsers.Ctx(ctx).Where("id", req.Id).Update(data)
if err != nil {
return nil, err
}
@@ -212,15 +204,16 @@ func (s *sAdmin) DeleteUser(ctx context.Context, req *v1.AdminUserDeleteReq) (*v
return nil, common.NewBadRequestError("不能删除超级管理员")
}
// Revoke all sessions
common.RevokeAllSessions(ctx, "admin", req.Id)
// Delete user
// Delete user first, then revoke sessions to avoid leaving the user
// in a state where sessions are gone but the user still exists.
_, err = dao.SysAdminUsers.Ctx(ctx).Where("id", req.Id).Delete()
if err != nil {
return nil, err
}
// Revoke all sessions
common.RevokeAllSessions(ctx, "admin", req.Id)
return nil, nil
}
+31 -3
View File
@@ -36,9 +36,9 @@ var validAuditLevels = map[string]bool{
AuditLevelNone: true,
}
// GetAuditConfig retrieves the global audit level from sys_options.
// GetGlobalAuditConfig retrieves the global audit level from sys_options.
// Package-level wrapper for use by tenant package.
func GetAuditConfig(ctx context.Context) (string, error) {
func GetGlobalAuditConfig(ctx context.Context) (string, error) {
return common.GetAuditLevel(ctx), nil
}
@@ -88,6 +88,8 @@ func (s *sAdmin) UpdateAuditConfig(ctx context.Context, req *v1.AuditConfigUpdat
}
// queryPage 使用 GoFrame ORM 分页查询,返回 []map[string]any。
// 注意:该函数使用 g.DB().Model(table) 直接操作动态表名而非 DAO,
// 适用于通用分页工具场景(多表共用)。固定表查询应优先使用对应的 DAO。
// 分开调用 Count 和 All 避免 ScanAndCount 对 map[string]any 的 bug。
func queryPage(ctx context.Context, table, fields, where, orderBy string, page, pageSize int, args ...any) ([]map[string]any, int, error) {
m := g.DB().Ctx(ctx).Model(table).Safe()
@@ -198,6 +200,14 @@ func (s *sAdmin) ListSensitiveAccessLogs(ctx context.Context, req *v1.SensitiveL
conditions = append(conditions, "resource_type = ?")
args = append(args, req.ResourceType)
}
if req.StartDate != "" {
conditions = append(conditions, "created_at >= ?")
args = append(args, req.StartDate+" 00:00:00")
}
if req.EndDate != "" {
conditions = append(conditions, "created_at <= ?")
args = append(args, req.EndDate+" 23:59:59")
}
where := strings.Join(conditions, " AND ")
items, total, err := queryPage(ctx,
@@ -311,6 +321,9 @@ func (s *sAdmin) ListRequestAuditLogs(ctx context.Context, req *v1.RequestAuditL
total = countResult[0]["total"].Int()
}
// NOTE: fromClause and whereClause are constructed from hardcoded strings
// and parameterized conditions (using ? placeholders). No user input is
// directly interpolated into the SQL template.
dataSQL := fmt.Sprintf(
`SELECT a.id, a.tenant_id, COALESCE(tn.name, '') AS tenant_name, a.user_id, COALESCE(t.username, '') AS username, a.project_id, COALESCE(p.name, '') AS project_name, a.api_key_id, COALESCE(ak.name, '') AS api_key_name, a.request_id, a.method, a.path, a.query_params, a.status_code, a.client_ip, a.user_agent, a.latency_ms, a.first_token_ms, a.created_at, a.updated_at, a.audit_level
FROM %s%s ORDER BY a.created_at DESC LIMIT ? OFFSET ?`,
@@ -417,7 +430,22 @@ func (s *sAdmin) ExportOperationLogs(ctx context.Context, req *v1.OperationLogEx
offset := 0
for {
where, args := buildOpLogWhere()
items, _, err := queryPage(ctx, "aud_operation_logs", selectFields, where, "created_at DESC", offset/1000+1, 1000, args...)
q := g.DB().Ctx(ctx).Model("aud_operation_logs").Fields(selectFields).OrderDesc("created_at").Offset(offset).Limit(1000)
if where != "" {
q = q.Where(where, args...)
}
result, err := q.All()
if err != nil {
return
}
items := make([]map[string]any, 0, len(result))
for _, row := range result {
m := make(map[string]any, len(row))
for k, v := range row {
m[k] = v.Val()
}
items = append(items, m)
}
if err != nil {
return
}
+9 -7
View File
@@ -104,13 +104,15 @@ func (s *sAdmin) Login(ctx context.Context, req *v1.AdminLoginReq) (*v1.AdminLog
return nil, err
}
// Update last login
dao.SysAdminUsers.Ctx(ctx).
// Update last login (non-critical, log errors only)
if _, err := dao.SysAdminUsers.Ctx(ctx).
Where("id", user.Id).
Data(do.SysAdminUsers{
LastLoginAt: gtime.Now(),
LastLoginIp: ipAddress,
}).Update()
}).Update(); err != nil {
g.Log().Warningf(ctx, "update last_login for user %d: %v", user.Id, err)
}
// Record login history
_ = common.RecordLoginHistory(ctx, "admin", user.Id, 0, "password", ipAddress, ua, deviceFP, true, "")
@@ -195,12 +197,12 @@ func (s *sAdmin) Refresh(ctx context.Context, req *v1.AdminRefreshReq) (*v1.Admi
// Fetch current role from user table
var adminUser *entity.SysAdminUsers
err = dao.SysAdminUsers.Ctx(ctx).Where("id", session.UserId).Fields("role").Scan(&adminUser)
err = dao.SysAdminUsers.Ctx(ctx).Where("id", session.UserId).Fields("role, status").Scan(&adminUser)
if err = common.IgnoreScanNoRows(err); err != nil {
return nil, err
}
if adminUser == nil {
return nil, common.NewUnauthorizedError("用户不存在")
if adminUser == nil || adminUser.Status != "active" {
return nil, common.NewUnauthorizedError("账号已被禁用或不存在")
}
// Generate new token pair with same session ID and jti
@@ -339,7 +341,7 @@ func (s *sAdmin) ChangePassword(ctx context.Context, req *v1.AdminChangePassword
return nil, err
}
if user == nil {
return nil, common.NewUnauthorizedError("用户不存在")
return nil, common.NewUnauthorizedError("账号已被禁用或不存在")
}
// Verify old password
+5 -5
View File
@@ -2,14 +2,14 @@ package admin
import (
"context"
"fmt"
"strings"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/util/gconv"
v1 "github.com/qianfree/team-api/api/admin/v1"
"github.com/qianfree/team-api/internal/logic/common"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/util/gconv"
)
// CronJobList returns all registered cron jobs with their last execution status.
@@ -121,7 +121,7 @@ func (s *sAdmin) CronJobExecutions(ctx context.Context, req *v1.CronJobExecution
func (s *sAdmin) CronJobTrigger(ctx context.Context, req *v1.CronJobTriggerReq) (*v1.CronJobTriggerRes, error) {
err := common.GetCronScheduler().TriggerJob(ctx, req.Name)
if err != nil {
return nil, fmt.Errorf("触发任务失败: %w", err)
return nil, gerror.Wrapf(err, "触发定时任务失败")
}
return &v1.CronJobTriggerRes{Message: "任务已触发"}, nil
}
+43 -15
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"time"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gcode"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
@@ -46,7 +47,7 @@ func (s *sAdmin) DataGovernanceExport(ctx context.Context, req *v1.DataGovernanc
count, err := dao.TskTasks.Ctx(ctx).
Where("handler", "data_export").
Where("status", "pending").
Where("payload->>'tenant_id'", fmt.Sprintf("%d", req.TenantID)).
Where("(payload->>'tenant_id')::bigint = ?", req.TenantID).
Count()
if err != nil {
return nil, err
@@ -80,7 +81,7 @@ func (s *sAdmin) DataGovernanceDeletion(ctx context.Context, req *v1.DataGoverna
count, err := dao.TskTasks.Ctx(ctx).
Where("handler", "data_deletion_request").
Where("status IN (?)", []string{"pending", "running"}).
Where("payload->>'tenant_id'", fmt.Sprintf("%d", req.TenantID)).
Where("(payload->>'tenant_id')::bigint = ?", req.TenantID).
Count()
if err != nil {
return nil, err
@@ -193,13 +194,30 @@ func cleanupTableByDate(ctx context.Context, table, dateColumn string, days int)
cutoff := time.Now().AddDate(0, 0, -days)
batchSize := 5000
// Build safe DELETE SQL via switch/case to avoid fmt.Sprintf on table/column names.
// The whitelist above guarantees table and dateColumn are from a trusted set.
var deleteSQL string
switch table {
case "aud_request_logs":
deleteSQL = "DELETE FROM aud_request_logs WHERE id IN (SELECT id FROM aud_request_logs WHERE created_at < ? LIMIT ?)"
case "aud_operation_logs":
deleteSQL = "DELETE FROM aud_operation_logs WHERE id IN (SELECT id FROM aud_operation_logs WHERE created_at < ? LIMIT ?)"
case "aud_sensitive_access_logs":
deleteSQL = "DELETE FROM aud_sensitive_access_logs WHERE id IN (SELECT id FROM aud_sensitive_access_logs WHERE created_at < ? LIMIT ?)"
default:
return fmt.Errorf("invalid table for cleanup: %s", table)
}
for {
result, err := g.DB().Ctx(ctx).Exec(ctx,
fmt.Sprintf("DELETE FROM %s WHERE id IN (SELECT id FROM %s WHERE %s < $1 LIMIT %d)", table, table, dateColumn, batchSize), cutoff)
result, err := g.DB().Ctx(ctx).Exec(ctx, deleteSQL, cutoff, batchSize)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
rowsAffected, _ := result.RowsAffected()
if rowsAffected == 0 {
break
}
@@ -224,16 +242,26 @@ func cleanupDeactivatedTenants(ctx context.Context) error {
}
for _, t := range tenants {
g.Log().Infof(ctx, "cleaning data for deactivated tenant %d (status=%s)", t.ID, t.StatusCode)
if _, err := dao.TntUsers.Ctx(ctx).Where("tenant_id", t.ID).
Data(do.TntUsers{DisplayName: "[deleted]", Email: fmt.Sprintf("deleted_%d@deleted.local", t.ID)}).Update(); err != nil {
return gerror.Wrapf(err, "anonymize users for tenant %d", t.ID)
}
if _, err := dao.ApiKeys.Ctx(ctx).Where("tenant_id", t.ID).Delete(); err != nil {
return gerror.Wrapf(err, "delete api keys for tenant %d", t.ID)
}
if _, err := dao.TntTenants.Ctx(ctx).Where("id", t.ID).
Data(do.TntTenants{DataRemovalAt: gtime.Now()}).Update(); err != nil {
return gerror.Wrapf(err, "update data removal for tenant %d", t.ID)
// Use transaction to ensure atomicity: if any step fails, the tenant's data
// remains consistent (no partial anonymization/deletion).
err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
tenantID := t.ID
if _, err := tx.Model("tnt_users").Ctx(ctx).Where("tenant_id", tenantID).
Data(do.TntUsers{DisplayName: "[deleted]", Email: fmt.Sprintf("deleted_%d@deleted.local", tenantID)}).Update(); err != nil {
return gerror.Wrapf(err, "anonymize users for tenant %d", tenantID)
}
if _, err := tx.Model("api_keys").Ctx(ctx).Where("tenant_id", tenantID).Delete(); err != nil {
return gerror.Wrapf(err, "delete api keys for tenant %d", tenantID)
}
if _, err := tx.Model("tnt_tenants").Ctx(ctx).Where("id", tenantID).
Data(do.TntTenants{DataRemovalAt: gtime.Now()}).Update(); err != nil {
return gerror.Wrapf(err, "update data removal for tenant %d", tenantID)
}
return nil
})
if err != nil {
return err
}
}
return nil
+4 -3
View File
@@ -53,9 +53,10 @@ func (s *sAdmin) ErrorLogList(ctx context.Context, req *v1.ErrorLogListReq) (*v1
}
where := strings.Join(conditions, " AND ")
page, pageSize := common.NormalizePagination(req.Page, req.PageSize)
items, total, err := queryPage(ctx,
"sys_error_logs", "*", where, "id DESC",
req.Page, req.PageSize, args...)
page, pageSize, args...)
if err != nil {
return nil, err
}
@@ -66,8 +67,8 @@ func (s *sAdmin) ErrorLogList(ctx context.Context, req *v1.ErrorLogListReq) (*v1
return &v1.ErrorLogListRes{
List: items,
Total: total,
Page: req.Page,
PageSize: req.PageSize,
Page: page,
PageSize: pageSize,
}, nil
}
+20
View File
@@ -6,6 +6,7 @@ import (
"github.com/gogf/gf/v2/database/gdb"
do "github.com/qianfree/team-api/internal/model/do"
"github.com/gogf/gf/v2/errors/gerror"
v1 "github.com/qianfree/team-api/api/admin/v1"
"github.com/qianfree/team-api/internal/dao"
"github.com/qianfree/team-api/internal/logic/common"
@@ -181,6 +182,14 @@ func (s *sAdmin) UpdateUserPermissions(ctx context.Context, req *v1.AdminPermiss
return err
}
// Validate permission points against predefined set
validPerms := buildValidPermissionSet()
for _, p := range req.Permissions {
if !validPerms[p] {
return gerror.Newf("无效的权限点: %s", p)
}
}
// Insert new permissions
if len(req.Permissions) > 0 {
data := make([]do.SysAdminRolePerms, len(req.Permissions))
@@ -311,3 +320,14 @@ func GetDataScopes(ctx context.Context, userID int64, role string) ([]v1.DataSco
}
return result, nil
}
// buildValidPermissionSet returns a set of all valid permission points.
func buildValidPermissionSet() map[string]bool {
set := make(map[string]bool)
for _, g := range predefinedPermissionGroups {
for _, p := range g.Permissions {
set[p] = true
}
}
return set
}
+32 -12
View File
@@ -2,6 +2,7 @@ package admin
import (
"context"
"encoding/hex"
"fmt"
"time"
@@ -75,11 +76,13 @@ func (s *sAdmin) Verify2FA(ctx context.Context, req *v1.Admin2FAVerifyReq) (*v1.
return nil, err
}
// Update last login
dao.SysAdminUsers.Ctx(ctx).Where("id", user.Id).Data(do.SysAdminUsers{
// Update last login (non-critical, log errors only)
if _, err := dao.SysAdminUsers.Ctx(ctx).Where("id", user.Id).Data(do.SysAdminUsers{
LastLoginAt: gtime.Now(),
LastLoginIp: ipAddress,
}).Update()
}).Update(); err != nil {
g.Log().Warningf(ctx, "update last_login for user %d: %v", user.Id, err)
}
// Record login history
ua := g.RequestFromCtx(ctx).Header.Get("User-Agent")
@@ -107,7 +110,10 @@ func (s *sAdmin) Setup2FA(ctx context.Context, _ *v1.Admin2FASetupReq) (*v1.Admi
}
// Store secret in Redis temporarily (5 min TTL) for the enable step
encKey := getEncKey(ctx)
encKey, err := getEncKey(ctx)
if err != nil {
return nil, err
}
encrypted, err := crypto.EncryptString(encKey, secret)
if err != nil {
return nil, err
@@ -305,10 +311,12 @@ func buildAdminUserMap(ctx context.Context, records []entity.AudLoginHistory) ma
ids = append(ids, id)
}
var users []entity.SysAdminUsers
_ = dao.SysAdminUsers.Ctx(ctx).
if err := dao.SysAdminUsers.Ctx(ctx).
Fields("id, username, display_name").
WhereIn("id", ids).
Scan(&users)
Scan(&users); err != nil {
g.Log().Warningf(ctx, "load admin users for session list: %v", err)
}
for _, u := range users {
m[u.Id] = adminUserBrief{Username: u.Username, DisplayName: u.DisplayName}
}
@@ -429,10 +437,12 @@ func buildTenantUserMap(ctx context.Context, records []entity.AudLoginHistory) m
ids = append(ids, id)
}
var users []entity.TntUsers
_ = dao.TntUsers.Ctx(ctx).
if err := dao.TntUsers.Ctx(ctx).
Fields("id, username, display_name").
WhereIn("id", ids).
Scan(&users)
Scan(&users); err != nil {
g.Log().Warningf(ctx, "load tenant users for session list: %v", err)
}
for _, u := range users {
m[u.Id] = tenantUserBrief{Username: u.Username, DisplayName: u.DisplayName}
}
@@ -450,7 +460,10 @@ func getPendingTOTPSecret(ctx context.Context, userID int64) (string, error) {
return "", nil
}
// The secret is stored encrypted, decrypt it
encKey := getEncKey(ctx)
encKey, err := getEncKey(ctx)
if err != nil {
return "", err
}
return crypto.DecryptString(encKey, val.String())
}
@@ -459,10 +472,17 @@ func clearPendingTOTPSecret(ctx context.Context, userID int64) {
_, _ = g.Redis().Do(ctx, "DEL", key)
}
func getEncKey(ctx context.Context) []byte {
func getEncKey(ctx context.Context) ([]byte, error) {
hexKey := g.Cfg().MustGet(ctx, "crypto.encryptionKey").String()
if hexKey == "" {
hexKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
return nil, gerror.New("crypto.encryptionKey is not configured")
}
return crypto.MustGetEncryptionKey(hexKey)
key, err := hex.DecodeString(hexKey)
if err != nil {
return nil, gerror.Wrapf(err, "invalid crypto.encryptionKey")
}
if len(key) != 32 {
return nil, gerror.Newf("crypto.encryptionKey must be 32 bytes, got %d", len(key))
}
return key, nil
}
+2 -3
View File
@@ -2,7 +2,6 @@ package admin
import (
"context"
"fmt"
v1 "github.com/qianfree/team-api/api/admin/v1"
"github.com/qianfree/team-api/internal/logic/common"
@@ -26,7 +25,7 @@ func (s *sAdmin) GetSettingsCategories(ctx context.Context, _ *v1.AdminSettingsC
// GetSettings retrieves settings with schema for a given category.
func (s *sAdmin) GetSettings(ctx context.Context, req *v1.AdminSettingsGetReq) (*v1.AdminSettingsGetRes, error) {
if !isValidCategory(req.Category) {
return nil, fmt.Errorf("未知的设置分类: %s", req.Category)
return nil, common.NewBadRequestError("无效的设置分类")
}
items := common.Config().GetCategoryWithValues(ctx, req.Category)
@@ -49,7 +48,7 @@ func (s *sAdmin) GetSettings(ctx context.Context, req *v1.AdminSettingsGetReq) (
// UpdateSettings batch-updates settings for a given category.
func (s *sAdmin) UpdateSettings(ctx context.Context, req *v1.AdminSettingsUpdateReq) (*v1.AdminSettingsUpdateRes, error) {
if !isValidCategory(req.Category) {
return nil, fmt.Errorf("未知的设置分类: %s", req.Category)
return nil, common.NewBadRequestError("无效的设置分类")
}
// Normalize interface{} values to strings for storage
+8 -1
View File
@@ -102,6 +102,13 @@ func AutoInitAdmin(ctx context.Context) (bool, error) {
return true, nil
}
// isDuplicateKeyError checks if the error is a PostgreSQL unique constraint violation.
// PostgreSQL error code 23505 = unique_violation.
// NOTE: Uses string matching on the error message. Consider switching to
// pq.Error type assertion if github.com/lib/pq becomes a direct dependency.
func isDuplicateKeyError(err error) bool {
return err != nil && strings.Contains(err.Error(), "23505")
if err == nil {
return false
}
return strings.Contains(err.Error(), "23505") || strings.Contains(err.Error(), "duplicate key")
}