feat(auth): implement session-based token management with revocation support
All checks were successful
Build Backend / build (push) Successful in 2m19s
Build Backend / build-docker (push) Successful in 46s

Add Session model, SessionService, and SessionRepository to track user login
sessions and enable token revocation on auth-critical events.

- Introduce explicit TokenType (access/refresh) in JWT claims to prevent
  refresh token misuse via access token endpoints
- Add SessionID field to JWT claims, enabling stateless JWT validation
  against revoked sessions
- Replace legacy Auth middleware with RequireAuth/OptionalAuth pipeline
  that validates token type, account status, and session validity
- Implement session revocation on password change, reset, user ban/inactive,
  and explicit logout
- Add Principal cache with active invalidation for banned/role-changed users
- Fix IDOR vulnerability: GetMessagesByCursor now validates currentUserID
  is conversation participant via GetParticipantStrict
- Add group member visibility checks: announcements, group info, member list
  now require group membership
- Simplify Casbin policy: remove g grouping, use r.sub == p.sub matcher
  with globMatch; user_roles table is single source of truth for roles
- Add migration logic to clean legacy casbin g rules and migrate old p rules
  from path-style to admin/<domain> resource naming
This commit is contained in:
lafay
2026-07-05 18:28:08 +08:00
parent d240485d0e
commit eb931bf1c6
41 changed files with 2544 additions and 360 deletions

View File

@@ -234,6 +234,9 @@ func autoMigrate(db *gorm.DB) error {
// 文件上传记录(用于聊天文件 TTL 过期清理)
&model.UploadedFile{},
// 会话(令牌撤销支持)
&model.Session{},
)
if err != nil {
return err
@@ -254,6 +257,12 @@ func autoMigrate(db *gorm.DB) error {
return fmt.Errorf("failed to seed permissions: %w", err)
}
// 清理历史 Casbin g 分组规则(真源已统一为 user_roles 表Casbin 仅保留 p 策略)。
// 幂等:仅在 casbin_rule 中存在 ptype='g' 记录时删除。
if err := cleanupLegacyCasbinGRules(db); err != nil {
return fmt.Errorf("failed to cleanup legacy casbin g rules: %w", err)
}
// 初始化频道配置种子数据
if err := seedChannels(db); err != nil {
return fmt.Errorf("failed to seed channels: %w", err)
@@ -285,6 +294,27 @@ func dropPostsHotScoreColumnIfExists(db *gorm.DB) error {
return m.DropColumn(&postWithHotScore{}, "HotScore")
}
// cleanupLegacyCasbinGRules 清理历史 Casbin g 分组规则。
//
// 真源策略变更后user_roles 表为用户-角色唯一真源Casbin 仅保留 p, role, resource, action 策略。
// 旧的 g, user, role 记录不再被 matcher 使用model.conf 已移除 g 依赖),保留只会造成管理后台误解。
// 幂等:仅删除 ptype='g' 的记录;若不存在则无操作。
func cleanupLegacyCasbinGRules(db *gorm.DB) error {
if !db.Migrator().HasTable(&model.CasbinRule{}) {
return nil
}
result := db.Where("ptype = ?", "g").Delete(&model.CasbinRule{})
if result.Error != nil {
return result.Error
}
if result.RowsAffected > 0 {
zap.L().Info("Cleaned up legacy casbin g rules",
zap.Int64("count", result.RowsAffected),
)
}
return nil
}
// seedRoles 初始化角色种子数据
func seedRoles(db *gorm.DB) error {
// 检查是否已有角色数据
@@ -344,6 +374,14 @@ func seedRoles(db *gorm.DB) error {
}
// seedPermissions 初始化权限策略种子数据
//
// 真源策略user_roles 表为用户-角色真源Casbin 仅维护 p, role, resource, action 策略。
// 资源命名约定admin/<domain>[/<sub>](路径式,配合 globMatch 中 * 不跨 /、** 跨 / 的语义)。
//
// 升级兼容:旧版本以 URL 路径式资源(如 /api/v1/admin/*、/*)作为 Casbin 策略,
// 新版本改为 admin/<domain> 资源命名后,存量部署需要迁移。本函数检测到任何 v1 以 '/' 开头的
// 旧策略时,将其视为旧版本部署:清空所有 p 策略并按新约定重新 seed。
// 新部署无此条件不触发幂等admin/<domain> 不以 '/' 开头,不会误判为新旧迁移)。
func seedPermissions(db *gorm.DB) error {
// 检查是否已有权限策略数据
var count int64
@@ -351,43 +389,79 @@ func seedPermissions(db *gorm.DB) error {
return err
}
// 如果已有数据,跳过种子初始化
if count > 0 {
return nil
// 检测是否存在旧版本路径式策略v1 以 '/' 开头)。
// 旧策略例如:{p, super_admin, /*, *} / {p, admin, /api/v1/admin/*, *} 等。
// 新约定资源形如 admin/users不以 '/' 开头),不会被本条件误命中。
// 旧策略在新 model.conf 的 admin/<domain> 命名下永远不会匹配,保留只会让管理后台权限全断。
var legacyCount int64
if err := db.Model(&model.CasbinRule{}).
Where("ptype = ? AND v1 LIKE '/%'", "p").
Count(&legacyCount).Error; err != nil {
return err
}
if legacyCount == 0 {
// 已是新版本策略,无需重复 seed。
return nil
}
// 命中旧策略:迁移。先 dump 待删除策略到日志便于回查,再删除全部 p 策略。
zap.L().Info("Migrating legacy path-based casbin p rules to admin/<domain> resource naming",
zap.Int64("legacy_count", legacyCount),
zap.Int64("total_p_count", count),
)
var legacyRules []model.CasbinRule
if err := db.Where("ptype = ?", "p").Find(&legacyRules).Error; err != nil {
return err
}
for _, r := range legacyRules {
zap.L().Debug("legacy casbin p rule will be removed",
zap.String("v0", r.V0),
zap.String("v1", r.V1),
zap.String("v2", r.V2),
)
}
if err := db.Where("ptype = ?", "p").Delete(&model.CasbinRule{}).Error; err != nil {
return err
}
// 注意count > 0 分支到此已清空所有 p 策略,下面统一重新 seed。
}
// 预定义权限策略数据
// p = sub, obj, act (角色, 资源, 操作)
//
// 资源命名约定admin/<domain>[/<sub>]路径式动作read / write / *。
// super_admin 通过 admin/** 通配获得所有管理能力globMatch 中 ** 跨 '/'
// admin 仅获得业务管理能力角色与权限管理admin/roles/*、admin/users/roles/*
// 与日志导出admin/logs/export仅 super_admin 拥有admin/logs 单层匹配不跨 /)。
permissions := []model.CasbinRule{
// 超级管理员 - 拥有所有权限
{Ptype: "p", V0: model.RoleSuperAdmin, V1: "/*", V2: "*"},
// 超级管理员 - 通配所有 admin 资源globMatch** 跨 '/' 分隔的多级资源)
{Ptype: "p", V0: model.RoleSuperAdmin, V1: "admin/**", V2: "*"},
// 管理员 - 拥有管理权限
{Ptype: "p", V0: model.RoleAdmin, V1: "/api/v1/admin/*", V2: "*"},
{Ptype: "p", V0: model.RoleAdmin, V1: "/api/v1/users/*", V2: "GET"},
{Ptype: "p", V0: model.RoleAdmin, V1: "/api/v1/posts/*", V2: "*"},
{Ptype: "p", V0: model.RoleAdmin, V1: "/api/v1/comments/*", V2: "*"},
{Ptype: "p", V0: model.RoleAdmin, V1: "/api/v1/groups/*", V2: "*"},
// 管理员 - 业务管理能力(不含角色/权限管理与日志导出)
{Ptype: "p", V0: model.RoleAdmin, V1: "admin/users", V2: "read"},
{Ptype: "p", V0: model.RoleAdmin, V1: "admin/users/status", V2: "write"},
{Ptype: "p", V0: model.RoleAdmin, V1: "admin/users/devices", V2: "read"},
{Ptype: "p", V0: model.RoleAdmin, V1: "admin/posts", V2: "*"},
{Ptype: "p", V0: model.RoleAdmin, V1: "admin/comments", V2: "*"},
{Ptype: "p", V0: model.RoleAdmin, V1: "admin/groups", V2: "*"},
{Ptype: "p", V0: model.RoleAdmin, V1: "admin/channels", V2: "*"},
{Ptype: "p", V0: model.RoleAdmin, V1: "admin/dashboard", V2: "read"},
{Ptype: "p", V0: model.RoleAdmin, V1: "admin/reports", V2: "*"},
{Ptype: "p", V0: model.RoleAdmin, V1: "admin/verifications", V2: "*"},
{Ptype: "p", V0: model.RoleAdmin, V1: "admin/profile_audits", V2: "*"},
{Ptype: "p", V0: model.RoleAdmin, V1: "admin/materials", V2: "*"},
{Ptype: "p", V0: model.RoleAdmin, V1: "admin/logs", V2: "read"},
// 版主 - 拥有内容审核权限
{Ptype: "p", V0: model.RoleModerator, V1: "/api/v1/posts/*", V2: "GET"},
{Ptype: "p", V0: model.RoleModerator, V1: "/api/v1/posts/*", V2: "DELETE"},
{Ptype: "p", V0: model.RoleModerator, V1: "/api/v1/comments/*", V2: "GET"},
{Ptype: "p", V0: model.RoleModerator, V1: "/api/v1/comments/*", V2: "DELETE"},
{Ptype: "p", V0: model.RoleModerator, V1: "/api/v1/groups/*", V2: "GET"},
// 版主 - 内容审核
{Ptype: "p", V0: model.RoleModerator, V1: "admin/posts", V2: "read"},
{Ptype: "p", V0: model.RoleModerator, V1: "admin/posts", V2: "write"},
{Ptype: "p", V0: model.RoleModerator, V1: "admin/comments", V2: "read"},
{Ptype: "p", V0: model.RoleModerator, V1: "admin/comments", V2: "write"},
{Ptype: "p", V0: model.RoleModerator, V1: "admin/reports", V2: "read"},
{Ptype: "p", V0: model.RoleModerator, V1: "admin/reports", V2: "write"},
// 普通用户 - 拥有基本权限
{Ptype: "p", V0: model.RoleUser, V1: "/api/v1/posts", V2: "GET"},
{Ptype: "p", V0: model.RoleUser, V1: "/api/v1/posts/*", V2: "GET"},
{Ptype: "p", V0: model.RoleUser, V1: "/api/v1/posts", V2: "POST"},
{Ptype: "p", V0: model.RoleUser, V1: "/api/v1/comments/*", V2: "GET"},
{Ptype: "p", V0: model.RoleUser, V1: "/api/v1/comments", V2: "POST"},
{Ptype: "p", V0: model.RoleUser, V1: "/api/v1/users/me", V2: "GET"},
{Ptype: "p", V0: model.RoleUser, V1: "/api/v1/users/me", V2: "PUT"},
{Ptype: "p", V0: model.RoleUser, V1: "/api/v1/groups", V2: "GET"},
{Ptype: "p", V0: model.RoleUser, V1: "/api/v1/groups/*", V2: "GET"},
// 被封禁用户 - 无权限
// 普通用户与被封禁用户admin 路由外的资源由路由层中间件保障Casbin 不维护。
}
// 批量插入权限策略