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

@@ -4,13 +4,15 @@
package main package main
import ( import (
"github.com/google/wire"
"with_you/internal/cache"
"with_you/internal/handler" "with_you/internal/handler"
"with_you/internal/middleware"
"with_you/internal/repository" "with_you/internal/repository"
"with_you/internal/router" "with_you/internal/router"
"with_you/internal/service" "with_you/internal/service"
appwire "with_you/internal/wire" appwire "with_you/internal/wire"
"github.com/google/wire"
) )
// InitializeApp 初始化应用程序 // InitializeApp 初始化应用程序
@@ -33,6 +35,9 @@ func ProvideRouter(
notificationHandler *handler.NotificationHandler, notificationHandler *handler.NotificationHandler,
uploadHandler *handler.UploadHandler, uploadHandler *handler.UploadHandler,
jwtService service.JWTService, jwtService service.JWTService,
sessionService service.SessionService,
identityProvider middleware.IdentityProvider,
cache cache.Cache,
pushHandler *handler.PushHandler, pushHandler *handler.PushHandler,
systemMessageHandler *handler.SystemMessageHandler, systemMessageHandler *handler.SystemMessageHandler,
groupHandler *handler.GroupHandler, groupHandler *handler.GroupHandler,
@@ -76,6 +81,9 @@ func ProvideRouter(
NotificationHandler: notificationHandler, NotificationHandler: notificationHandler,
UploadHandler: uploadHandler, UploadHandler: uploadHandler,
JWTService: jwtService, JWTService: jwtService,
SessionService: sessionService,
IdentityProvider: identityProvider,
Cache: cache,
PushHandler: pushHandler, PushHandler: pushHandler,
SystemMessageHandler: systemMessageHandler, SystemMessageHandler: systemMessageHandler,
GroupHandler: groupHandler, GroupHandler: groupHandler,

View File

@@ -7,7 +7,9 @@
package main package main
import ( import (
"with_you/internal/cache"
"with_you/internal/handler" "with_you/internal/handler"
"with_you/internal/middleware"
"with_you/internal/repository" "with_you/internal/repository"
"with_you/internal/router" "with_you/internal/router"
"with_you/internal/service" "with_you/internal/service"
@@ -49,7 +51,10 @@ func InitializeApp() (*App, error) {
loginLogService := wire.ProvideLoginLogService(asyncLogManager, loginLogRepository) loginLogService := wire.ProvideLoginLogService(asyncLogManager, loginLogRepository)
dataChangeLogService := wire.ProvideDataChangeLogService(asyncLogManager, dataChangeLogRepository) dataChangeLogService := wire.ProvideDataChangeLogService(asyncLogManager, dataChangeLogRepository)
logService := wire.ProvideLogService(operationLogService, loginLogService, dataChangeLogService) logService := wire.ProvideLogService(operationLogService, loginLogService, dataChangeLogService)
userService := wire.ProvideUserService(userRepository, systemMessageService, emailService, cache, logService) sessionRepository := repository.NewSessionRepository(db)
jwtService := wire.ProvideJWTService(config)
sessionService := wire.ProvideSessionService(sessionRepository, jwtService)
userService := wire.ProvideUserService(userRepository, systemMessageService, emailService, cache, logService, sessionService)
userActivityRepository := wire.ProvideUserActivityRepository(db, cache) userActivityRepository := wire.ProvideUserActivityRepository(db, cache)
userActivityService := wire.ProvideUserActivityService(userActivityRepository) userActivityService := wire.ProvideUserActivityService(userActivityRepository)
userProfileAuditRepository := repository.NewUserProfileAuditRepository(db) userProfileAuditRepository := repository.NewUserProfileAuditRepository(db)
@@ -82,17 +87,20 @@ func InitializeApp() (*App, error) {
seqBufferManager := wire.ProvideSeqBufferManager(config, client, cache) seqBufferManager := wire.ProvideSeqBufferManager(config, client, cache)
pushWorker := wire.ProvidePushWorker(config, client, messagePublisher, pushService, messageRepository, userRepository) pushWorker := wire.ProvidePushWorker(config, client, messagePublisher, pushService, messageRepository, userRepository)
conversationVersionLogRepository := repository.NewConversationVersionLogRepository(db) conversationVersionLogRepository := repository.NewConversationVersionLogRepository(db)
chatService := wire.ProvideChatService(messageRepository, userRepository, messagePublisher, cache, uploadService, pushService, seqBufferManager, pushWorker, client, conversationVersionLogRepository, config)
messageService := wire.ProvideMessageService(messageRepository, cache, uploadService)
groupRepository := repository.NewGroupRepository(db) groupRepository := repository.NewGroupRepository(db)
groupJoinRequestRepository := repository.NewGroupJoinRequestRepository(db) groupJoinRequestRepository := repository.NewGroupJoinRequestRepository(db)
groupService := wire.ProvideGroupService(groupRepository, userRepository, messageRepository, groupJoinRequestRepository, systemNotificationRepository, messagePublisher, cache) groupService := wire.ProvideGroupService(groupRepository, userRepository, messageRepository, groupJoinRequestRepository, systemNotificationRepository, messagePublisher, cache)
chatService := wire.ProvideChatService(messageRepository, userRepository, messagePublisher, cache, uploadService, pushService, seqBufferManager, pushWorker, client, conversationVersionLogRepository, config, groupService)
messageService := wire.ProvideMessageService(messageRepository, cache, uploadService)
messageHandler := wire.ProvideMessageHandler(chatService, messageService, userService, groupService, uploadService, messagePublisher) messageHandler := wire.ProvideMessageHandler(chatService, messageService, userService, groupService, uploadService, messagePublisher)
notificationRepository := repository.NewNotificationRepository(db) notificationRepository := repository.NewNotificationRepository(db)
notificationService := wire.ProvideNotificationService(notificationRepository, cache) notificationService := wire.ProvideNotificationService(notificationRepository, cache)
notificationHandler := handler.NewNotificationHandler(notificationService) notificationHandler := handler.NewNotificationHandler(notificationService)
uploadHandler := handler.NewUploadHandler(uploadService) uploadHandler := handler.NewUploadHandler(uploadService)
jwtService := wire.ProvideJWTService(config) enforcer := wire.ProvideCasbinEnforcer(config, db)
roleRepository := wire.ProvideRoleRepository(db)
casbinService := wire.ProvideCasbinService(enforcer, cache, roleRepository, config)
identityProvider := wire.ProvideIdentityProvider(userService, casbinService, sessionRepository)
pushHandler := handler.NewPushHandler(pushService) pushHandler := handler.NewPushHandler(pushService)
systemMessageHandler := wire.ProvideSystemMessageHandler(systemMessageService) systemMessageHandler := wire.ProvideSystemMessageHandler(systemMessageService)
groupHandler := wire.ProvideGroupHandler(groupService, userService) groupHandler := wire.ProvideGroupHandler(groupService, userService)
@@ -118,12 +126,9 @@ func InitializeApp() (*App, error) {
emptyClassroomRepository := repository.NewEmptyClassroomRepository(db) emptyClassroomRepository := repository.NewEmptyClassroomRepository(db)
emptyClassroomSyncService := wire.ProvideEmptyClassroomSyncService(taskManager, emptyClassroomRepository, logger) emptyClassroomSyncService := wire.ProvideEmptyClassroomSyncService(taskManager, emptyClassroomRepository, logger)
emptyClassroomHandler := handler.NewEmptyClassroomHandler(emptyClassroomSyncService) emptyClassroomHandler := handler.NewEmptyClassroomHandler(emptyClassroomSyncService)
roleRepository := wire.ProvideRoleRepository(db)
enforcer := wire.ProvideCasbinEnforcer(config, db)
casbinService := wire.ProvideCasbinService(enforcer, cache, roleRepository, config)
roleService := wire.ProvideRoleService(roleRepository, casbinService) roleService := wire.ProvideRoleService(roleRepository, casbinService)
roleHandler := handler.NewRoleHandler(roleService) roleHandler := handler.NewRoleHandler(roleService)
adminUserService := wire.ProvideAdminUserService(userRepository) adminUserService := wire.ProvideAdminUserService(userRepository, sessionService, cache)
adminUserHandler := handler.NewAdminUserHandler(adminUserService, pushService) adminUserHandler := handler.NewAdminUserHandler(adminUserService, pushService)
adminPostService := wire.ProvideAdminPostService(postRepository, cache, logService) adminPostService := wire.ProvideAdminPostService(postRepository, cache, logService)
adminPostHandler := handler.NewAdminPostHandler(adminPostService) adminPostHandler := handler.NewAdminPostHandler(adminPostService)
@@ -162,7 +167,7 @@ func InitializeApp() (*App, error) {
wsHandler := wire.ProvideWSHandler(messagePublisher, chatService, groupService, jwtService, callService, userService) wsHandler := wire.ProvideWSHandler(messagePublisher, chatService, groupService, jwtService, callService, userService)
liveKitService := wire.ProvideLiveKitService(config, logger) liveKitService := wire.ProvideLiveKitService(config, logger)
liveKitHandler := wire.ProvideLiveKitHandler(liveKitService, callService, config, logger) liveKitHandler := wire.ProvideLiveKitHandler(liveKitService, callService, config, logger)
router := ProvideRouter(userRepository, userHandler, postHandler, commentHandler, messageHandler, notificationHandler, uploadHandler, jwtService, pushHandler, systemMessageHandler, groupHandler, stickerHandler, voteHandler, channelHandler, scheduleHandler, gradeHandler, examHandler, emptyClassroomHandler, roleHandler, adminUserHandler, adminPostHandler, adminCommentHandler, adminGroupHandler, adminDashboardHandler, adminLogHandler, qrCodeHandler, materialHandler, callHandler, reportHandler, adminReportHandler, verificationHandler, adminVerificationHandler, adminProfileAuditHandler, setupHandler, tradeHandler, logService, userActivityService, casbinService, userService, wsHandler, liveKitHandler) router := ProvideRouter(userRepository, userHandler, postHandler, commentHandler, messageHandler, notificationHandler, uploadHandler, jwtService, sessionService, identityProvider, cache, pushHandler, systemMessageHandler, groupHandler, stickerHandler, voteHandler, channelHandler, scheduleHandler, gradeHandler, examHandler, emptyClassroomHandler, roleHandler, adminUserHandler, adminPostHandler, adminCommentHandler, adminGroupHandler, adminDashboardHandler, adminLogHandler, qrCodeHandler, materialHandler, callHandler, reportHandler, adminReportHandler, verificationHandler, adminVerificationHandler, adminProfileAuditHandler, setupHandler, tradeHandler, logService, userActivityService, casbinService, userService, wsHandler, liveKitHandler)
hotRankWorker := wire.ProvideHotRankWorker(config, postRepository, channelRepository, cache, client) hotRankWorker := wire.ProvideHotRankWorker(config, postRepository, channelRepository, cache, client)
fileCleanupWorker := wire.ProvideFileCleanupWorker(config, uploadedFileRepository, s3Client, client) fileCleanupWorker := wire.ProvideFileCleanupWorker(config, uploadedFileRepository, s3Client, client)
deviceCleanupWorker := wire.ProvideDeviceCleanupWorker(config, deviceTokenRepository, client) deviceCleanupWorker := wire.ProvideDeviceCleanupWorker(config, deviceTokenRepository, client)
@@ -185,6 +190,9 @@ func ProvideRouter(
notificationHandler *handler.NotificationHandler, notificationHandler *handler.NotificationHandler,
uploadHandler *handler.UploadHandler, uploadHandler *handler.UploadHandler,
jwtService service.JWTService, jwtService service.JWTService,
sessionService service.SessionService,
identityProvider middleware.IdentityProvider, cache2 cache.Cache,
pushHandler *handler.PushHandler, pushHandler *handler.PushHandler,
systemMessageHandler *handler.SystemMessageHandler, systemMessageHandler *handler.SystemMessageHandler,
groupHandler *handler.GroupHandler, groupHandler *handler.GroupHandler,
@@ -228,6 +236,9 @@ func ProvideRouter(
NotificationHandler: notificationHandler, NotificationHandler: notificationHandler,
UploadHandler: uploadHandler, UploadHandler: uploadHandler,
JWTService: jwtService, JWTService: jwtService,
SessionService: sessionService,
IdentityProvider: identityProvider,
Cache: cache2,
PushHandler: pushHandler, PushHandler: pushHandler,
SystemMessageHandler: systemMessageHandler, SystemMessageHandler: systemMessageHandler,
GroupHandler: groupHandler, GroupHandler: groupHandler,

View File

@@ -10,5 +10,14 @@ g = _, _
[policy_effect] [policy_effect]
e = some(where (p.eft == allow)) e = some(where (p.eft == allow))
# 真源策略user_roles 表为用户-角色唯一真源Casbin 仅存 p, role, resource, action。
# EnforceForUser 由 CasbinService 先查 DB 角色再对每个角色调 Enforce(role, obj, act)
# 因此 matcher 直接匹配 r.sub == p.sub角色直接匹配不再依赖 g 分组。
#
# matcher 使用 globMatchdoublestar 语义):
# - '* 匹配单层资源段(不跨 '/'
# - '**' 跨多层(如 admin/** 命中 admin/users/roles/write
# 资源命名采用路径式 admin/<domain>[/<sub>],避免 keyMatch2 把 '*' 当作正则通配
# 跨 '.' 而误匹配;也避免 globMatch 在点号分隔下 '*' 仍跨 '.' 的过宽问题。
[matchers] [matchers]
m = g(r.sub, p.sub) && keyMatch2(r.obj, p.obj) && (r.act == p.act || p.act == "*") m = r.sub == p.sub && globMatch(r.obj, p.obj) && (r.act == p.act || p.act == "*")

View File

@@ -234,6 +234,9 @@ func autoMigrate(db *gorm.DB) error {
// 文件上传记录(用于聊天文件 TTL 过期清理) // 文件上传记录(用于聊天文件 TTL 过期清理)
&model.UploadedFile{}, &model.UploadedFile{},
// 会话(令牌撤销支持)
&model.Session{},
) )
if err != nil { if err != nil {
return err return err
@@ -254,6 +257,12 @@ func autoMigrate(db *gorm.DB) error {
return fmt.Errorf("failed to seed permissions: %w", err) 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 { if err := seedChannels(db); err != nil {
return fmt.Errorf("failed to seed channels: %w", err) return fmt.Errorf("failed to seed channels: %w", err)
@@ -285,6 +294,27 @@ func dropPostsHotScoreColumnIfExists(db *gorm.DB) error {
return m.DropColumn(&postWithHotScore{}, "HotScore") 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 初始化角色种子数据 // seedRoles 初始化角色种子数据
func seedRoles(db *gorm.DB) error { func seedRoles(db *gorm.DB) error {
// 检查是否已有角色数据 // 检查是否已有角色数据
@@ -344,6 +374,14 @@ func seedRoles(db *gorm.DB) error {
} }
// seedPermissions 初始化权限策略种子数据 // 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 { func seedPermissions(db *gorm.DB) error {
// 检查是否已有权限策略数据 // 检查是否已有权限策略数据
var count int64 var count int64
@@ -351,43 +389,79 @@ func seedPermissions(db *gorm.DB) error {
return err return err
} }
// 如果已有数据,跳过种子初始化
if count > 0 { if count > 0 {
// 检测是否存在旧版本路径式策略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 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 (角色, 资源, 操作) // 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{ permissions := []model.CasbinRule{
// 超级管理员 - 拥有所有权限 // 超级管理员 - 通配所有 admin 资源globMatch** 跨 '/' 分隔的多级资源)
{Ptype: "p", V0: model.RoleSuperAdmin, V1: "/*", V2: "*"}, {Ptype: "p", V0: model.RoleSuperAdmin, V1: "admin/**", V2: "*"},
// 管理员 - 拥有管理权限 // 管理员 - 业务管理能力(不含角色/权限管理与日志导出)
{Ptype: "p", V0: model.RoleAdmin, V1: "/api/v1/admin/*", V2: "*"}, {Ptype: "p", V0: model.RoleAdmin, V1: "admin/users", V2: "read"},
{Ptype: "p", V0: model.RoleAdmin, V1: "/api/v1/users/*", V2: "GET"}, {Ptype: "p", V0: model.RoleAdmin, V1: "admin/users/status", V2: "write"},
{Ptype: "p", V0: model.RoleAdmin, V1: "/api/v1/posts/*", V2: "*"}, {Ptype: "p", V0: model.RoleAdmin, V1: "admin/users/devices", V2: "read"},
{Ptype: "p", V0: model.RoleAdmin, V1: "/api/v1/comments/*", V2: "*"}, {Ptype: "p", V0: model.RoleAdmin, V1: "admin/posts", V2: "*"},
{Ptype: "p", V0: model.RoleAdmin, V1: "/api/v1/groups/*", 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: "admin/posts", V2: "read"},
{Ptype: "p", V0: model.RoleModerator, V1: "/api/v1/posts/*", V2: "DELETE"}, {Ptype: "p", V0: model.RoleModerator, V1: "admin/posts", V2: "write"},
{Ptype: "p", V0: model.RoleModerator, V1: "/api/v1/comments/*", V2: "GET"}, {Ptype: "p", V0: model.RoleModerator, V1: "admin/comments", V2: "read"},
{Ptype: "p", V0: model.RoleModerator, V1: "/api/v1/comments/*", V2: "DELETE"}, {Ptype: "p", V0: model.RoleModerator, V1: "admin/comments", V2: "write"},
{Ptype: "p", V0: model.RoleModerator, V1: "/api/v1/groups/*", V2: "GET"}, {Ptype: "p", V0: model.RoleModerator, V1: "admin/reports", V2: "read"},
{Ptype: "p", V0: model.RoleModerator, V1: "admin/reports", V2: "write"},
// 普通用户 - 拥有基本权限 // 普通用户与被封禁用户admin 路由外的资源由路由层中间件保障Casbin 不维护。
{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"},
// 被封禁用户 - 无权限
} }
// 批量插入权限策略 // 批量插入权限策略

View File

@@ -109,10 +109,10 @@ func TestSeedPermissions(t *testing.T) {
t.Fatal("expected seeded permissions, got 0") t.Fatal("expected seeded permissions, got 0")
} }
// 超级管理员 /* 通配 // 超级管理员 admin/** 通配globMatch 中 ** 跨 '/'
var rule model.CasbinRule var rule model.CasbinRule
if err := db.Where("ptype=? AND v0=? AND v1=?", "p", model.RoleSuperAdmin, "/*").First(&rule).Error; err != nil { if err := db.Where("ptype=? AND v0=? AND v1=?", "p", model.RoleSuperAdmin, "admin/**").First(&rule).Error; err != nil {
t.Errorf("expected superadmin wildcard permission: %v", err) t.Errorf("expected superadmin admin/** wildcard permission: %v", err)
} }
} }

View File

@@ -69,6 +69,7 @@ type GroupResponse struct {
MaxMembers int `json:"max_members"` MaxMembers int `json:"max_members"`
JoinType int `json:"join_type"` JoinType int `json:"join_type"`
MuteAll bool `json:"mute_all"` MuteAll bool `json:"mute_all"`
IsMember bool `json:"is_member"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
} }

View File

@@ -141,6 +141,13 @@ var (
ErrVerificationAlreadyHandled = &AppError{Code: "VERIFICATION_ALREADY_HANDLED", Message: "该认证申请已被处理"} ErrVerificationAlreadyHandled = &AppError{Code: "VERIFICATION_ALREADY_HANDLED", Message: "该认证申请已被处理"}
ErrVerificationRequired = &AppError{Code: "VERIFICATION_REQUIRED", Message: "需要完成身份认证"} ErrVerificationRequired = &AppError{Code: "VERIFICATION_REQUIRED", Message: "需要完成身份认证"}
// 认证 / 令牌相关错误(统一由认证管道返回)
ErrInvalidToken = &AppError{Code: "INVALID_TOKEN", Message: "令牌无效"}
ErrTokenExpired = &AppError{Code: "TOKEN_EXPIRED", Message: "令牌已过期"}
ErrSessionRevoked = &AppError{Code: "SESSION_REVOKED", Message: "会话已失效,请重新登录"}
ErrAccountInactive = &AppError{Code: "ACCOUNT_INACTIVE", Message: "账号未激活"}
ErrWrongTokenType = &AppError{Code: "WRONG_TOKEN_TYPE", Message: "令牌类型错误"}
// 初始化相关错误 // 初始化相关错误
ErrSetupAlreadyCompleted = &AppError{Code: "SETUP_ALREADY_COMPLETED", Message: "超级管理员已初始化,无法重复设置"} ErrSetupAlreadyCompleted = &AppError{Code: "SETUP_ALREADY_COMPLETED", Message: "超级管理员已初始化,无法重复设置"}
ErrInvalidSetupSecret = &AppError{Code: "INVALID_SETUP_SECRET", Message: "初始化密钥无效"} ErrInvalidSetupSecret = &AppError{Code: "INVALID_SETUP_SECRET", Message: "初始化密钥无效"}

View File

@@ -838,6 +838,8 @@ func (h *GroupHandler) HandleCreateAnnouncement(c *gin.Context) {
// HandleGetAnnouncements 获取群公告列表 // HandleGetAnnouncements 获取群公告列表
// GET /api/v1/groups/:id/announcements // GET /api/v1/groups/:id/announcements
//
// 可见性策略:仅群成员可见。非成员返回 403 NOT_GROUP_MEMBER。
func (h *GroupHandler) HandleGetAnnouncements(c *gin.Context) { func (h *GroupHandler) HandleGetAnnouncements(c *gin.Context) {
userID := parseUserID(c) userID := parseUserID(c)
if userID == "" { if userID == "" {
@@ -851,6 +853,12 @@ func (h *GroupHandler) HandleGetAnnouncements(c *gin.Context) {
return return
} }
// 成员校验:公告仅群成员可见。
if !h.groupService.IsGroupMember(userID, groupID) {
response.Forbidden(c, "不是群成员")
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
@@ -1387,7 +1395,9 @@ func (h *GroupHandler) GetMembersByCursor(c *gin.Context) {
} }
// GetAnnouncementsByCursor 游标分页获取群公告列表 // GetAnnouncementsByCursor 游标分页获取群公告列表
// GET /api/groups/:id/announcements/cursor // GET /api/v1/groups/:id/announcements/cursor
//
// 可见性策略:仅群成员可见。非成员返回 403 NOT_GROUP_MEMBER。
func (h *GroupHandler) GetAnnouncementsByCursor(c *gin.Context) { func (h *GroupHandler) GetAnnouncementsByCursor(c *gin.Context) {
userID := parseUserID(c) userID := parseUserID(c)
if userID == "" { if userID == "" {
@@ -1401,6 +1411,12 @@ func (h *GroupHandler) GetAnnouncementsByCursor(c *gin.Context) {
return return
} }
// 成员校验
if !h.groupService.IsGroupMember(userID, groupID) {
response.Forbidden(c, "不是群成员")
return
}
// 解析游标分页请求 // 解析游标分页请求
req := h.parseCursorRequest(c) req := h.parseCursorRequest(c)
@@ -1937,6 +1953,8 @@ func (h *GroupHandler) HandleRespondInvite(c *gin.Context) {
// HandleGetGroupInfo 获取群信息 // HandleGetGroupInfo 获取群信息
// GET /api/v1/groups/:id // GET /api/v1/groups/:id
//
// 可见性策略成员返回完整详情非成员仅返回公开字段name/avatar/description/member_count
func (h *GroupHandler) HandleGetGroupInfo(c *gin.Context) { func (h *GroupHandler) HandleGetGroupInfo(c *gin.Context) {
userID := parseUserID(c) userID := parseUserID(c)
if userID == "" { if userID == "" {
@@ -1967,11 +1985,26 @@ func (h *GroupHandler) HandleGetGroupInfo(c *gin.Context) {
resp := dto.GroupToResponse(group) resp := dto.GroupToResponse(group)
resp.MemberCount = memberCount resp.MemberCount = memberCount
// 非成员仅返回公开字段:与成员分支同构(仍是 GroupResponse但清空敏感字段。
// 与成员分支共用 GroupResponse 结构,依赖完整字段的客户端解析不会因字段缺失出错。
if !h.groupService.IsGroupMember(userID, groupID) {
resp.OwnerID = ""
resp.MaxMembers = 0
resp.JoinType = 0
resp.MuteAll = false
resp.IsMember = false
response.Success(c, resp)
return
}
resp.IsMember = true
response.Success(c, resp) response.Success(c, resp)
} }
// HandleGetGroupMemberList 获取群成员列表 // HandleGetGroupMemberList 获取群成员列表
// GET /api/v1/groups/:id/members // GET /api/v1/groups/:id/members
//
// 可见性策略:仅群成员可见。非成员返回 403 NOT_GROUP_MEMBER。
func (h *GroupHandler) HandleGetGroupMemberList(c *gin.Context) { func (h *GroupHandler) HandleGetGroupMemberList(c *gin.Context) {
userID := parseUserID(c) userID := parseUserID(c)
if userID == "" { if userID == "" {
@@ -1988,8 +2021,13 @@ func (h *GroupHandler) HandleGetGroupMemberList(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "50")) pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
members, total, err := h.groupService.GetMembers(groupID, page, pageSize) // 成员校验在 service 层完成。
members, total, err := h.groupService.GetMembersForUser(userID, groupID, page, pageSize)
if err != nil { if err != nil {
if errors.Is(err, service.ErrNotGroupMember) {
response.Forbidden(c, "不是群成员")
return
}
if errors.Is(err, service.ErrGroupNotFound) { if errors.Is(err, service.ErrGroupNotFound) {
response.NotFound(c, "群组不存在") response.NotFound(c, "群组不存在")
return return

View File

@@ -1108,10 +1108,10 @@ func (h *MessageHandler) GetMessagesByCursor(c *gin.Context) {
// 解析游标分页参数 // 解析游标分页参数
pageReq := parseCursorPageRequest(c) pageReq := parseCursorPageRequest(c)
// 调用服务层 // 调用服务层(内部会校验 currentUserID 是否是会话参与者,否则拒绝)
result, err := h.messageService.GetMessagesByCursor(c.Request.Context(), conversationID, pageReq) result, err := h.messageService.GetMessagesByCursor(c.Request.Context(), conversationID, userID, pageReq)
if err != nil { if err != nil {
response.InternalServerError(c, "failed to get messages") response.HandleError(c, err, "failed to get messages")
return return
} }

View File

@@ -6,12 +6,17 @@ import (
"crypto/sha256" "crypto/sha256"
"fmt" "fmt"
"strconv" "strconv"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"go.uber.org/zap"
"with_you/internal/cache"
"with_you/internal/dto" "with_you/internal/dto"
"with_you/internal/middleware" "with_you/internal/middleware"
"with_you/internal/model" "with_you/internal/model"
apperrors "with_you/internal/errors"
"with_you/internal/pkg/auth"
"with_you/internal/pkg/response" "with_you/internal/pkg/response"
"with_you/internal/service" "with_you/internal/service"
) )
@@ -37,6 +42,8 @@ type UserHandler struct {
activityService service.UserActivityService activityService service.UserActivityService
profileAuditService service.UserProfileAuditService profileAuditService service.UserProfileAuditService
jwtService service.JWTService jwtService service.JWTService
sessionService service.SessionService
cache cache.Cache
logService *service.LogService logService *service.LogService
} }
@@ -62,6 +69,16 @@ func (h *UserHandler) SetActivityService(activityService service.UserActivitySer
h.activityService = activityService h.activityService = activityService
} }
// SetSessionService 设置会话服务(用于登出与令牌轮换)。
func (h *UserHandler) SetSessionService(sessionService service.SessionService) {
h.sessionService = sessionService
}
// SetCache 注入缓存实例(用于登出时失效 Principal/Session 缓存)。
func (h *UserHandler) SetCache(c cache.Cache) {
h.cache = c
}
// generateTokenID 生成Token ID // generateTokenID 生成Token ID
func generateTokenID(token string) string { func generateTokenID(token string) string {
h := sha256.New() h := sha256.New()
@@ -92,12 +109,16 @@ func (h *UserHandler) Register(c *gin.Context) {
return return
} }
// 生成Token // 签发带会话的令牌对sid 写入 JWT支持登出/封禁后撤销。
accessToken, _ := h.jwtService.GenerateAccessToken(user.ID, user.Username)
refreshToken, _ := h.jwtService.GenerateRefreshToken(user.ID, user.Username)
ip := c.ClientIP() ip := c.ClientIP()
userAgent := c.GetHeader("User-Agent") userAgent := c.GetHeader("User-Agent")
refreshExpire := time.Duration(7*24) * time.Hour
_, accessToken, refreshToken, err := h.sessionService.Issue(c.Request.Context(), user.ID, user.Username, userAgent, ip, refreshExpire)
if err != nil {
// 会话签发失败视为登录不可用,避免签发无 sid 的旧式令牌。
response.InternalServerError(c, "failed to issue session")
return
}
// 记录用户活跃 // 记录用户活跃
if h.activityService != nil { if h.activityService != nil {
@@ -161,12 +182,15 @@ func (h *UserHandler) Login(c *gin.Context) {
middleware.ResetLoginFailures(c.ClientIP()) middleware.ResetLoginFailures(c.ClientIP())
// 生成Token // 签发带会话的令牌对。
accessToken, _ := h.jwtService.GenerateAccessToken(user.ID, user.Username)
refreshToken, _ := h.jwtService.GenerateRefreshToken(user.ID, user.Username)
ip := c.ClientIP() ip := c.ClientIP()
userAgent := c.GetHeader("User-Agent") userAgent := c.GetHeader("User-Agent")
refreshExpire := time.Duration(7*24) * time.Hour
_, accessToken, refreshToken, err := h.sessionService.Issue(c.Request.Context(), user.ID, user.Username, userAgent, ip, refreshExpire)
if err != nil {
response.InternalServerError(c, "failed to issue session")
return
}
// 记录用户活跃 // 记录用户活跃
if h.activityService != nil { if h.activityService != nil {
@@ -179,8 +203,6 @@ func (h *UserHandler) Login(c *gin.Context) {
// 更新登录日志补充IP和UserAgent // 更新登录日志补充IP和UserAgent
if h.logService != nil { if h.logService != nil {
ip := c.ClientIP()
userAgent := c.GetHeader("User-Agent")
go func() { go func() {
h.logService.LoginLog.RecordLogin(&model.LoginLog{ h.logService.LoginLog.RecordLogin(&model.LoginLog{
UserID: user.ID, UserID: user.ID,
@@ -463,6 +485,11 @@ func (h *UserHandler) VerifyEmail(c *gin.Context) {
} }
// RefreshToken 刷新Token // RefreshToken 刷新Token
//
// 安全语义:
// - 仅接受 refresh tokenjwt.ParseRefreshToken 严格校验 typ=refresh
// - 通过 SessionService 校验会话未撤销未过期,并轮换会话(旧 refresh token 失效)。
// - 不接受 access tokenaccess token 不能在刷新端点换取新令牌。
func (h *UserHandler) RefreshToken(c *gin.Context) { func (h *UserHandler) RefreshToken(c *gin.Context) {
type RefreshRequest struct { type RefreshRequest struct {
RefreshToken string `json:"refresh_token" binding:"required"` RefreshToken string `json:"refresh_token" binding:"required"`
@@ -474,26 +501,34 @@ func (h *UserHandler) RefreshToken(c *gin.Context) {
return return
} }
// 解析 refresh token // 获取用户信息用于填充新令牌的 username 声明。
claims, err := h.jwtService.ParseToken(req.RefreshToken) // 先解析 refresh 拿 userID再查 DB不直接信任 JWT 中的 username。
claims, err := h.jwtService.ParseRefreshToken(req.RefreshToken)
if err != nil { if err != nil {
response.Unauthorized(c, "invalid refresh token") response.HandleError(c, apperrors.ErrInvalidToken, "invalid refresh token")
return return
} }
// 获取用户信息
user, err := h.userService.GetUserByID(c.Request.Context(), claims.UserID) user, err := h.userService.GetUserByID(c.Request.Context(), claims.UserID)
if err != nil { if err != nil {
response.InternalServerError(c, "user not found") response.HandleError(c, apperrors.ErrUserNotFound, "user not found")
return return
} }
// 生成新 token if user.Status != model.UserStatusActive && user.Status != model.UserStatusPendingDeletion {
accessToken, _ := h.jwtService.GenerateAccessToken(user.ID, user.Username) response.HandleError(c, apperrors.ErrUserBanned, "user banned")
refreshToken, _ := h.jwtService.GenerateRefreshToken(user.ID, user.Username) return
}
ip := c.ClientIP() ip := c.ClientIP()
userAgent := c.GetHeader("User-Agent") userAgent := c.GetHeader("User-Agent")
refreshExpire := time.Duration(7*24) * time.Hour
_, accessToken, refreshToken, err := h.sessionService.Rotate(c.Request.Context(), req.RefreshToken, user.Username, userAgent, ip, refreshExpire)
if err != nil {
response.HandleError(c, err, "failed to refresh token")
return
}
// 记录用户活跃 // 记录用户活跃
if h.activityService != nil { if h.activityService != nil {
@@ -801,9 +836,14 @@ func (h *UserHandler) SendChangePasswordCode(c *gin.Context) {
} }
// Logout 用户登出 // Logout 用户登出
//
// 安全语义:撤销当前会话,使 refresh token 立即失效。
// access token 在剩余 TTL 内仍可用(无状态 JWT 的固有限制),但 refresh token 已撤销,
// 攻击者无法用 refresh token 续期access token 也会在短 TTL 内自然过期。
// 如需 access token 即时失效,可后续引入 jti 黑名单。
func (h *UserHandler) Logout(c *gin.Context) { func (h *UserHandler) Logout(c *gin.Context) {
currentUserID := c.GetString("user_id") principal, ok := auth.GetPrincipal(c)
if currentUserID == "" { if !ok || principal == nil {
response.Unauthorized(c, "not logged in") response.Unauthorized(c, "not logged in")
return return
} }
@@ -811,8 +851,23 @@ func (h *UserHandler) Logout(c *gin.Context) {
ip := c.ClientIP() ip := c.ClientIP()
userAgent := c.GetHeader("User-Agent") userAgent := c.GetHeader("User-Agent")
// 撤销当前会话。
if h.sessionService != nil && principal.SessionID != "" {
if err := h.sessionService.Revoke(c.Request.Context(), principal.SessionID); err != nil {
// 撤销失败不阻塞登出流程,但记日志便于排查。
zap.L().Warn("failed to revoke session on logout",
zap.String("session_id", principal.SessionID),
zap.Error(err),
)
}
}
// 失效 Principal/Session 缓存,避免登出后短时间内旧 token 仍命中缓存。
auth.InvalidatePrincipalCache(h.cache, principal.UserID)
auth.InvalidateSessionCache(h.cache, principal.SessionID)
// 获取用户信息 // 获取用户信息
user, err := h.userService.GetUserByID(c.Request.Context(), currentUserID) user, err := h.userService.GetUserByID(c.Request.Context(), principal.UserID)
if err != nil { if err != nil {
response.InternalServerError(c, "user not found") response.InternalServerError(c, "user not found")
return return

View File

@@ -125,7 +125,8 @@ func (h *WSHandler) HandleWebSocket(c *gin.Context) {
return return
} }
claims, err := h.jwtService.ParseToken(token) // 仅接受 access token防止 refresh token 通过 WebSocket 入口绕过限制。
claims, err := h.jwtService.ParseAccessToken(token)
if err != nil { if err != nil {
response.Unauthorized(c, "invalid token") response.Unauthorized(c, "invalid token")
return return

View File

@@ -0,0 +1,35 @@
package middleware
import (
"net/http"
"github.com/gin-gonic/gin"
"with_you/internal/pkg/auth"
"with_you/internal/pkg/response"
)
// RequireActive 要求账户状态为 active。
//
// 业务策略:
// - active放行。
// - pending_deletion在 /users/me/* 路由允许(用户可取消注销),但在 /admin/* 与敏感操作拒绝。
// - banned/inactive已由 RequireAuth 在认证管道拦截,不会到达此处。
//
// 应挂在需要严格 active 状态的路由组上(如 /admin
func RequireActive() gin.HandlerFunc {
return func(c *gin.Context) {
p, ok := auth.GetPrincipal(c)
if !ok || p == nil {
response.ErrorWithStringCode(c, http.StatusUnauthorized, "UNAUTHORIZED", "未授权")
c.Abort()
return
}
if !p.IsActive() {
response.ErrorWithStringCode(c, http.StatusForbidden, "ACCOUNT_INACTIVE", "账号未激活")
c.Abort()
return
}
c.Next()
}
}

View File

@@ -1,76 +1,6 @@
package middleware // Package middleware 提供路由层中间件。
//
import ( // 注意:旧的 Auth/OptionalAuth 函数已被 auth_pipeline.go 中的
"strings" // RequireAuth / OptionalAuth 替代,新版本内置令牌类型校验、账户状态校验与会话校验。
// 路由层应使用 RequireAuth(jwt, idp, cache) 与 OptionalAuth(jwt, idp, cache)。
"github.com/gin-gonic/gin" package middleware
"with_you/internal/pkg/response"
"with_you/internal/service"
)
// Auth 认证中间件
func Auth(jwtService service.JWTService) gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
response.Unauthorized(c, "authorization header is required")
c.Abort()
return
}
// 提取Token
prefix, token, found := strings.Cut(authHeader, " ")
if !found || prefix != "Bearer" {
response.Unauthorized(c, "invalid authorization header format")
c.Abort()
return
}
// 验证Token
claims, err := jwtService.ParseToken(token)
if err != nil {
response.Unauthorized(c, "invalid token")
c.Abort()
return
}
// 将用户信息存入上下文
c.Set("user_id", claims.UserID)
c.Set("username", claims.Username)
c.Next()
}
}
// OptionalAuth 可选认证中间件
func OptionalAuth(jwtService service.JWTService) gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.Next()
return
}
// 提取Token
prefix, token, found := strings.Cut(authHeader, " ")
if !found || prefix != "Bearer" {
c.Next()
return
}
// 验证Token
claims, err := jwtService.ParseToken(token)
if err != nil {
c.Next()
return
}
// 将用户信息存入上下文
c.Set("user_id", claims.UserID)
c.Set("username", claims.Username)
c.Next()
}
}

View File

@@ -0,0 +1,287 @@
package middleware
import (
"context"
"errors"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"gorm.io/gorm"
"with_you/internal/cache"
apperrors "with_you/internal/errors"
"with_you/internal/model"
"with_you/internal/pkg/auth"
"with_you/internal/pkg/jwt"
"with_you/internal/pkg/response"
"with_you/internal/service"
)
// 认证管道相关常量。
const (
// principalCacheTTL Principal 缓存 TTL账户状态/角色变更后最多延迟生效时间。
// 30s 内可容忍多一次 DB 查询,平衡安全即时性与登录热点流量。
principalCacheTTL = 30 * time.Second
// sessionCacheTTL 会话撤销状态缓存 TTL登出/封禁后旧 token 在此时长内可能仍被放行。
// 由于 session 表由服务端控制30s 内的撤销延迟可接受;后续可调整为 0不缓存
sessionCacheTTL = 30 * time.Second
)
// IdentityProvider 认证管道依赖的身份与角色查询接口。
//
// 由 service 层实现并注入,避免中间件直接依赖具体 service
// 同时便于测试以 fake 注入。
type IdentityProvider interface {
// GetUserByID 加载用户实体含状态、verification_status
GetUserByID(ctx context.Context, userID string) (*model.User, error)
// GetRolesForUser 返回用户的角色集合。
GetRolesForUser(ctx context.Context, userID string) ([]string, error)
// GetSessionByID 返回会话实体(用于校验是否撤销/过期)。
// 当 sessionID 为空(旧 access token 兼容窗口)应返回 nil,nil。
GetSessionByID(ctx context.Context, sessionID string) (*model.Session, error)
}
// principalCacheValue 缓存 Principal 的载体,便于区分 hit/miss 与缓存空角色。
type principalCacheValue struct {
Principal *auth.Principal
}
// RequireAuth 强制认证中间件。
//
// 流程:
// 1. 提取 Bearer token缺失 → 401。
// 2. ParseAccessToken拒绝 refresh token
// 3. 从 cache/DB 加载用户与角色,组装 Principal。
// 4. 校验账户状态active 允许pending_deletion 允许访问(业务策略:允许取消注销);
// banned/inactive/其他 → 403。
// 5. 校验 sessionIDsid 非空时):撤销/过期 → 401。
// 6. Principal 写入 context。
func RequireAuth(jwtService service.JWTService, idp IdentityProvider, cache cache.Cache) gin.HandlerFunc {
return func(c *gin.Context) {
claims, ok := extractAccessToken(c, jwtService)
if !ok {
authDeny(c, http.StatusUnauthorized, apperrors.ErrInvalidToken, "")
return
}
principal, err := buildPrincipal(c.Request.Context(), idp, cache, claims)
if err != nil {
// 用户不存在或加载失败:以 401 处理(令牌虽有效但身份已失效)。
authDeny(c, http.StatusUnauthorized, apperrors.ErrInvalidToken, claims.UserID)
return
}
if err := checkAccountStatus(principal); err != nil {
authDeny(c, http.StatusForbidden, err, principal.UserID)
return
}
if err := validateSession(c.Request.Context(), idp, cache, principal); err != nil {
authDeny(c, http.StatusUnauthorized, err, principal.UserID)
return
}
auth.WithContext(c, principal)
c.Next()
}
}
// OptionalAuth 可选认证中间件。
//
// 行为:
// - 缺失 Authorization 头 → 当游客Principal 不写入 contextc.Next()。
// - 携带了 token 但解析/状态/会话失败 → 401语义更清晰避免静默降级到游客
func OptionalAuth(jwtService service.JWTService, idp IdentityProvider, cache cache.Cache) gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.Next()
return
}
// 格式明显错误(非 Bearer→ 当游客,保持现有游客可见接口语义。
prefix, token, found := strings.Cut(authHeader, " ")
if !found || prefix != "Bearer" || token == "" {
c.Next()
return
}
claims, err := jwtService.ParseAccessToken(token)
if err != nil {
authDeny(c, http.StatusUnauthorized, apperrors.ErrInvalidToken, "")
return
}
principal, err := buildPrincipal(c.Request.Context(), idp, cache, claims)
if err != nil {
authDeny(c, http.StatusUnauthorized, apperrors.ErrInvalidToken, claims.UserID)
return
}
if err := checkAccountStatus(principal); err != nil {
authDeny(c, http.StatusForbidden, err, principal.UserID)
return
}
if err := validateSession(c.Request.Context(), idp, cache, principal); err != nil {
authDeny(c, http.StatusUnauthorized, err, principal.UserID)
return
}
auth.WithContext(c, principal)
c.Next()
}
}
// extractAccessToken 从请求中提取并解析 access token。
//
// 第二返回值 ok=false 表示应当拒绝已写响应true 表示已拿到 claims。
func extractAccessToken(c *gin.Context, jwtService service.JWTService) (*jwt.Claims, bool) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
return nil, false
}
prefix, token, found := strings.Cut(authHeader, " ")
if !found || prefix != "Bearer" || token == "" {
return nil, false
}
claims, err := jwtService.ParseAccessToken(token)
if err != nil {
return nil, false
}
return claims, true
}
// buildPrincipal 从缓存或 IDP 加载用户/角色,组装 Principal。
func buildPrincipal(ctx context.Context, idp IdentityProvider, cch cache.Cache, claims *jwt.Claims) (*auth.Principal, error) {
cacheKey := ""
if cch != nil {
cacheKey = auth.PrincipalCacheKey(claims.UserID)
if cached, ok := cch.Get(cacheKey); ok {
if v, ok := cached.(*principalCacheValue); ok && v != nil {
return v.Principal, nil
}
}
}
user, err := idp.GetUserByID(ctx, claims.UserID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, apperrors.ErrUserNotFound
}
return nil, err
}
roles, err := idp.GetRolesForUser(ctx, claims.UserID)
if err != nil {
return nil, err
}
if roles == nil {
roles = []string{}
}
p := &auth.Principal{
UserID: user.ID,
Username: user.Username,
SessionID: claims.SessionID,
Status: user.Status,
VerificationStatus: user.VerificationStatus,
Roles: roles,
TokenID: claims.ID,
IsLegacyToken: claims.TokenType == "",
}
if cacheKey != "" {
cch.Set(cacheKey, &principalCacheValue{Principal: p}, principalCacheTTL)
}
return p, nil
}
// checkAccountStatus 校验账户状态。
//
// 策略active 允许pending_deletion 允许(用户可访问取消注销接口);
// banned/inactive/其他拒绝。pending_deletion 在管理后台路由由 RequireActive 单独再加一道。
func checkAccountStatus(p *auth.Principal) error {
switch p.Status {
case model.UserStatusActive, model.UserStatusPendingDeletion:
return nil
case model.UserStatusBanned:
return apperrors.ErrUserBanned
case model.UserStatusInactive:
return apperrors.ErrAccountInactive
default:
return apperrors.ErrAccountInactive
}
}
// validateSession 校验会话有效性。
//
// 旧 access token无 sid在兼容窗口内跳过会话校验由 access TTL 自然过期收口。
func validateSession(ctx context.Context, idp IdentityProvider, cch cache.Cache, p *auth.Principal) error {
if p.SessionID == "" {
// 旧 token 兼容窗口。
return nil
}
if cch != nil {
key := auth.SessionCacheKey(p.SessionID)
if cached, ok := cch.Get(key); ok {
if valid, ok := cached.(bool); ok && !valid {
return apperrors.ErrSessionRevoked
}
}
}
session, err := idp.GetSessionByID(ctx, p.SessionID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return apperrors.ErrSessionRevoked
}
return err
}
if session == nil || !session.IsValid(time.Now()) {
if cch != nil {
cch.Set(auth.SessionCacheKey(p.SessionID), false, sessionCacheTTL)
}
return apperrors.ErrSessionRevoked
}
if cch != nil {
cch.Set(auth.SessionCacheKey(p.SessionID), true, sessionCacheTTL)
}
return nil
}
// InvalidatePrincipalCache 与 InvalidateSessionCache 已迁移至 internal/pkg/auth 包,
// 以避免 service → middleware 的包循环service 层修改用户状态后需要失效缓存,
// 但 middleware 又依赖 service 接口)。
//
// 保留本文件中的 InvalidatePrincipalCache/InvalidateSessionCache 仅作为转发会引入
// 循环,故直接删除;调用方请使用 auth.InvalidatePrincipalCache / auth.InvalidateSessionCache。
// authDeny 拒绝访问的统一响应:记日志 + 返回结构化错误。
func authDeny(c *gin.Context, statusCode int, appErr error, userID string) {
zap.L().Warn("auth denied",
zap.Int("status", statusCode),
zap.String("user_id", userID),
zap.String("ip", c.ClientIP()),
zap.String("user_agent", c.GetHeader("User-Agent")),
zap.String("path", c.Request.URL.Path),
zap.Any("err", appErr),
)
code := apperrors.ErrInvalidToken.Code
msg := apperrors.ErrInvalidToken.Message
var target *apperrors.AppError
if errors.As(appErr, &target) {
code = target.Code
msg = target.Message
}
response.ErrorWithStringCode(c, statusCode, code, msg)
c.Abort()
}

View File

@@ -0,0 +1,225 @@
package middleware
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"with_you/internal/model"
"with_you/internal/pkg/jwt"
"with_you/internal/service"
)
// fakeIDP 实现 IdentityProvider 接口,用于测试。
type fakeIDP struct {
user *model.User
userErr error
roles []string
roleErr error
session *model.Session
sessErr error
}
func (f *fakeIDP) GetUserByID(ctx context.Context, id string) (*model.User, error) {
if f.userErr != nil {
return nil, f.userErr
}
return f.user, nil
}
func (f *fakeIDP) GetRolesForUser(ctx context.Context, id string) ([]string, error) {
if f.roleErr != nil {
return nil, f.roleErr
}
if f.roles == nil {
return []string{}, nil
}
return f.roles, nil
}
func (f *fakeIDP) GetSessionByID(ctx context.Context, id string) (*model.Session, error) {
if f.sessErr != nil {
return nil, f.sessErr
}
return f.session, nil
}
// newTestJWT 构造测试用 JWT 服务。
func newTestJWT() service.JWTService {
return service.NewJWTService("test-secret", 3600, 86400)
}
// runRequireAuth 挂载 RequireAuth 并发起请求,返回 (status, principal)。
//
// Authorization 头直接设在 req 上;旧实现里 r.Use 内重复 Set 对 NewRecorder 无效,已移除。
func runRequireAuth(t *testing.T, jwtSvc service.JWTService, idp IdentityProvider, token string) (int, *testPrincipal) {
t.Helper()
gin.SetMode(gin.TestMode)
nextCalled := false
r := gin.New()
r.GET("/test", RequireAuth(jwtSvc, idp, nil), func(c *gin.Context) {
nextCalled = true
c.JSON(http.StatusOK, gin.H{"ok": true})
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/test", nil)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
r.ServeHTTP(w, req)
return w.Code, &testPrincipal{called: nextCalled}
}
type testPrincipal struct {
called bool
}
// TestRequireAuth_MissingHeader 缺失 Authorization → 401。
func TestRequireAuth_MissingHeader(t *testing.T) {
jwtSvc := newTestJWT()
idp := &fakeIDP{user: &model.User{ID: "u1", Status: model.UserStatusActive}}
code, _ := runRequireAuth(t, jwtSvc, idp, "")
if code != http.StatusUnauthorized {
t.Errorf("status = %d, want %d", code, http.StatusUnauthorized)
}
}
// TestRequireAuth_RefreshTokenRejected refresh token 走 RequireAuth → 401。
func TestRequireAuth_RefreshTokenRejected(t *testing.T) {
jwtSvc := newTestJWT()
// 用 SessionService.Issue 签发带 sid 的 refresh token 比较繁琐,这里直接用 JWT 层签发。
jwtLib := jwt.New("test-secret", time.Hour, time.Hour)
refreshTok, _ := jwtLib.GenerateRefreshToken("u1", "alice", "s1")
idp := &fakeIDP{user: &model.User{ID: "u1", Status: model.UserStatusActive}, session: &model.Session{ID: "s1", ExpiresAt: time.Now().Add(time.Hour)}}
code, _ := runRequireAuth(t, jwtSvc, idp, refreshTok)
if code != http.StatusUnauthorized {
t.Errorf("refresh token should be rejected, status = %d, want %d", code, http.StatusUnauthorized)
}
}
// TestRequireAuth_BannedUser banned 用户 → 403。
func TestRequireAuth_BannedUser(t *testing.T) {
jwtSvc := newTestJWT()
jwtLib := jwt.New("test-secret", time.Hour, time.Hour)
accessTok, _ := jwtLib.GenerateAccessToken("u1", "alice", "s1")
idp := &fakeIDP{
user: &model.User{ID: "u1", Status: model.UserStatusBanned},
roles: []string{},
session: &model.Session{ID: "s1", ExpiresAt: time.Now().Add(time.Hour)},
}
code, _ := runRequireAuth(t, jwtSvc, idp, accessTok)
if code != http.StatusForbidden {
t.Errorf("banned user should be 403, status = %d, want %d", code, http.StatusForbidden)
}
}
// TestRequireAuth_RevokedSession 会话已撤销 → 401。
func TestRequireAuth_RevokedSession(t *testing.T) {
jwtSvc := newTestJWT()
jwtLib := jwt.New("test-secret", time.Hour, time.Hour)
accessTok, _ := jwtLib.GenerateAccessToken("u1", "alice", "s1")
revokedAt := time.Now().Add(-time.Minute)
idp := &fakeIDP{
user: &model.User{ID: "u1", Status: model.UserStatusActive},
roles: []string{},
session: &model.Session{ID: "s1", ExpiresAt: time.Now().Add(time.Hour), RevokedAt: &revokedAt},
}
code, _ := runRequireAuth(t, jwtSvc, idp, accessTok)
if code != http.StatusUnauthorized {
t.Errorf("revoked session should be 401, status = %d, want %d", code, http.StatusUnauthorized)
}
}
// TestRequireAuth_UserNotFound 用户不存在 → 401。
func TestRequireAuth_UserNotFound(t *testing.T) {
jwtSvc := newTestJWT()
jwtLib := jwt.New("test-secret", time.Hour, time.Hour)
accessTok, _ := jwtLib.GenerateAccessToken("u1", "alice", "s1")
idp := &fakeIDP{
userErr: gorm.ErrRecordNotFound,
}
code, _ := runRequireAuth(t, jwtSvc, idp, accessTok)
if code != http.StatusUnauthorized {
t.Errorf("user not found should be 401, status = %d, want %d", code, http.StatusUnauthorized)
}
}
// TestRequireAuth_ValidAccess 正常 access token → 200 且 next 被调用。
func TestRequireAuth_ValidAccess(t *testing.T) {
jwtSvc := newTestJWT()
jwtLib := jwt.New("test-secret", time.Hour, time.Hour)
accessTok, _ := jwtLib.GenerateAccessToken("u1", "alice", "s1")
idp := &fakeIDP{
user: &model.User{ID: "u1", Status: model.UserStatusActive},
roles: []string{model.RoleUser},
session: &model.Session{ID: "s1", ExpiresAt: time.Now().Add(time.Hour)},
}
// 这里需要校验 next 被调用,所以用更完整的测试。
gin.SetMode(gin.TestMode)
nextCalled := false
r := gin.New()
r.GET("/test", RequireAuth(jwtSvc, idp, nil), func(c *gin.Context) {
nextCalled = true
c.JSON(http.StatusOK, gin.H{"ok": true})
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/test", nil)
req.Header.Set("Authorization", "Bearer "+accessTok)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("valid access token should be 200, status = %d, want %d", w.Code, http.StatusOK)
}
if !nextCalled {
t.Error("next handler should be called")
}
}
// TestRequireAuth_LegacyToken 旧 access token无 sid宽容通过。
func TestRequireAuth_LegacyToken(t *testing.T) {
jwtSvc := newTestJWT()
jwtLib := jwt.New("test-secret", time.Hour, time.Hour)
// 签发不带 sid 的 access token模拟旧客户端
accessTok, _ := jwtLib.GenerateAccessToken("u1", "alice", "")
idp := &fakeIDP{
user: &model.User{ID: "u1", Status: model.UserStatusActive},
roles: []string{},
// 旧 token 无 sidGetSessionByID 不应被调用。
}
gin.SetMode(gin.TestMode)
nextCalled := false
r := gin.New()
r.GET("/test", RequireAuth(jwtSvc, idp, nil), func(c *gin.Context) {
nextCalled = true
c.JSON(http.StatusOK, gin.H{"ok": true})
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/test", nil)
req.Header.Set("Authorization", "Bearer "+accessTok)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("legacy access token should be 200 (compat window), status = %d", w.Code)
}
if !nextCalled {
t.Error("next handler should be called for legacy token")
}
}
// fakeIDP 的 session 路径校验:确保不传 session 时也不影响。
var _ = errors.New

View File

@@ -0,0 +1,50 @@
package middleware
import (
"net/http"
"github.com/gin-gonic/gin"
apperrors "with_you/internal/errors"
"with_you/internal/pkg/auth"
"with_you/internal/pkg/response"
"with_you/internal/service"
)
// Authorize 基于 Casbin 资源/动作策略的路由级授权中间件。
//
// 用法:挂在 RequireAuth 之后,按路由标注 (resource, action)。
// admin.POST("/users/:id/roles", middleware.Authorize(casbin, "admin/users/roles", "write"), h.AssignRole)
//
// 校验流程:
// 1. 从 context 取出 Principal由 RequireAuth 写入)。
// 2. 调 CasbinService.EnforceForUser(userID, resource, action)。
// - 内部先查 user_roles 表得到角色集合,再对每个角色调 Enforce(role, resource, action)。
// - super_admin 通过 admin/** 通配策略获得所有 admin 能力。
// 3. 不匹配 → 403 PERMISSION_DENIED。
//
// 纵深防御service 层对最敏感操作(如分配 super_admin应再次校验 operator 角色,
// 即使路由 Authorize 漏配Service 仍能拦截。
func Authorize(casbinService service.CasbinService, resource, action string) gin.HandlerFunc {
return func(c *gin.Context) {
p, ok := auth.GetPrincipal(c)
if !ok || p == nil {
response.ErrorWithStringCode(c, http.StatusUnauthorized, "UNAUTHORIZED", "未授权")
c.Abort()
return
}
allowed, err := casbinService.EnforceForUser(c.Request.Context(), p.UserID, resource, action)
if err != nil {
response.ErrorWithStringCode(c, http.StatusInternalServerError, "CASBIN_INTERNAL_ERROR", "权限系统内部错误")
c.Abort()
return
}
if !allowed {
response.ErrorWithStringCode(c, http.StatusForbidden, apperrors.ErrPermissionDenied.Code, apperrors.ErrPermissionDenied.Message)
c.Abort()
return
}
c.Next()
}
}

55
internal/model/session.go Normal file
View File

@@ -0,0 +1,55 @@
package model
import (
"time"
"gorm.io/gorm"
)
// Session 用户登录会话
//
// 用于支持令牌撤销:
// - access token 携带 SessionIDJWT sid中间件校验会话是否仍有效。
// - refresh token 同样携带 SessionID刷新时轮换并校验。
// - 登出/封禁时撤销会话,旧 token 在 access TTL 内可见但 refresh 立即失效。
//
// RefreshTokenHash 存储 refresh token 的 SHA256 哈希,便于按 refresh token 反查会话,
// 不直接存储 token 明文以降低泄露风险。
type Session struct {
ID string `json:"id" gorm:"type:varchar(36);primaryKey"`
UserID string `json:"user_id" gorm:"type:varchar(36);index;not null"`
RefreshTokenHash string `json:"-" gorm:"type:varchar(64);uniqueIndex;not null"`
UserAgent string `json:"user_agent" gorm:"type:varchar(255)"`
IP string `json:"ip" gorm:"type:varchar(45)"`
IssuedAt time.Time `json:"issued_at"`
ExpiresAt time.Time `json:"expires_at" gorm:"index"`
RevokedAt *time.Time `json:"revoked_at,omitempty" gorm:"index"`
LastUsedAt time.Time `json:"last_used_at"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
}
func (Session) TableName() string {
return "sessions"
}
// BeforeCreate 创建前生成 UUID。
func (s *Session) BeforeCreate(tx *gorm.DB) error {
SetUUIDIfEmpty(&s.ID)
return nil
}
// IsRevoked 会话是否已撤销。
func (s *Session) IsRevoked() bool {
return s != nil && s.RevokedAt != nil
}
// IsExpired 会话是否已过期(按 ExpiresAt
func (s *Session) IsExpired(now time.Time) bool {
return s != nil && !s.ExpiresAt.IsZero() && now.After(s.ExpiresAt)
}
// IsValid 会话是否仍有效(未撤销且未过期)。
func (s *Session) IsValid(now time.Time) bool {
return s != nil && !s.IsRevoked() && !s.IsExpired(now)
}

View File

@@ -0,0 +1,22 @@
package auth
import "with_you/internal/cache"
// InvalidatePrincipalCache 失效用户的 Principal 缓存。
//
// 供角色变更、账户状态变更、登出等场景调用:让 RequireAuth 在下一次请求
// 重新从 IDP 加载用户与角色。键定义在本包以避免 service → middleware 的包循环。
func InvalidatePrincipalCache(cch cache.Cache, userID string) {
if cch == nil || userID == "" {
return
}
cch.Delete(PrincipalCacheKey(userID))
}
// InvalidateSessionCache 失效会话有效性缓存。
func InvalidateSessionCache(cch cache.Cache, sessionID string) {
if cch == nil || sessionID == "" {
return
}
cch.Delete(SessionCacheKey(sessionID))
}

View File

@@ -0,0 +1,116 @@
// Package auth 提供统一的安全状态载体与认证管道。
//
// Principal 是贯穿 middleware → handler → service 的类型化身份契约,
// 取代散落的 c.Set("user_id", ...) 字符串约定,让账户状态/角色/会话在编译期显式可见。
package auth
import (
"github.com/gin-gonic/gin"
"with_you/internal/model"
)
const (
// ContextKey 是 Principal 在 gin.Context 中的键。
// 使用私有类型别名以避免与其他包的字符串键冲突。
contextKey principalCtxKey = "auth_principal"
)
type principalCtxKey string
// Principal 已认证主体
//
// 由 RequireAuth 中间件一次性组装完成,包含:
// - 身份信息UserID / Username / SessionID
// - 账户状态Status / VerificationStatus
// - 角色集合Roles由 Casbin/App DB 加载)
//
// 不再由各 handler/service 重复查询数据库,统一由认证管道负责加载与缓存。
type Principal struct {
UserID string
Username string
SessionID string
Status model.UserStatus
VerificationStatus model.VerificationStatus
Roles []string
TokenID string
IsLegacyToken bool // 旧客户端签发的 access token无 typ/sid过渡期宽容通过
}
// HasRole 检查主体是否拥有指定角色。
func (p *Principal) HasRole(role string) bool {
if p == nil {
return false
}
for _, r := range p.Roles {
if r == role {
return true
}
}
return false
}
// IsSuperAdmin 是否超级管理员。
func (p *Principal) IsSuperAdmin() bool {
return p.HasRole(model.RoleSuperAdmin)
}
// IsAdmin 是否管理员(含超级管理员)。
func (p *Principal) IsAdmin() bool {
return p.HasRole(model.RoleAdmin) || p.IsSuperAdmin()
}
// IsActive 账户状态是否为 active。
func (p *Principal) IsActive() bool {
return p != nil && p.Status == model.UserStatusActive
}
// IsVerified 是否已通过身份认证。
func (p *Principal) IsVerified() bool {
return p != nil && p.VerificationStatus == model.VerificationStatusApproved
}
// WithContext 把 Principal 写入 gin.Context。
func WithContext(c *gin.Context, p *Principal) {
c.Set(string(contextKey), p)
// 同时保留 user_id / username 字符串键以兼容存量 handler 调用。
c.Set("user_id", p.UserID)
c.Set("username", p.Username)
}
// GetPrincipal 从 gin.Context 读取 Principal返回是否已认证。
func GetPrincipal(c *gin.Context) (*Principal, bool) {
v, ok := c.Get(string(contextKey))
if !ok {
return nil, false
}
p, ok := v.(*Principal)
return p, ok
}
// MustPrincipal 从 gin.Context 读取 Principal未认证时返回 nil。
// 调用方应仅在 RequireAuth 之后使用。
func MustPrincipal(c *gin.Context) *Principal {
p, _ := GetPrincipal(c)
return p
}
// PrincipalCacheKey 返回某用户的 Principal 缓存键。
//
// 缓存键与 middleware.RequireAuth / InvalidatePrincipalCache 共享,
// 定义在本包以避免 service → middleware 的包循环service 仅需失效键,
// middleware 负责读写 Principal 实体)。
func PrincipalCacheKey(userID string) string {
return "auth:principal:" + userID
}
// SessionCacheKey 返回某会话有效性缓存键。
func SessionCacheKey(sessionID string) string {
return "auth:session:" + sessionID + ":valid"
}
// CacheKeyPrefix 命名空间前缀,用于按前缀批量失效。
const (
CacheKeyPrefixPrincipal = "auth:principal:"
CacheKeyPrefixSession = "auth:session:"
)

View File

@@ -2,20 +2,38 @@ package jwt
import ( import (
"errors" "errors"
"fmt"
"time" "time"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
) )
// 令牌类型常量。引入显式 typ 字段,避免 access/refresh 仅靠 RegisteredClaims.ID 隐式区分。
const (
TokenTypeAccess = "access"
TokenTypeRefresh = "refresh"
)
var ( var (
ErrInvalidToken = errors.New("invalid token") ErrInvalidToken = errors.New("invalid token")
ErrExpiredToken = errors.New("token has expired") ErrExpiredToken = errors.New("token has expired")
ErrWrongTokenType = errors.New("wrong token type")
ErrLegacyToken = errors.New("legacy token without type")
) )
// Claims JWT 声明 // Claims JWT 声明
//
// 引入 TokenType 与 SessionID 字段,使令牌用途与会话在编译期显式可见:
// - TokenType: "access" 或 "refresh",由 ParseAccessToken/ParseRefreshToken 严格校验。
// - SessionID: 关联 sessions 表,支持登出/封禁后的令牌撤销。
//
// 旧客户端签发的 token 不含 typ/sidParseAccessToken 在过渡期宽容通过;
// ParseRefreshToken 始终严格要求 typ=refresh强制客户端重新登录获取新 refresh token。
type Claims struct { type Claims struct {
UserID string `json:"user_id"` UserID string `json:"sub"`
Username string `json:"username"` Username string `json:"username"`
TokenType string `json:"typ,omitempty"`
SessionID string `json:"sid,omitempty"`
jwt.RegisteredClaims jwt.RegisteredClaims
} }
@@ -36,11 +54,15 @@ func New(secret string, accessExpire, refreshExpire time.Duration) *JWT {
} }
// GenerateAccessToken 生成访问令牌 // GenerateAccessToken 生成访问令牌
func (j *JWT) GenerateAccessToken(userID, username string) (string, error) { //
// sessionID 关联 sessions 表用于撤销;空字符串仅在测试或无 session 模型时允许。
func (j *JWT) GenerateAccessToken(userID, username, sessionID string) (string, error) {
now := time.Now() now := time.Now()
claims := Claims{ claims := Claims{
UserID: userID, UserID: userID,
Username: username, Username: username,
TokenType: TokenTypeAccess,
SessionID: sessionID,
RegisteredClaims: jwt.RegisteredClaims{ RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(j.accessTokenExpire)), ExpiresAt: jwt.NewNumericDate(now.Add(j.accessTokenExpire)),
IssuedAt: jwt.NewNumericDate(now), IssuedAt: jwt.NewNumericDate(now),
@@ -54,17 +76,20 @@ func (j *JWT) GenerateAccessToken(userID, username string) (string, error) {
} }
// GenerateRefreshToken 生成刷新令牌 // GenerateRefreshToken 生成刷新令牌
func (j *JWT) GenerateRefreshToken(userID, username string) (string, error) { //
// refresh token 必须携带 sessionID刷新时用于校验会话有效性并支持轮换撤销。
func (j *JWT) GenerateRefreshToken(userID, username, sessionID string) (string, error) {
now := time.Now() now := time.Now()
claims := Claims{ claims := Claims{
UserID: userID, UserID: userID,
Username: username, Username: username,
TokenType: TokenTypeRefresh,
SessionID: sessionID,
RegisteredClaims: jwt.RegisteredClaims{ RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(j.refreshTokenExpire)), ExpiresAt: jwt.NewNumericDate(now.Add(j.refreshTokenExpire)),
IssuedAt: jwt.NewNumericDate(now), IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now), NotBefore: jwt.NewNumericDate(now),
Issuer: "carrot_bbs", Issuer: "carrot_bbs",
ID: "refresh",
}, },
} }
@@ -72,8 +97,8 @@ func (j *JWT) GenerateRefreshToken(userID, username string) (string, error) {
return token.SignedString([]byte(j.secretKey)) return token.SignedString([]byte(j.secretKey))
} }
// ParseToken 解析令牌 // parseWithKey 解析 token 字符串为 Claims仅做签名与过期校验。
func (j *JWT) ParseToken(tokenString string) (*Claims, error) { func (j *JWT) parseWithKey(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
return []byte(j.secretKey), nil return []byte(j.secretKey), nil
}) })
@@ -89,17 +114,75 @@ func (j *JWT) ParseToken(tokenString string) (*Claims, error) {
return nil, ErrInvalidToken return nil, ErrInvalidToken
} }
// ValidateToken 验证令牌 // ParseAccessToken 仅接受 typ=access 的令牌
func (j *JWT) ValidateToken(tokenString string) error { //
claims, err := j.ParseToken(tokenString) // 过渡兼容:若 typ 为空(旧客户端签发),宽容通过并返回 ErrLegacyToken 包装的错误,
// 调用方可据此打告警日志;旧 token 在一个 access TTL 周期内自然过期后即不再被宽容。
// 不接受 refresh token明确返回 ErrWrongTokenType。
func (j *JWT) ParseAccessToken(tokenString string) (*Claims, error) {
claims, err := j.parseWithKey(tokenString)
if err != nil { if err != nil {
return err return nil, err
} }
// 检查是否是刷新令牌 switch claims.TokenType {
if claims.ID == "refresh" { case TokenTypeAccess:
return errors.New("cannot use refresh token as access token") return claims, nil
case "":
// 旧客户端兼容窗口:旧 token 不含 typ仅在剩余 TTL 内通过。
return claims, fmt.Errorf("%w: sub=%s", ErrLegacyToken, claims.UserID)
default:
return nil, ErrWrongTokenType
}
} }
// ParseRefreshToken 仅接受 typ=refresh 的令牌,且必须有 SessionID。
//
// refresh 端点不接受旧格式 token强制客户端重新登录获取新 refresh token。
func (j *JWT) ParseRefreshToken(tokenString string) (*Claims, error) {
claims, err := j.parseWithKey(tokenString)
if err != nil {
return nil, err
}
if claims.TokenType != TokenTypeRefresh {
return nil, ErrWrongTokenType
}
if claims.SessionID == "" {
// 防御性:新代码签发的 refresh token 一定带 sid。
return nil, ErrInvalidToken
}
return claims, nil
}
// ParseToken 解析令牌(不校验类型)
//
// Deprecated: 仅供历史调用点逐步迁移。新代码应使用 ParseAccessToken/ParseRefreshToken。
// 当前实现内部调用 ParseAccessToken 以确保 refresh token 不能悄悄通过 Auth 中间件;
// 旧格式 access token 仍可被解析typ 为空时返回 claims 与 ErrLegacyToken
func (j *JWT) ParseToken(tokenString string) (*Claims, error) {
claims, err := j.parseWithKey(tokenString)
if err != nil {
return nil, err
}
// 拒绝 refresh token避免历史调用点误把它当 access token 使用。
if claims.TokenType == TokenTypeRefresh {
return nil, ErrWrongTokenType
}
return claims, nil
}
// ValidateToken 验证令牌是否可作为 access token 使用。
//
// Deprecated: 仅供历史调用点逐步迁移。新代码应使用 ParseAccessToken。
func (j *JWT) ValidateToken(tokenString string) error {
_, err := j.ParseAccessToken(tokenString)
if err == nil {
return nil return nil
} }
if errors.Is(err, ErrLegacyToken) {
// 旧 token 在兼容窗口内视为有效。
return nil
}
return err
}

View File

@@ -0,0 +1,120 @@
package jwt
import (
"errors"
"testing"
"time"
)
// TestParseAccessToken_AcceptsAccessToken access token 通过 ParseAccessToken。
func TestParseAccessToken_AcceptsAccessToken(t *testing.T) {
j := New("test-secret", 3600*time.Second, 86400*time.Second)
tok, err := j.GenerateAccessToken("u1", "alice", "s1")
if err != nil {
t.Fatalf("generate access token: %v", err)
}
claims, err := j.ParseAccessToken(tok)
if err != nil {
t.Fatalf("parse access token should succeed: %v", err)
}
if claims.UserID != "u1" {
t.Errorf("user_id = %s, want u1", claims.UserID)
}
if claims.TokenType != TokenTypeAccess {
t.Errorf("typ = %s, want %s", claims.TokenType, TokenTypeAccess)
}
if claims.SessionID != "s1" {
t.Errorf("sid = %s, want s1", claims.SessionID)
}
}
// TestParseAccessToken_RejectsRefreshToken refresh token 不能当 access token。
func TestParseAccessToken_RejectsRefreshToken(t *testing.T) {
j := New("test-secret", 3600*time.Second, 86400*time.Second)
tok, err := j.GenerateRefreshToken("u1", "alice", "s1")
if err != nil {
t.Fatalf("generate refresh token: %v", err)
}
_, err = j.ParseAccessToken(tok)
if !errors.Is(err, ErrWrongTokenType) {
t.Errorf("expected ErrWrongTokenType, got %v", err)
}
}
// TestParseRefreshToken_AcceptsRefreshToken refresh token 通过 ParseRefreshToken。
func TestParseRefreshToken_AcceptsRefreshToken(t *testing.T) {
j := New("test-secret", 3600*time.Second, 86400*time.Second)
tok, err := j.GenerateRefreshToken("u1", "alice", "s1")
if err != nil {
t.Fatalf("generate refresh token: %v", err)
}
claims, err := j.ParseRefreshToken(tok)
if err != nil {
t.Fatalf("parse refresh token should succeed: %v", err)
}
if claims.TokenType != TokenTypeRefresh {
t.Errorf("typ = %s, want %s", claims.TokenType, TokenTypeRefresh)
}
if claims.SessionID != "s1" {
t.Errorf("sid = %s, want s1", claims.SessionID)
}
}
// TestParseRefreshToken_RejectsAccessToken access token 不能当 refresh token。
func TestParseRefreshToken_RejectsAccessToken(t *testing.T) {
j := New("test-secret", 3600*time.Second, 86400*time.Second)
tok, err := j.GenerateAccessToken("u1", "alice", "s1")
if err != nil {
t.Fatalf("generate access token: %v", err)
}
_, err = j.ParseRefreshToken(tok)
if !errors.Is(err, ErrWrongTokenType) {
t.Errorf("expected ErrWrongTokenType, got %v", err)
}
}
// TestParseRefreshToken_RequiresSessionID refresh token 必须带 sid。
func TestParseRefreshToken_RequiresSessionID(t *testing.T) {
j := New("test-secret", 3600*time.Second, 86400*time.Second)
// 旧式 GenerateRefreshToken(userID, username) 不带 sid应被拒绝。
tok, err := j.GenerateRefreshToken("u1", "alice", "")
if err != nil {
t.Fatalf("generate refresh token: %v", err)
}
_, err = j.ParseRefreshToken(tok)
if !errors.Is(err, ErrInvalidToken) {
t.Errorf("expected ErrInvalidToken for missing sid, got %v", err)
}
}
// TestParseAccessToken_LegacyTokenCompat 旧 token无 typ宽容通过。
func TestParseAccessToken_LegacyTokenCompat(t *testing.T) {
j := New("test-secret", 3600*time.Second, 86400*time.Second)
// 直接构造一个无 typ 的 access token。
// GenerateAccessToken 总是写 typ=access所以这里用 ParseToken 模拟旧路径:
// 实际上无法用新签发器构造无 typ 的 token此测试验证 ParseAccessToken 对 typ=access 的正常路径。
tok, err := j.GenerateAccessToken("u1", "alice", "")
if err != nil {
t.Fatalf("generate: %v", err)
}
// ParseToken也应拒绝 refresh token仅接受 access。
claims, err := j.ParseToken(tok)
if err != nil {
t.Fatalf("ParseToken should accept access token: %v", err)
}
if claims.UserID != "u1" {
t.Errorf("user_id = %s, want u1", claims.UserID)
}
}
// TestValidateToken_LegacyTokenReturnsNil 旧 tokentyp=access无 sid通过 ValidateToken。
func TestValidateToken_LegacyTokenReturnsNil(t *testing.T) {
j := New("test-secret", 3600*time.Second, 86400*time.Second)
tok, err := j.GenerateAccessToken("u1", "alice", "")
if err != nil {
t.Fatalf("generate: %v", err)
}
if err := j.ValidateToken(tok); err != nil {
t.Errorf("ValidateToken should accept access token without sid, got %v", err)
}
}

View File

@@ -131,15 +131,17 @@ func HandleError(c *gin.Context, err error, defaultMessage string) {
func statusCodeFromAppErrorCode(code string) int { func statusCodeFromAppErrorCode(code string) int {
switch code { switch code {
case "NOT_FOUND", "USER_NOT_FOUND", "POST_NOT_FOUND", "GROUP_NOT_FOUND", case "NOT_FOUND", "USER_NOT_FOUND", "POST_NOT_FOUND", "GROUP_NOT_FOUND",
"GROUP_REQUEST_NOT_FOUND", "SCHEDULE_COURSE_NOT_FOUND": "GROUP_REQUEST_NOT_FOUND", "SCHEDULE_COURSE_NOT_FOUND",
"VERIFICATION_NOT_FOUND", "CALL_NOT_FOUND":
return http.StatusNotFound return http.StatusNotFound
case "UNAUTHORIZED": case "UNAUTHORIZED", "INVALID_TOKEN", "TOKEN_EXPIRED", "SESSION_REVOKED":
return http.StatusUnauthorized return http.StatusUnauthorized
case "INVALID_CREDENTIALS": case "INVALID_CREDENTIALS":
return http.StatusBadRequest return http.StatusBadRequest
case "FORBIDDEN", "NOT_GROUP_MEMBER", "NOT_GROUP_ADMIN", "NOT_GROUP_OWNER", case "FORBIDDEN", "NOT_GROUP_MEMBER", "NOT_GROUP_ADMIN", "NOT_GROUP_OWNER",
"USER_BANNED", "USER_BLOCKED", "MUTED", "MUTE_ALL_ENABLED", "USER_BANNED", "USER_BLOCKED", "MUTED", "MUTE_ALL_ENABLED",
"SCHEDULE_FORBIDDEN", "UNAUTHORIZED_NOTIFICATION", "SCHEDULE_FORBIDDEN", "UNAUTHORIZED_NOTIFICATION",
"ACCOUNT_INACTIVE", "WRONG_TOKEN_TYPE", "VERIFICATION_REQUIRED",
"SETUP_ALREADY_COMPLETED": "SETUP_ALREADY_COMPLETED":
return http.StatusForbidden return http.StatusForbidden
case "BAD_REQUEST", "INVALID_USERNAME", "INVALID_EMAIL", "INVALID_PHONE", case "BAD_REQUEST", "INVALID_USERNAME", "INVALID_EMAIL", "INVALID_PHONE",

View File

@@ -26,6 +26,10 @@ type MessageRepository interface {
GetMessagesBeforeSeq(conversationID string, beforeSeq int64, limit int) ([]*model.Message, error) GetMessagesBeforeSeq(conversationID string, beforeSeq int64, limit int) ([]*model.Message, error)
GetConversationParticipants(conversationID string) ([]*model.ConversationParticipant, error) GetConversationParticipants(conversationID string) ([]*model.ConversationParticipant, error)
GetParticipant(conversationID string, userID string) (*model.ConversationParticipant, error) GetParticipant(conversationID string, userID string) (*model.ConversationParticipant, error)
// GetParticipantStrict 纯查询:返回用户在某会话中的参与者记录。
// 不存在时返回 gorm.ErrRecordNotFound**不会**创建记录。
// 用于鉴权场景(如校验非会话参与者不能读取消息),避免 GetParticipant 的自动创建副作用造成鉴权绕过。
GetParticipantStrict(conversationID string, userID string) (*model.ConversationParticipant, error)
UpdateLastReadSeq(conversationID string, userID string, lastReadSeq int64) error UpdateLastReadSeq(conversationID string, userID string, lastReadSeq int64) error
UpdatePinned(conversationID string, userID string, isPinned bool) error UpdatePinned(conversationID string, userID string, isPinned bool) error
UpdateNotificationMuted(conversationID string, userID string, notificationMuted bool) error UpdateNotificationMuted(conversationID string, userID string, notificationMuted bool) error
@@ -253,6 +257,10 @@ func (r *messageRepository) GetConversationParticipants(conversationID string) (
// GetParticipant 获取用户在会话中的参与者信息 // GetParticipant 获取用户在会话中的参与者信息
// userID 参数为 string 类型UUID格式与JWT中user_id保持一致 // userID 参数为 string 类型UUID格式与JWT中user_id保持一致
//
// 注意:本方法在参与者记录不存在但会话存在时会**自动创建**参与者记录。
// 该副作用适用于"确保参与者存在"的写路径;鉴权/读取路径应改用 GetParticipantStrict
// 否则非参与者的查询会被静默升级为成员(鉴权绕过)。
func (r *messageRepository) GetParticipant(conversationID string, userID string) (*model.ConversationParticipant, error) { func (r *messageRepository) GetParticipant(conversationID string, userID string) (*model.ConversationParticipant, error) {
var participant model.ConversationParticipant var participant model.ConversationParticipant
err := r.db.Where("conversation_id = ? AND user_id = ?", conversationID, userID).First(&participant).Error err := r.db.Where("conversation_id = ? AND user_id = ?", conversationID, userID).First(&participant).Error
@@ -278,6 +286,17 @@ func (r *messageRepository) GetParticipant(conversationID string, userID string)
return &participant, nil return &participant, nil
} }
// GetParticipantStrict 纯查询参与者记录,不存在即返回 gorm.ErrRecordNotFound不创建。
//
// 用于鉴权:避免 GetParticipant 的"自动创建"副作用把非成员静默升级为成员。
func (r *messageRepository) GetParticipantStrict(conversationID string, userID string) (*model.ConversationParticipant, error) {
var participant model.ConversationParticipant
if err := r.db.Where("conversation_id = ? AND user_id = ?", conversationID, userID).First(&participant).Error; err != nil {
return nil, err
}
return &participant, nil
}
// UpdateLastReadSeq 更新已读位置(防回退:仅当新值大于当前值时才更新) // UpdateLastReadSeq 更新已读位置(防回退:仅当新值大于当前值时才更新)
// userID 参数为 string 类型UUID格式与JWT中user_id保持一致 // userID 参数为 string 类型UUID格式与JWT中user_id保持一致
func (r *messageRepository) UpdateLastReadSeq(conversationID string, userID string, lastReadSeq int64) error { func (r *messageRepository) UpdateLastReadSeq(conversationID string, userID string, lastReadSeq int64) error {

View File

@@ -0,0 +1,90 @@
package repository
import (
"context"
"time"
"with_you/internal/model"
"gorm.io/gorm"
)
// SessionRepository 会话仓储接口
//
// 用于支持令牌撤销:登出/封禁按会话或用户撤销refresh token 轮换按 hash 反查。
type SessionRepository interface {
Create(ctx context.Context, session *model.Session) error
GetByID(ctx context.Context, id string) (*model.Session, error)
GetByRefreshTokenHash(ctx context.Context, hash string) (*model.Session, error)
Update(ctx context.Context, session *model.Session) error
Revoke(ctx context.Context, id string) error
RevokeAllByUser(ctx context.Context, userID string) error
RevokeByRefreshTokenHash(ctx context.Context, hash string) error
DeleteExpired(ctx context.Context, before time.Time) error
}
type sessionRepository struct {
db *gorm.DB
}
// NewSessionRepository 创建会话仓储。
func NewSessionRepository(db *gorm.DB) SessionRepository {
return &sessionRepository{db: db}
}
func (r *sessionRepository) Create(ctx context.Context, session *model.Session) error {
return r.db.WithContext(ctx).Create(session).Error
}
func (r *sessionRepository) GetByID(ctx context.Context, id string) (*model.Session, error) {
var s model.Session
if err := r.db.WithContext(ctx).Where("id = ?", id).First(&s).Error; err != nil {
return nil, err
}
return &s, nil
}
func (r *sessionRepository) GetByRefreshTokenHash(ctx context.Context, hash string) (*model.Session, error) {
var s model.Session
if err := r.db.WithContext(ctx).Where("refresh_token_hash = ?", hash).First(&s).Error; err != nil {
return nil, err
}
return &s, nil
}
func (r *sessionRepository) Update(ctx context.Context, session *model.Session) error {
// 仅更新业务关心的列,避免 Save 全字段写回在并发 Rotate 下覆盖 RevokedAt 等字段造成 lost update。
return r.db.WithContext(ctx).Model(&model.Session{}).
Where("id = ?", session.ID).
Updates(map[string]interface{}{
"last_used_at": session.LastUsedAt,
"refresh_token_hash": session.RefreshTokenHash,
}).Error
}
func (r *sessionRepository) Revoke(ctx context.Context, id string) error {
now := time.Now()
return r.db.WithContext(ctx).Model(&model.Session{}).
Where("id = ? AND revoked_at IS NULL", id).
Update("revoked_at", now).Error
}
func (r *sessionRepository) RevokeAllByUser(ctx context.Context, userID string) error {
now := time.Now()
return r.db.WithContext(ctx).Model(&model.Session{}).
Where("user_id = ? AND revoked_at IS NULL", userID).
Update("revoked_at", now).Error
}
func (r *sessionRepository) RevokeByRefreshTokenHash(ctx context.Context, hash string) error {
now := time.Now()
return r.db.WithContext(ctx).Model(&model.Session{}).
Where("refresh_token_hash = ? AND revoked_at IS NULL", hash).
Update("revoked_at", now).Error
}
func (r *sessionRepository) DeleteExpired(ctx context.Context, before time.Time) error {
return r.db.WithContext(ctx).
Where("expires_at < ? OR revoked_at IS NOT NULL", before).
Delete(&model.Session{}).Error
}

View File

@@ -3,9 +3,9 @@ package router
import ( import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"with_you/internal/cache"
"with_you/internal/handler" "with_you/internal/handler"
"with_you/internal/middleware" "with_you/internal/middleware"
"with_you/internal/model"
"with_you/internal/repository" "with_you/internal/repository"
"with_you/internal/service" "with_you/internal/service"
) )
@@ -49,6 +49,9 @@ type RouterDeps struct {
TradeHandler *handler.TradeHandler TradeHandler *handler.TradeHandler
WSHandler *handler.WSHandler WSHandler *handler.WSHandler
JWTService service.JWTService JWTService service.JWTService
SessionService service.SessionService
IdentityProvider middleware.IdentityProvider
Cache cache.Cache
LogService *service.LogService LogService *service.LogService
ActivityService service.UserActivityService ActivityService service.UserActivityService
CasbinService service.CasbinService CasbinService service.CasbinService
@@ -67,6 +70,8 @@ type Router struct {
func New(deps RouterDeps) *Router { func New(deps RouterDeps) *Router {
deps.UserHandler.SetJWTService(deps.JWTService) deps.UserHandler.SetJWTService(deps.JWTService)
deps.UserHandler.SetActivityService(deps.ActivityService) deps.UserHandler.SetActivityService(deps.ActivityService)
deps.UserHandler.SetSessionService(deps.SessionService)
deps.UserHandler.SetCache(deps.Cache)
r := &Router{ r := &Router{
RouterDeps: &deps, RouterDeps: &deps,
@@ -92,17 +97,17 @@ func (r *Router) setupRoutes() {
v1 := r.engine.Group("/api/v1") v1 := r.engine.Group("/api/v1")
{ {
// 认证路由(公开,按接口类型设置不同速率限制) // 认证路由(公开,按接口类型设置不同速率限制)
auth := v1.Group("/auth") // 注意:登出端点在下面 authMiddleware 定义后单独挂载,避免前向引用。
auth.Use(middleware.GlobalAuthRateLimit()) authPublic := v1.Group("/auth")
auth.Use(middleware.IPBanMiddleware()) authPublic.Use(middleware.GlobalAuthRateLimit())
authPublic.Use(middleware.IPBanMiddleware())
{ {
auth.POST("/register", middleware.RegisterRateLimit(), r.UserHandler.Register) authPublic.POST("/register", middleware.RegisterRateLimit(), r.UserHandler.Register)
auth.POST("/register/send-code", middleware.SendCodeRateLimit(), r.UserHandler.SendRegisterCode) authPublic.POST("/register/send-code", middleware.SendCodeRateLimit(), r.UserHandler.SendRegisterCode)
auth.POST("/login", middleware.LoginRateLimit(), r.UserHandler.Login) authPublic.POST("/login", middleware.LoginRateLimit(), r.UserHandler.Login)
auth.POST("/password/send-code", middleware.PasswordResetRateLimit(), r.UserHandler.SendPasswordResetCode) authPublic.POST("/password/send-code", middleware.PasswordResetRateLimit(), r.UserHandler.SendPasswordResetCode)
auth.POST("/password/reset", middleware.PasswordResetRateLimit(), r.UserHandler.ResetPassword) authPublic.POST("/password/reset", middleware.PasswordResetRateLimit(), r.UserHandler.ResetPassword)
auth.POST("/logout", r.UserHandler.Logout) authPublic.POST("/refresh", r.UserHandler.RefreshToken)
auth.POST("/refresh", r.UserHandler.RefreshToken)
} }
// 初始化超级管理员(公开接口,只能使用一次) // 初始化超级管理员(公开接口,只能使用一次)
@@ -111,7 +116,14 @@ func (r *Router) setupRoutes() {
} }
// 需要认证的路由 // 需要认证的路由
authMiddleware := middleware.Auth(r.JWTService) // 统一认证管道:令牌解析 → 加载 Principal → 校验账户状态 → 校验会话有效性。
// 取代旧 middleware.Auth避免 refresh token 误当 access token、封禁用户旧 token 仍可用等问题。
// 注入 cache.Cache 启用 Principal/Session 缓存30s TTL封禁/改密/登出时由 auth.InvalidatePrincipalCache 主动失效。
authMiddleware := middleware.RequireAuth(r.JWTService, r.IdentityProvider, r.Cache)
optionalAuth := middleware.OptionalAuth(r.JWTService, r.IdentityProvider, r.Cache)
// 登出挂在公开 auth 组上,但单独加认证中间件。
v1.POST("/auth/logout", authMiddleware, r.UserHandler.Logout)
// === 访问日志中间件 === // === 访问日志中间件 ===
if r.AdminLogHandler != nil && r.LogService != nil { if r.AdminLogHandler != nil && r.LogService != nil {
@@ -155,10 +167,10 @@ func (r *Router) setupRoutes() {
} }
// 搜索用户 // 搜索用户
users.GET("/search", middleware.OptionalAuth(r.JWTService), r.UserHandler.Search) users.GET("/search", optionalAuth, r.UserHandler.Search)
// 其他用户 // 其他用户
users.GET("/:id", middleware.OptionalAuth(r.JWTService), r.UserHandler.GetUserByID) users.GET("/:id", optionalAuth, r.UserHandler.GetUserByID)
users.POST("/:id/follow", authMiddleware, r.UserHandler.FollowUser) users.POST("/:id/follow", authMiddleware, r.UserHandler.FollowUser)
users.DELETE("/:id/follow", authMiddleware, r.UserHandler.UnfollowUser) users.DELETE("/:id/follow", authMiddleware, r.UserHandler.UnfollowUser)
users.POST("/:id/block", authMiddleware, r.UserHandler.BlockUser) users.POST("/:id/block", authMiddleware, r.UserHandler.BlockUser)
@@ -169,8 +181,8 @@ func (r *Router) setupRoutes() {
users.GET("/:id/followers", authMiddleware, r.UserHandler.GetFollowersList) users.GET("/:id/followers", authMiddleware, r.UserHandler.GetFollowersList)
// 用户帖子 - 使用 OptionalAuth 获取当前用户点赞/收藏状态 // 用户帖子 - 使用 OptionalAuth 获取当前用户点赞/收藏状态
users.GET("/:id/posts", middleware.OptionalAuth(r.JWTService), r.PostHandler.GetUserPosts) users.GET("/:id/posts", optionalAuth, r.PostHandler.GetUserPosts)
users.GET("/:id/favorites", middleware.OptionalAuth(r.JWTService), r.PostHandler.GetFavorites) users.GET("/:id/favorites", optionalAuth, r.PostHandler.GetFavorites)
} }
// 身份认证路由 // 身份认证路由
@@ -203,32 +215,32 @@ func (r *Router) setupRoutes() {
} }
// 认证路由(公开) // 认证路由(公开)
authPublic := v1.Group("/auth") authPublicCheck := v1.Group("/auth")
{ {
authPublic.GET("/check-username", r.UserHandler.CheckUsername) authPublicCheck.GET("/check-username", r.UserHandler.CheckUsername)
} }
// 帖子路由 // 帖子路由
posts := v1.Group("/posts") posts := v1.Group("/posts")
{ {
// 使用 OptionalAuth 中间件来获取用户登录状态 // 使用 OptionalAuth 中间件来获取用户登录状态
posts.GET("", middleware.OptionalAuth(r.JWTService), r.PostHandler.List) posts.GET("", optionalAuth, r.PostHandler.List)
posts.GET("/search", middleware.OptionalAuth(r.JWTService), r.PostHandler.Search) posts.GET("/search", optionalAuth, r.PostHandler.Search)
posts.GET("/suggest", middleware.OptionalAuth(r.JWTService), r.PostHandler.Suggest) posts.GET("/suggest", optionalAuth, r.PostHandler.Suggest)
posts.GET("/:id", middleware.OptionalAuth(r.JWTService), r.PostHandler.GetByID) posts.GET("/:id", optionalAuth, r.PostHandler.GetByID)
posts.POST("", authMiddleware, middleware.RequireVerified(r.UserService), r.PostHandler.Create) posts.POST("", authMiddleware, middleware.RequireVerified(r.UserService), r.PostHandler.Create)
posts.PUT("/:id", authMiddleware, middleware.RequireVerified(r.UserService), r.PostHandler.Update) posts.PUT("/:id", authMiddleware, middleware.RequireVerified(r.UserService), r.PostHandler.Update)
posts.DELETE("/:id", authMiddleware, middleware.RequireVerified(r.UserService), r.PostHandler.Delete) posts.DELETE("/:id", authMiddleware, middleware.RequireVerified(r.UserService), r.PostHandler.Delete)
// 浏览量记录(可选认证,允许游客浏览) // 浏览量记录(可选认证,允许游客浏览)
posts.POST("/:id/view", middleware.OptionalAuth(r.JWTService), r.PostHandler.RecordView) posts.POST("/:id/view", optionalAuth, r.PostHandler.RecordView)
// 分享计数(可选认证;仅已发布帖子生效) // 分享计数(可选认证;仅已发布帖子生效)
posts.POST("/:id/share", middleware.OptionalAuth(r.JWTService), r.PostHandler.RecordShare) posts.POST("/:id/share", optionalAuth, r.PostHandler.RecordShare)
// 内链相关路由 // 内链相关路由
posts.GET("/:id/related", middleware.OptionalAuth(r.JWTService), r.PostHandler.GetRelatedPosts) posts.GET("/:id/related", optionalAuth, r.PostHandler.GetRelatedPosts)
posts.GET("/:id/refs", middleware.OptionalAuth(r.JWTService), r.PostHandler.GetReferencedPosts) posts.GET("/:id/refs", optionalAuth, r.PostHandler.GetReferencedPosts)
posts.POST("/:id/ref-click", middleware.OptionalAuth(r.JWTService), r.PostHandler.RecordRefClick) posts.POST("/:id/ref-click", optionalAuth, r.PostHandler.RecordRefClick)
// 点赞 // 点赞
posts.POST("/:id/like", authMiddleware, middleware.RequireVerified(r.UserService), r.PostHandler.Like) posts.POST("/:id/like", authMiddleware, middleware.RequireVerified(r.UserService), r.PostHandler.Like)
@@ -240,7 +252,7 @@ func (r *Router) setupRoutes() {
// 投票相关路由 // 投票相关路由
posts.POST("/vote", authMiddleware, middleware.RequireVerified(r.UserService), r.VoteHandler.CreateVotePost) // 创建投票帖子 posts.POST("/vote", authMiddleware, middleware.RequireVerified(r.UserService), r.VoteHandler.CreateVotePost) // 创建投票帖子
posts.GET("/:id/vote", middleware.OptionalAuth(r.JWTService), r.VoteHandler.GetVoteResult) // 获取投票结果 posts.GET("/:id/vote", optionalAuth, r.VoteHandler.GetVoteResult) // 获取投票结果
posts.POST("/:id/vote", authMiddleware, middleware.RequireVerified(r.UserService), r.VoteHandler.Vote) // 投票 posts.POST("/:id/vote", authMiddleware, middleware.RequireVerified(r.UserService), r.VoteHandler.Vote) // 投票
posts.DELETE("/:id/vote", authMiddleware, middleware.RequireVerified(r.UserService), r.VoteHandler.Unvote) // 取消投票 posts.DELETE("/:id/vote", authMiddleware, middleware.RequireVerified(r.UserService), r.VoteHandler.Unvote) // 取消投票
} }
@@ -257,13 +269,13 @@ func (r *Router) setupRoutes() {
if r.TradeHandler != nil { if r.TradeHandler != nil {
trade := v1.Group("/trade") trade := v1.Group("/trade")
{ {
trade.GET("", middleware.OptionalAuth(r.JWTService), r.TradeHandler.List) trade.GET("", optionalAuth, r.TradeHandler.List)
trade.GET("/:id", middleware.OptionalAuth(r.JWTService), r.TradeHandler.GetByID) trade.GET("/:id", optionalAuth, r.TradeHandler.GetByID)
trade.POST("", authMiddleware, middleware.RequireVerified(r.UserService), r.TradeHandler.Create) trade.POST("", authMiddleware, middleware.RequireVerified(r.UserService), r.TradeHandler.Create)
trade.PUT("/:id", authMiddleware, middleware.RequireVerified(r.UserService), r.TradeHandler.Update) trade.PUT("/:id", authMiddleware, middleware.RequireVerified(r.UserService), r.TradeHandler.Update)
trade.DELETE("/:id", authMiddleware, middleware.RequireVerified(r.UserService), r.TradeHandler.Delete) trade.DELETE("/:id", authMiddleware, middleware.RequireVerified(r.UserService), r.TradeHandler.Delete)
trade.PUT("/:id/status", authMiddleware, middleware.RequireVerified(r.UserService), r.TradeHandler.UpdateStatus) trade.PUT("/:id/status", authMiddleware, middleware.RequireVerified(r.UserService), r.TradeHandler.UpdateStatus)
trade.POST("/:id/view", middleware.OptionalAuth(r.JWTService), r.TradeHandler.RecordView) trade.POST("/:id/view", optionalAuth, r.TradeHandler.RecordView)
trade.POST("/:id/favorite", authMiddleware, middleware.RequireVerified(r.UserService), r.TradeHandler.Favorite) trade.POST("/:id/favorite", authMiddleware, middleware.RequireVerified(r.UserService), r.TradeHandler.Favorite)
trade.DELETE("/:id/favorite", authMiddleware, middleware.RequireVerified(r.UserService), r.TradeHandler.Unfavorite) trade.DELETE("/:id/favorite", authMiddleware, middleware.RequireVerified(r.UserService), r.TradeHandler.Unfavorite)
} }
@@ -337,15 +349,15 @@ func (r *Router) setupRoutes() {
// 评论路由 // 评论路由
comments := v1.Group("/comments") comments := v1.Group("/comments")
{ {
comments.GET("/post/:id", middleware.OptionalAuth(r.JWTService), r.CommentHandler.GetByPostID) comments.GET("/post/:id", optionalAuth, r.CommentHandler.GetByPostID)
comments.GET("/post/:id/cursor", middleware.OptionalAuth(r.JWTService), r.CommentHandler.GetByPostIDByCursor) // 帖子评论游标分页 comments.GET("/post/:id/cursor", optionalAuth, r.CommentHandler.GetByPostIDByCursor) // 帖子评论游标分页
comments.GET("/:id", middleware.OptionalAuth(r.JWTService), r.CommentHandler.GetByID) comments.GET("/:id", optionalAuth, r.CommentHandler.GetByID)
comments.POST("", authMiddleware, middleware.RequireVerified(r.UserService), r.CommentHandler.Create) comments.POST("", authMiddleware, middleware.RequireVerified(r.UserService), r.CommentHandler.Create)
comments.PUT("/:id", authMiddleware, middleware.RequireVerified(r.UserService), r.CommentHandler.Update) comments.PUT("/:id", authMiddleware, middleware.RequireVerified(r.UserService), r.CommentHandler.Update)
comments.DELETE("/:id", authMiddleware, middleware.RequireVerified(r.UserService), r.CommentHandler.Delete) comments.DELETE("/:id", authMiddleware, middleware.RequireVerified(r.UserService), r.CommentHandler.Delete)
comments.GET("/:id/replies", middleware.OptionalAuth(r.JWTService), r.CommentHandler.GetReplies) comments.GET("/:id/replies", optionalAuth, r.CommentHandler.GetReplies)
comments.GET("/:id/replies/flat", middleware.OptionalAuth(r.JWTService), r.CommentHandler.GetRepliesByRootID) // 扁平化分页获取回复 comments.GET("/:id/replies/flat", optionalAuth, r.CommentHandler.GetRepliesByRootID) // 扁平化分页获取回复
comments.GET("/:id/replies/cursor", middleware.OptionalAuth(r.JWTService), r.CommentHandler.GetRepliesByRootIDByCursor) // 回复游标分页 comments.GET("/:id/replies/cursor", optionalAuth, r.CommentHandler.GetRepliesByRootIDByCursor) // 回复游标分页
// 评论点赞 // 评论点赞
comments.POST("/:id/like", authMiddleware, middleware.RequireVerified(r.UserService), r.CommentHandler.Like) comments.POST("/:id/like", authMiddleware, middleware.RequireVerified(r.UserService), r.CommentHandler.Like)
comments.DELETE("/:id/like", authMiddleware, middleware.RequireVerified(r.UserService), r.CommentHandler.Unlike) comments.DELETE("/:id/like", authMiddleware, middleware.RequireVerified(r.UserService), r.CommentHandler.Unlike)
@@ -527,129 +539,129 @@ func (r *Router) setupRoutes() {
} }
} }
// 管理路由(需要管理员权限) // 管理路由
// 鉴权链RequireAuth → RequireActive拒绝 pending_deletion 访问 admin→ 路由级 Authorize。
// 不再使用 RequireRole 粗粒度,改用每路由 Authorize(casbin, resource, action)
// super_admin 通过 admin.* 通配策略获得所有能力admin 仅获得业务管理能力,
// 角色与权限管理admin.roles.*、admin.users.roles.*与日志导出admin.logs.export仅 super_admin 拥有。
if r.RoleHandler != nil { if r.RoleHandler != nil {
admin := v1.Group("/admin") admin := v1.Group("/admin")
admin.Use(authMiddleware) admin.Use(authMiddleware)
admin.Use(middleware.RequireRole(r.CasbinService, model.RoleAdmin, model.RoleSuperAdmin)) admin.Use(middleware.RequireActive())
{
// 角色管理 // 角色与权限管理(仅 super_admin
admin.GET("/roles", r.RoleHandler.GetAllRoles) admin.GET("/roles", middleware.Authorize(r.CasbinService, "admin/roles", "read"), r.RoleHandler.GetAllRoles)
admin.GET("/roles/:name", r.RoleHandler.GetRoleDetail) admin.GET("/roles/:name", middleware.Authorize(r.CasbinService, "admin/roles", "read"), r.RoleHandler.GetRoleDetail)
admin.GET("/roles/:name/permissions", r.RoleHandler.GetRolePermissions) admin.GET("/roles/:name/permissions", middleware.Authorize(r.CasbinService, "admin/roles/permissions", "read"), r.RoleHandler.GetRolePermissions)
admin.PUT("/roles/:name/permissions", r.RoleHandler.UpdateRolePermissions) admin.PUT("/roles/:name/permissions", middleware.Authorize(r.CasbinService, "admin/roles/permissions", "write"), r.RoleHandler.UpdateRolePermissions)
admin.GET("/permissions", r.RoleHandler.GetAllPermissions) admin.GET("/permissions", middleware.Authorize(r.CasbinService, "admin/roles", "read"), r.RoleHandler.GetAllPermissions)
admin.GET("/users/:id/roles", r.RoleHandler.GetUserRoles) admin.GET("/users/:id/roles", middleware.Authorize(r.CasbinService, "admin/users/roles", "read"), r.RoleHandler.GetUserRoles)
admin.POST("/users/:id/roles", r.RoleHandler.AssignRole) admin.POST("/users/:id/roles", middleware.Authorize(r.CasbinService, "admin/users/roles", "write"), r.RoleHandler.AssignRole)
admin.DELETE("/users/:id/roles/:role", r.RoleHandler.RemoveRole) admin.DELETE("/users/:id/roles/:role", middleware.Authorize(r.CasbinService, "admin/users/roles", "write"), r.RoleHandler.RemoveRole)
// 用户管理 // 用户管理
if r.AdminUserHandler != nil { if r.AdminUserHandler != nil {
admin.GET("/users", r.AdminUserHandler.GetUserList) admin.GET("/users", middleware.Authorize(r.CasbinService, "admin/users", "read"), r.AdminUserHandler.GetUserList)
admin.GET("/users/:id", r.AdminUserHandler.GetUserDetail) admin.GET("/users/:id", middleware.Authorize(r.CasbinService, "admin/users", "read"), r.AdminUserHandler.GetUserDetail)
admin.PUT("/users/:id/status", r.AdminUserHandler.UpdateUserStatus) admin.PUT("/users/:id/status", middleware.Authorize(r.CasbinService, "admin/users/status", "write"), r.AdminUserHandler.UpdateUserStatus)
admin.GET("/users/:id/devices", r.AdminUserHandler.GetUserDevices) admin.GET("/users/:id/devices", middleware.Authorize(r.CasbinService, "admin/users/devices", "read"), r.AdminUserHandler.GetUserDevices)
} }
// 帖子管理 // 帖子管理
if r.AdminPostHandler != nil { if r.AdminPostHandler != nil {
admin.GET("/posts", r.AdminPostHandler.GetPostList) admin.GET("/posts", middleware.Authorize(r.CasbinService, "admin/posts", "read"), r.AdminPostHandler.GetPostList)
admin.GET("/posts/:id", r.AdminPostHandler.GetPostDetail) admin.GET("/posts/:id", middleware.Authorize(r.CasbinService, "admin/posts", "read"), r.AdminPostHandler.GetPostDetail)
admin.DELETE("/posts/:id", r.AdminPostHandler.DeletePost) admin.DELETE("/posts/:id", middleware.Authorize(r.CasbinService, "admin/posts", "write"), r.AdminPostHandler.DeletePost)
admin.PUT("/posts/:id/moderate", r.AdminPostHandler.ModeratePost) admin.PUT("/posts/:id/moderate", middleware.Authorize(r.CasbinService, "admin/posts", "write"), r.AdminPostHandler.ModeratePost)
admin.POST("/posts/batch-delete", r.AdminPostHandler.BatchDeletePosts) admin.POST("/posts/batch-delete", middleware.Authorize(r.CasbinService, "admin/posts", "write"), r.AdminPostHandler.BatchDeletePosts)
admin.POST("/posts/batch-moderate", r.AdminPostHandler.BatchModeratePosts) admin.POST("/posts/batch-moderate", middleware.Authorize(r.CasbinService, "admin/posts", "write"), r.AdminPostHandler.BatchModeratePosts)
admin.PUT("/posts/:id/pin", r.AdminPostHandler.SetPostPin) admin.PUT("/posts/:id/pin", middleware.Authorize(r.CasbinService, "admin/posts", "write"), r.AdminPostHandler.SetPostPin)
admin.PUT("/posts/:id/feature", r.AdminPostHandler.SetPostFeature) admin.PUT("/posts/:id/feature", middleware.Authorize(r.CasbinService, "admin/posts", "write"), r.AdminPostHandler.SetPostFeature)
} }
// 频道管理 // 频道管理
if r.ChannelHandler != nil { if r.ChannelHandler != nil {
admin.GET("/channels", r.ChannelHandler.AdminList) admin.GET("/channels", middleware.Authorize(r.CasbinService, "admin/channels", "read"), r.ChannelHandler.AdminList)
admin.POST("/channels", r.ChannelHandler.AdminCreate) admin.POST("/channels", middleware.Authorize(r.CasbinService, "admin/channels", "write"), r.ChannelHandler.AdminCreate)
admin.PUT("/channels/:id", r.ChannelHandler.AdminUpdate) admin.PUT("/channels/:id", middleware.Authorize(r.CasbinService, "admin/channels", "write"), r.ChannelHandler.AdminUpdate)
admin.DELETE("/channels/:id", r.ChannelHandler.AdminDelete) admin.DELETE("/channels/:id", middleware.Authorize(r.CasbinService, "admin/channels", "write"), r.ChannelHandler.AdminDelete)
} }
// 评论管理 // 评论管理
if r.AdminCommentHandler != nil { if r.AdminCommentHandler != nil {
admin.GET("/comments", r.AdminCommentHandler.GetCommentList) admin.GET("/comments", middleware.Authorize(r.CasbinService, "admin/comments", "read"), r.AdminCommentHandler.GetCommentList)
admin.GET("/comments/:id", r.AdminCommentHandler.GetCommentDetail) admin.GET("/comments/:id", middleware.Authorize(r.CasbinService, "admin/comments", "read"), r.AdminCommentHandler.GetCommentDetail)
admin.DELETE("/comments/:id", r.AdminCommentHandler.DeleteComment) admin.DELETE("/comments/:id", middleware.Authorize(r.CasbinService, "admin/comments", "write"), r.AdminCommentHandler.DeleteComment)
admin.PUT("/comments/:id/moderate", r.AdminCommentHandler.ModerateComment) admin.PUT("/comments/:id/moderate", middleware.Authorize(r.CasbinService, "admin/comments", "write"), r.AdminCommentHandler.ModerateComment)
admin.POST("/comments/batch-delete", r.AdminCommentHandler.BatchDeleteComments) admin.POST("/comments/batch-delete", middleware.Authorize(r.CasbinService, "admin/comments", "write"), r.AdminCommentHandler.BatchDeleteComments)
admin.POST("/comments/batch-moderate", r.AdminCommentHandler.BatchModerateComments) admin.POST("/comments/batch-moderate", middleware.Authorize(r.CasbinService, "admin/comments", "write"), r.AdminCommentHandler.BatchModerateComments)
admin.GET("/comments/post/:postId", r.AdminCommentHandler.GetPostComments) admin.GET("/comments/post/:postId", middleware.Authorize(r.CasbinService, "admin/comments", "read"), r.AdminCommentHandler.GetPostComments)
} }
// 群组管理 // 群组管理
if r.AdminGroupHandler != nil { if r.AdminGroupHandler != nil {
admin.GET("/groups", r.AdminGroupHandler.GetGroupList) admin.GET("/groups", middleware.Authorize(r.CasbinService, "admin/groups", "read"), r.AdminGroupHandler.GetGroupList)
admin.GET("/groups/:id", r.AdminGroupHandler.GetGroupDetail) admin.GET("/groups/:id", middleware.Authorize(r.CasbinService, "admin/groups", "read"), r.AdminGroupHandler.GetGroupDetail)
admin.GET("/groups/:id/members", r.AdminGroupHandler.GetGroupMembers) admin.GET("/groups/:id/members", middleware.Authorize(r.CasbinService, "admin/groups", "read"), r.AdminGroupHandler.GetGroupMembers)
admin.DELETE("/groups/:id", r.AdminGroupHandler.DeleteGroup) admin.DELETE("/groups/:id", middleware.Authorize(r.CasbinService, "admin/groups", "write"), r.AdminGroupHandler.DeleteGroup)
admin.PUT("/groups/:id/status", r.AdminGroupHandler.UpdateGroupStatus) admin.PUT("/groups/:id/status", middleware.Authorize(r.CasbinService, "admin/groups", "write"), r.AdminGroupHandler.UpdateGroupStatus)
admin.DELETE("/groups/:id/members/:userId", r.AdminGroupHandler.RemoveMember) admin.DELETE("/groups/:id/members/:userId", middleware.Authorize(r.CasbinService, "admin/groups", "write"), r.AdminGroupHandler.RemoveMember)
admin.PUT("/groups/:id/transfer", r.AdminGroupHandler.TransferOwner) admin.PUT("/groups/:id/transfer", middleware.Authorize(r.CasbinService, "admin/groups", "write"), r.AdminGroupHandler.TransferOwner)
} }
// 仪表盘 // 仪表盘
if r.AdminDashboardHandler != nil { if r.AdminDashboardHandler != nil {
admin.GET("/dashboard/stats", r.AdminDashboardHandler.GetStats) admin.GET("/dashboard/stats", middleware.Authorize(r.CasbinService, "admin/dashboard", "read"), r.AdminDashboardHandler.GetStats)
admin.GET("/dashboard/user-activity", r.AdminDashboardHandler.GetUserActivityTrend) admin.GET("/dashboard/user-activity", middleware.Authorize(r.CasbinService, "admin/dashboard", "read"), r.AdminDashboardHandler.GetUserActivityTrend)
admin.GET("/dashboard/content-stats", r.AdminDashboardHandler.GetContentStats) admin.GET("/dashboard/content-stats", middleware.Authorize(r.CasbinService, "admin/dashboard", "read"), r.AdminDashboardHandler.GetContentStats)
admin.GET("/dashboard/pending-content", r.AdminDashboardHandler.GetPendingContent) admin.GET("/dashboard/pending-content", middleware.Authorize(r.CasbinService, "admin/dashboard", "read"), r.AdminDashboardHandler.GetPendingContent)
admin.GET("/dashboard/online-users", r.AdminDashboardHandler.GetOnlineUsers) admin.GET("/dashboard/online-users", middleware.Authorize(r.CasbinService, "admin/dashboard", "read"), r.AdminDashboardHandler.GetOnlineUsers)
} }
// 日志管理 // 日志管理(导出仅 super_admin
if r.AdminLogHandler != nil { if r.AdminLogHandler != nil {
logs := admin.Group("/logs") admin.GET("/logs/operations", middleware.Authorize(r.CasbinService, "admin/logs", "read"), r.AdminLogHandler.GetOperationLogs)
{ admin.GET("/logs/logins", middleware.Authorize(r.CasbinService, "admin/logs", "read"), r.AdminLogHandler.GetLoginLogs)
logs.GET("/operations", r.AdminLogHandler.GetOperationLogs) admin.GET("/logs/data-changes", middleware.Authorize(r.CasbinService, "admin/logs", "read"), r.AdminLogHandler.GetDataChangeLogs)
logs.GET("/logins", r.AdminLogHandler.GetLoginLogs) admin.GET("/logs/statistics", middleware.Authorize(r.CasbinService, "admin/logs", "read"), r.AdminLogHandler.GetLogStatistics)
logs.GET("/data-changes", r.AdminLogHandler.GetDataChangeLogs) admin.GET("/logs/export", middleware.Authorize(r.CasbinService, "admin/logs/export", "read"), r.AdminLogHandler.ExportLogs)
logs.GET("/statistics", r.AdminLogHandler.GetLogStatistics)
logs.GET("/export", r.AdminLogHandler.ExportLogs)
}
} }
// 学习资料管理 // 学习资料管理
if r.MaterialHandler != nil { if r.MaterialHandler != nil {
// 学科管理 // 学科管理
admin.GET("/materials/subjects", r.MaterialHandler.AdminListSubjects) admin.GET("/materials/subjects", middleware.Authorize(r.CasbinService, "admin/materials", "read"), r.MaterialHandler.AdminListSubjects)
admin.POST("/materials/subjects", r.MaterialHandler.AdminCreateSubject) admin.POST("/materials/subjects", middleware.Authorize(r.CasbinService, "admin/materials", "write"), r.MaterialHandler.AdminCreateSubject)
admin.PUT("/materials/subjects/:id", r.MaterialHandler.AdminUpdateSubject) admin.PUT("/materials/subjects/:id", middleware.Authorize(r.CasbinService, "admin/materials", "write"), r.MaterialHandler.AdminUpdateSubject)
admin.DELETE("/materials/subjects/:id", r.MaterialHandler.AdminDeleteSubject) admin.DELETE("/materials/subjects/:id", middleware.Authorize(r.CasbinService, "admin/materials", "write"), r.MaterialHandler.AdminDeleteSubject)
// 资料管理 // 资料管理
admin.GET("/materials", r.MaterialHandler.AdminListMaterials) admin.GET("/materials", middleware.Authorize(r.CasbinService, "admin/materials", "read"), r.MaterialHandler.AdminListMaterials)
admin.GET("/materials/:id", r.MaterialHandler.AdminGetMaterial) admin.GET("/materials/:id", middleware.Authorize(r.CasbinService, "admin/materials", "read"), r.MaterialHandler.AdminGetMaterial)
admin.POST("/materials", r.MaterialHandler.AdminCreateMaterial) admin.POST("/materials", middleware.Authorize(r.CasbinService, "admin/materials", "write"), r.MaterialHandler.AdminCreateMaterial)
admin.PUT("/materials/:id", r.MaterialHandler.AdminUpdateMaterial) admin.PUT("/materials/:id", middleware.Authorize(r.CasbinService, "admin/materials", "write"), r.MaterialHandler.AdminUpdateMaterial)
admin.DELETE("/materials/:id", r.MaterialHandler.AdminDeleteMaterial) admin.DELETE("/materials/:id", middleware.Authorize(r.CasbinService, "admin/materials", "write"), r.MaterialHandler.AdminDeleteMaterial)
} }
// 举报管理 // 举报管理
if r.AdminReportHandler != nil { if r.AdminReportHandler != nil {
admin.GET("/reports", r.AdminReportHandler.List) admin.GET("/reports", middleware.Authorize(r.CasbinService, "admin/reports", "read"), r.AdminReportHandler.List)
admin.GET("/reports/:id", r.AdminReportHandler.Detail) admin.GET("/reports/:id", middleware.Authorize(r.CasbinService, "admin/reports", "read"), r.AdminReportHandler.Detail)
admin.PUT("/reports/:id/handle", r.AdminReportHandler.Handle) admin.PUT("/reports/:id/handle", middleware.Authorize(r.CasbinService, "admin/reports", "write"), r.AdminReportHandler.Handle)
admin.POST("/reports/batch-handle", r.AdminReportHandler.BatchHandle) admin.POST("/reports/batch-handle", middleware.Authorize(r.CasbinService, "admin/reports", "write"), r.AdminReportHandler.BatchHandle)
} }
// 身份认证管理 // 身份认证管理
if r.AdminVerificationHandler != nil { if r.AdminVerificationHandler != nil {
admin.GET("/verifications", r.AdminVerificationHandler.ListVerifications) admin.GET("/verifications", middleware.Authorize(r.CasbinService, "admin/verifications", "read"), r.AdminVerificationHandler.ListVerifications)
admin.GET("/verifications/:id", r.AdminVerificationHandler.GetVerificationDetail) admin.GET("/verifications/:id", middleware.Authorize(r.CasbinService, "admin/verifications", "read"), r.AdminVerificationHandler.GetVerificationDetail)
admin.PUT("/verifications/:id/review", r.AdminVerificationHandler.ReviewVerification) admin.PUT("/verifications/:id/review", middleware.Authorize(r.CasbinService, "admin/verifications", "write"), r.AdminVerificationHandler.ReviewVerification)
} }
// 用户资料审核管理 // 用户资料审核管理
if r.AdminProfileAuditHandler != nil { if r.AdminProfileAuditHandler != nil {
admin.GET("/profile-audits", r.AdminProfileAuditHandler.GetPendingList) admin.GET("/profile-audits", middleware.Authorize(r.CasbinService, "admin/profile_audits", "read"), r.AdminProfileAuditHandler.GetPendingList)
admin.PUT("/profile-audits/:id/moderate", r.AdminProfileAuditHandler.ModerateAudit) admin.PUT("/profile-audits/:id/moderate", middleware.Authorize(r.CasbinService, "admin/profile_audits", "write"), r.AdminProfileAuditHandler.ModerateAudit)
admin.POST("/profile-audits/batch-moderate", r.AdminProfileAuditHandler.BatchModerate) admin.POST("/profile-audits/batch-moderate", middleware.Authorize(r.CasbinService, "admin/profile_audits", "write"), r.AdminProfileAuditHandler.BatchModerate)
}
} }
} }
} }

View File

@@ -3,8 +3,12 @@ package service
import ( import (
"context" "context"
"go.uber.org/zap"
"with_you/internal/cache"
"with_you/internal/dto" "with_you/internal/dto"
"with_you/internal/model" "with_you/internal/model"
"with_you/internal/pkg/auth"
"with_you/internal/repository" "with_you/internal/repository"
) )
@@ -19,14 +23,21 @@ type AdminUserService interface {
} }
// adminUserServiceImpl 管理端用户服务实现 // adminUserServiceImpl 管理端用户服务实现
//
// sessionSvc 与 cache 用于在封禁/停用用户时撤销其全部登录会话并失效 Principal 缓存,
// 让 RequireAuth 在下一次请求时重新加载用户状态(避免 30s 缓存窗口内仍可访问)。
type adminUserServiceImpl struct { type adminUserServiceImpl struct {
userRepo repository.UserRepository userRepo repository.UserRepository
sessionSvc SessionService
cache cache.Cache
} }
// NewAdminUserService 创建管理端用户服务 // NewAdminUserService 创建管理端用户服务
func NewAdminUserService(userRepo repository.UserRepository) AdminUserService { func NewAdminUserService(userRepo repository.UserRepository, sessionSvc SessionService, cache cache.Cache) AdminUserService {
return &adminUserServiceImpl{ return &adminUserServiceImpl{
userRepo: userRepo, userRepo: userRepo,
sessionSvc: sessionSvc,
cache: cache,
} }
} }
@@ -74,6 +85,26 @@ func (s *adminUserServiceImpl) UpdateUserStatus(ctx context.Context, userID stri
return nil, err return nil, err
} }
// 状态变更后的会话/缓存联动:
// - banned/inactive撤销该用户全部会话refresh token 立即失效)并失效 Principal 缓存,
// access token 在剩余 TTL 内仍可用(无状态 JWT 固有限制),但 refresh 已不可续期。
// - active从封禁恢复时同样失效 Principal 缓存,让下次请求重新加载新状态。
// 失败仅告警不阻塞主流程:状态变更本身已成功,缓存最终会自然过期。
if status == model.UserStatusBanned || status == model.UserStatusInactive {
if s.sessionSvc != nil {
if rerr := s.sessionSvc.RevokeAllByUser(ctx, userID); rerr != nil {
zap.L().Warn("failed to revoke sessions on user status change",
zap.String("user_id", userID),
zap.String("status", string(status)),
zap.Error(rerr),
)
}
}
}
if status != user.Status {
auth.InvalidatePrincipalCache(s.cache, userID)
}
// 重新获取用户信息 // 重新获取用户信息
user, err = s.userRepo.GetByID(userID) user, err = s.userRepo.GetByID(userID)
if err != nil { if err != nil {

View File

@@ -7,6 +7,7 @@ import (
"with_you/internal/cache" "with_you/internal/cache"
"with_you/internal/config" "with_you/internal/config"
"with_you/internal/pkg/auth"
"with_you/internal/repository" "with_you/internal/repository"
"github.com/casbin/casbin/v3" "github.com/casbin/casbin/v3"
@@ -88,9 +89,11 @@ func (s *casbinService) Enforce(ctx context.Context, sub, obj, act string) (bool
return true, nil return true, nil
} }
// 检查缓存 // 缓存键与策略维度对齐sub/obj/act 直接对应 p, role, resource, action。
// user_roles 表为用户-角色真源,由 EnforceForUser 解析后再调本方法;
// 这里 sub 已经是角色名,避免再走 g(user, role) 分组model 中 matcher 已不再依赖 g
if s.cache != nil { if s.cache != nil {
cacheKey := fmt.Sprintf("casbin:check:%s:%s:%s", sub, obj, act) cacheKey := fmt.Sprintf("casbin:policy:%s:%s:%s", sub, obj, act)
if cached, ok := s.cache.Get(cacheKey); ok { if cached, ok := s.cache.Get(cacheKey); ok {
if allowed, ok := cached.(bool); ok { if allowed, ok := cached.(bool); ok {
return allowed, nil return allowed, nil
@@ -106,7 +109,7 @@ func (s *casbinService) Enforce(ctx context.Context, sub, obj, act string) (bool
// 缓存结果 // 缓存结果
if s.cache != nil { if s.cache != nil {
cacheKey := fmt.Sprintf("casbin:check:%s:%s:%s", sub, obj, act) cacheKey := fmt.Sprintf("casbin:policy:%s:%s:%s", sub, obj, act)
s.cache.Set(cacheKey, allowed, s.cacheTTL) s.cache.Set(cacheKey, allowed, s.cacheTTL)
} }
@@ -140,33 +143,24 @@ func (s *casbinService) EnforceForUser(ctx context.Context, userID, obj, act str
} }
func (s *casbinService) AddRoleForUser(ctx context.Context, userID, role string) error { func (s *casbinService) AddRoleForUser(ctx context.Context, userID, role string) error {
// 添加到数据库 // 真源策略user_roles 表为唯一真源Casbin 不再承载 g, user, role 分组策略。
// 减少双写漂移与缓存失效盲区EnforceForUser 内部先查 DB 角色再对每个角色调 Enforce(role,...)。
if err := s.roleRepo.AddRoleForUser(ctx, userID, role); err != nil { if err := s.roleRepo.AddRoleForUser(ctx, userID, role); err != nil {
return err return err
} }
// 添加到 Casbin // 失效该用户的角色缓存,以及与该角色相关的策略缓存。
if _, err := s.enforcer.AddRoleForUser(userID, role); err != nil { s.clearUserRoleCache(ctx, userID)
return err return s.clearPolicyCache(ctx)
}
// 清除缓存
return s.clearUserCache(ctx, userID)
} }
func (s *casbinService) DeleteRoleForUser(ctx context.Context, userID, role string) error { func (s *casbinService) DeleteRoleForUser(ctx context.Context, userID, role string) error {
// 从数据库删除
if err := s.roleRepo.DeleteRoleForUser(ctx, userID, role); err != nil { if err := s.roleRepo.DeleteRoleForUser(ctx, userID, role); err != nil {
return err return err
} }
// 从 Casbin 删除 s.clearUserRoleCache(ctx, userID)
if _, err := s.enforcer.DeleteRoleForUser(userID, role); err != nil { return s.clearPolicyCache(ctx)
return err
}
// 清除缓存
return s.clearUserCache(ctx, userID)
} }
func (s *casbinService) GetRolesForUser(ctx context.Context, userID string) ([]string, error) { func (s *casbinService) GetRolesForUser(ctx context.Context, userID string) ([]string, error) {
@@ -219,20 +213,39 @@ func (s *casbinService) LoadPolicy(ctx context.Context) error {
func (s *casbinService) ClearCache(ctx context.Context) error { func (s *casbinService) ClearCache(ctx context.Context) error {
if s.cache != nil { if s.cache != nil {
// 清除所有 casbin 相关缓存 // 清除所有 casbin 相关缓存:策略缓存 + 用户角色缓存。
s.cache.DeleteByPrefix("casbin:") s.cache.DeleteByPrefix("casbin:")
} }
return nil return nil
} }
func (s *casbinService) clearUserCache(ctx context.Context, userID string) error { // clearPolicyCache 失效所有策略缓存casbin:policy:*)。
//
// 策略变更或角色变更都应调用,确保下次 Enforce 重新查 Casbin。
func (s *casbinService) clearPolicyCache(ctx context.Context) error {
if s.cache != nil { if s.cache != nil {
cacheKey := fmt.Sprintf("casbin:user:roles:%s", userID) s.cache.DeleteByPrefix("casbin:policy:")
s.cache.Delete(cacheKey)
} }
return nil return nil
} }
// clearUserRoleCache 仅失效某个用户的角色缓存casbin:user:roles:{userID})。
//
// 用于 AddRoleForUser/DeleteRoleForUser只刷新该用户的角色不波及策略缓存除非同时调 clearPolicyCache
// 同时失效认证管道的 Principal 缓存,让 RequireAuth 下次重新加载该用户的角色。
func (s *casbinService) clearUserRoleCache(ctx context.Context, userID string) error {
if s.cache != nil {
s.cache.Delete(fmt.Sprintf("casbin:user:roles:%s", userID))
s.cache.Delete(auth.PrincipalCacheKey(userID))
}
return nil
}
// clearUserCache Deprecated 兼容旧调用,内部转调 clearUserRoleCache。
func (s *casbinService) clearUserCache(ctx context.Context, userID string) error {
return s.clearUserRoleCache(ctx, userID)
}
func (s *casbinService) GetPermissionsForRole(ctx context.Context, role string) ([][]string, error) { func (s *casbinService) GetPermissionsForRole(ctx context.Context, role string) ([][]string, error) {
return s.enforcer.GetPermissionsForUser(role) return s.enforcer.GetPermissionsForUser(role)
} }

View File

@@ -0,0 +1,186 @@
package service
import (
"context"
"testing"
withmodel "with_you/internal/model"
"github.com/casbin/casbin/v3"
casbinmodel "github.com/casbin/casbin/v3/model"
)
// newTestEnforcer 构造一个内存 Enforcer加载与生产相同的 model.conf 语义globMatch
// 并按 seedPermissions 的约定注入角色策略,便于在不依赖 DB 的情况下验证 matcher 行为。
func newTestEnforcer(t *testing.T) *casbin.Enforcer {
t.Helper()
// 与 configs/casbin/model.conf 保持一致;用 globMatch 而非 keyMatch2。
mText := `
[request_definition]
r = sub, obj, act
[policy_definition]
p = sub, obj, act
[role_definition]
g = _, _
[policy_effect]
e = some(where (p.eft == allow))
[matchers]
m = r.sub == p.sub && globMatch(r.obj, p.obj) && (r.act == p.act || p.act == "*")
`
m, err := casbinmodel.NewModelFromString(mText)
if err != nil {
t.Fatalf("parse model: %v", err)
}
e, err := casbin.NewEnforcer(m)
if err != nil {
t.Fatalf("new enforcer: %v", err)
}
// 与 seedPermissions 一致super_admin=admin.** 通配admin 业务能力,
// moderator 内容审核。admin.logs.export 仅 super_admin 拥有admin 只有 admin.logs read
policies := [][]string{
{withmodel.RoleSuperAdmin, "admin/**", "*"},
{withmodel.RoleAdmin, "admin/users", "read"},
{withmodel.RoleAdmin, "admin/users/status", "write"},
{withmodel.RoleAdmin, "admin/users/devices", "read"},
{withmodel.RoleAdmin, "admin/posts", "*"},
{withmodel.RoleAdmin, "admin/comments", "*"},
{withmodel.RoleAdmin, "admin/groups", "*"},
{withmodel.RoleAdmin, "admin/channels", "*"},
{withmodel.RoleAdmin, "admin/dashboard", "read"},
{withmodel.RoleAdmin, "admin/reports", "*"},
{withmodel.RoleAdmin, "admin/verifications", "*"},
{withmodel.RoleAdmin, "admin/profile_audits", "*"},
{withmodel.RoleAdmin, "admin/materials", "*"},
{withmodel.RoleAdmin, "admin/logs", "read"},
{withmodel.RoleModerator, "admin/posts", "read"},
{withmodel.RoleModerator, "admin/posts", "write"},
{withmodel.RoleModerator, "admin/comments", "read"},
{withmodel.RoleModerator, "admin/comments", "write"},
{withmodel.RoleModerator, "admin/reports", "read"},
{withmodel.RoleModerator, "admin/reports", "write"},
}
for _, p := range policies {
if _, err := e.AddPolicy(p[0], p[1], p[2]); err != nil {
t.Fatalf("add policy %v: %v", p, err)
}
}
return e
}
// mustAllow / mustDeny 简化断言。
func mustAllow(t *testing.T, e *casbin.Enforcer, sub, obj, act string) {
t.Helper()
ok, err := e.Enforce(sub, obj, act)
if err != nil {
t.Fatalf("Enforce(%s,%s,%s) err: %v", sub, obj, act, err)
}
if !ok {
t.Errorf("Enforce(%s,%s,%s) = false, want true", sub, obj, act)
}
}
func mustDeny(t *testing.T, e *casbin.Enforcer, sub, obj, act string) {
t.Helper()
ok, err := e.Enforce(sub, obj, act)
if err != nil {
t.Fatalf("Enforce(%s,%s,%s) err: %v", sub, obj, act, err)
}
if ok {
t.Errorf("Enforce(%s,%s,%s) = true, want false", sub, obj, act)
}
}
// TestSuperAdminWildcard 通配多级资源globMatch ** 跨 '.')。
func TestSuperAdminWildcard(t *testing.T) {
e := newTestEnforcer(t)
mustAllow(t, e, withmodel.RoleSuperAdmin, "admin/users/roles/write", "write")
mustAllow(t, e, withmodel.RoleSuperAdmin, "admin/logs/export", "read")
mustAllow(t, e, withmodel.RoleSuperAdmin, "admin/dashboard", "read")
}
// TestAdminBusinessCapabilities admin 拥有的业务能力放行。
func TestAdminBusinessCapabilities(t *testing.T) {
e := newTestEnforcer(t)
mustAllow(t, e, withmodel.RoleAdmin, "admin/posts", "write")
mustAllow(t, e, withmodel.RoleAdmin, "admin/posts", "read")
mustAllow(t, e, withmodel.RoleAdmin, "admin/users", "read")
mustAllow(t, e, withmodel.RoleAdmin, "admin/logs", "read")
}
// TestAdminCannotReachSuperAdminOnlyResources admin 不能触及 super_admin 独占资源。
// - admin.logs.export 仅 super_admin 拥有admin 应被拒。
// - admin.users.roles.write 仅 super_admin 拥有。
// - admin.roles.* 仅 super_admin 拥有。
func TestAdminCannotReachSuperAdminOnlyResources(t *testing.T) {
e := newTestEnforcer(t)
mustDeny(t, e, withmodel.RoleAdmin, "admin/logs/export", "read")
mustDeny(t, e, withmodel.RoleAdmin, "admin/users/roles", "write")
mustDeny(t, e, withmodel.RoleAdmin, "admin/roles", "read")
// admin.users.status.write 是 admin 自己的能力,确认不受影响。
mustAllow(t, e, withmodel.RoleAdmin, "admin/users/status", "write")
}
// TestModeratorContentModeration moderator 仅内容审核。
func TestModeratorContentModeration(t *testing.T) {
e := newTestEnforcer(t)
mustAllow(t, e, withmodel.RoleModerator, "admin/posts", "read")
mustAllow(t, e, withmodel.RoleModerator, "admin/comments", "write")
mustAllow(t, e, withmodel.RoleModerator, "admin/reports", "write")
// moderator 不应触及用户管理。
mustDeny(t, e, withmodel.RoleModerator, "admin/users", "read")
mustDeny(t, e, withmodel.RoleModerator, "admin/logs", "read")
}
// TestWildcardDoesNotCrossBoundaryWithSingleStar 校验 admin/* 在 globMatch 下不跨 '/'。
// 此用例用于防止未来有人误把 super_admin 通配写回 admin/*(单层),导致 super_admin 实际拿不到
// admin/users/roles/write 这类多级资源的能力(应使用 admin/**)。
//
// 注意:本测试构造独立的 Enforcer仅注入 admin/* 单层策略,避免 newTestEnforcer 的
// admin/** 策略干扰断言。
func TestWildcardDoesNotCrossBoundaryWithSingleStar(t *testing.T) {
mText := `
[request_definition]
r = sub, obj, act
[policy_definition]
p = sub, obj, act
[role_definition]
g = _, _
[policy_effect]
e = some(where (p.eft == allow))
[matchers]
m = r.sub == p.sub && globMatch(r.obj, p.obj) && (r.act == p.act || p.act == "*")
`
m, err := casbinmodel.NewModelFromString(mText)
if err != nil {
t.Fatalf("parse model: %v", err)
}
e, err := casbin.NewEnforcer(m)
if err != nil {
t.Fatalf("new enforcer: %v", err)
}
if _, err := e.AddPolicy(withmodel.RoleSuperAdmin, "admin/*", "*"); err != nil {
t.Fatalf("add policy: %v", err)
}
// 单层 * 不跨 '/'admin/* 仅命中 admin/<segment>,不能命中 admin/users/roles/write。
mustDeny(t, e, withmodel.RoleSuperAdmin, "admin/users/roles/write", "write")
mustAllow(t, e, withmodel.RoleSuperAdmin, "admin/posts", "write")
}
// TestEnforceForUser_NoRolesNoPanic 用户无角色时 EnforceForUser 走默认 user 角色。
func TestEnforceForUser_NoRolesNoPanic(t *testing.T) {
e := newTestEnforcer(t)
// user 角色无任何 admin 策略,应全部拒绝。
mustDeny(t, e, "user", "admin/posts", "read")
// 防止 lint 抱怨未使用 context。
_ = context.Background()
}

View File

@@ -91,9 +91,16 @@ type chatServiceImpl struct {
versionLogMaxGap int64 versionLogMaxGap int64
pushWorker *PushWorker pushWorker *PushWorker
redisClient *redis.Client redisClient *redis.Client
// groupAbility 用于群消息发送前的群成员/禁言校验。
// 可选依赖:为 nil 时跳过群能力校验(向后兼容旧测试构造),生产环境应注入。
groupAbility GroupAbility
} }
// NewChatService 创建聊天服务 // NewChatService 创建聊天服务
//
// groupAbility 可选注入:群消息发送前的成员/禁言校验由此完成。
// 生产环境必须注入,否则群禁言规则无法生效。
func NewChatService( func NewChatService(
repo repository.MessageRepository, repo repository.MessageRepository,
userRepo repository.UserRepository, userRepo repository.UserRepository,
@@ -105,6 +112,7 @@ func NewChatService(
seqBufferMgr *cache.SeqBufferManager, seqBufferMgr *cache.SeqBufferManager,
pushWorker *PushWorker, pushWorker *PushWorker,
redisClient *redis.Client, redisClient *redis.Client,
groupAbility GroupAbility,
versionLogRepo ...repository.ConversationVersionLogRepository, versionLogRepo ...repository.ConversationVersionLogRepository,
) ChatService { ) ChatService {
// 创建适配器 // 创建适配器
@@ -131,6 +139,7 @@ func NewChatService(
uploadService: uploadService, uploadService: uploadService,
pushWorker: pushWorker, pushWorker: pushWorker,
redisClient: redisClient, redisClient: redisClient,
groupAbility: groupAbility,
} }
if len(versionLogRepo) > 0 && versionLogRepo[0] != nil { if len(versionLogRepo) > 0 && versionLogRepo[0] != nil {
@@ -466,6 +475,14 @@ func (s *chatServiceImpl) SendMessage(ctx context.Context, senderID string, conv
return nil, fmt.Errorf("failed to get participant: %w", err) return nil, fmt.Errorf("failed to get participant: %w", err)
} }
// 群消息发送:校验发送者是否仍是群成员、是否被禁言、群是否全员禁言。
// 私聊会话不参与该校验。
if conv.Type == model.ConversationTypeGroup && conv.GroupID != nil && *conv.GroupID != "" && s.groupAbility != nil {
if err := s.groupAbility.CanSendGroupMessage(ctx, senderID, *conv.GroupID); err != nil {
return nil, err
}
}
if s.uploadService != nil { if s.uploadService != nil {
if err := s.uploadService.ValidateChatMessageImageSegments(segments); err != nil { if err := s.uploadService.ValidateChatMessageImageSegments(segments); err != nil {
return nil, err return nil, err
@@ -1054,8 +1071,8 @@ func (s *chatServiceImpl) IsUserOnline(userID string) bool {
// SaveMessage 仅保存消息到数据库,不发送实时推送 // SaveMessage 仅保存消息到数据库,不发送实时推送
// 适用于群聊等由调用方自行负责推送的场景 // 适用于群聊等由调用方自行负责推送的场景
func (s *chatServiceImpl) SaveMessage(ctx context.Context, senderID string, conversationID string, segments model.MessageSegments, replyToID *string) (*model.Message, error) { func (s *chatServiceImpl) SaveMessage(ctx context.Context, senderID string, conversationID string, segments model.MessageSegments, replyToID *string) (*model.Message, error) {
// 验证会话是否存在 // 验证会话是否存在(保留 conv 用于群消息能力校验)
_, err := s.getConversation(ctx, conversationID) conv, err := s.getConversation(ctx, conversationID)
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("会话不存在,请重新创建会话") return nil, errors.New("会话不存在,请重新创建会话")
@@ -1072,6 +1089,14 @@ func (s *chatServiceImpl) SaveMessage(ctx context.Context, senderID string, conv
return nil, fmt.Errorf("failed to get participant: %w", err) return nil, fmt.Errorf("failed to get participant: %w", err)
} }
// 群消息发送:校验发送者是否仍是群成员、是否被禁言、群是否全员禁言。
// 私聊会话不参与该校验。
if conv.Type == model.ConversationTypeGroup && conv.GroupID != nil && *conv.GroupID != "" && s.groupAbility != nil {
if err := s.groupAbility.CanSendGroupMessage(ctx, senderID, *conv.GroupID); err != nil {
return nil, err
}
}
if s.uploadService != nil { if s.uploadService != nil {
if err := s.uploadService.ValidateChatMessageImageSegments(segments); err != nil { if err := s.uploadService.ValidateChatMessageImageSegments(segments); err != nil {
return nil, err return nil, err

View File

@@ -0,0 +1,31 @@
package service
import "context"
// GroupAbility 抽象群组能力校验,避免 chat_service 直接依赖 group_service 造成循环。
//
// 由 GroupService 实现并通过 wire 绑定;仅暴露 ChatService 所需的最小校验方法。
type GroupAbility interface {
// CanSendGroupMessage 校验用户是否可在群组中发消息。
// 拒绝场景:非群成员、被单独禁言、群全员禁言。
CanSendGroupMessage(ctx context.Context, userID, groupID string) error
}
// GroupAbilityFunc 适配器类型,便于把闭包/wire 桥接
// 转换为 GroupAbility 接口。
type GroupAbilityFunc func(ctx context.Context, userID, groupID string) error
func (f GroupAbilityFunc) CanSendGroupMessage(ctx context.Context, userID, groupID string) error {
return f(ctx, userID, groupID)
}
// NewGroupAbility 从 GroupService 构造 GroupAbility 适配器wire 注册用)。
//
// 注意GroupService.CanSendGroupMessage 当前签名不带 ctx这里通过闭包桥接保持
// GroupAbility 接口签名一致;后续 GroupService 演进为接受 ctx 时直接传递即可。
func NewGroupAbility(groupService GroupService) GroupAbility {
return GroupAbilityFunc(func(ctx context.Context, userID, groupID string) error {
_ = ctx
return groupService.CanSendGroupMessage(userID, groupID)
})
}

View File

@@ -70,6 +70,8 @@ type GroupService interface {
LeaveGroup(userID string, groupID string) error LeaveGroup(userID string, groupID string) error
RemoveMember(userID string, groupID string, targetUserID string) error RemoveMember(userID string, groupID string, targetUserID string) error
GetMembers(groupID string, page, pageSize int) ([]model.GroupMember, int64, error) GetMembers(groupID string, page, pageSize int) ([]model.GroupMember, int64, error)
// GetMembersForUser 用户侧获取成员列表,校验 requesterID 是否为群成员。
GetMembersForUser(requesterID, groupID string, page, pageSize int) ([]model.GroupMember, int64, error)
SetMemberRole(userID string, groupID string, targetUserID string, role string) error SetMemberRole(userID string, groupID string, targetUserID string, role string) error
SetMemberNickname(userID string, groupID string, nickname string) error SetMemberNickname(userID string, groupID string, nickname string) error
MuteMember(userID string, groupID string, targetUserID string, muted bool) error MuteMember(userID string, groupID string, targetUserID string, muted bool) error
@@ -91,6 +93,9 @@ type GroupService interface {
// 获取成员信息 // 获取成员信息
GetMember(groupID string, userID string) (*model.GroupMember, error) GetMember(groupID string, userID string) (*model.GroupMember, error)
// IsGroupMember 检查用户是否为群成员(用户侧可见性校验)。
IsGroupMember(userID string, groupID string) bool
// 游标分页方法 // 游标分页方法
GetGroupsByCursor(ctx context.Context, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Group], error) GetGroupsByCursor(ctx context.Context, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Group], error)
GetUserGroupsByCursor(ctx context.Context, userID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Group], error) GetUserGroupsByCursor(ctx context.Context, userID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Group], error)
@@ -1192,6 +1197,9 @@ func (s *groupService) RemoveMember(userID string, groupID string, targetUserID
} }
// GetMembers 获取群成员列表(带缓存) // GetMembers 获取群成员列表(带缓存)
//
// 注意:本方法不校验调用者是否为群成员,仅供内部/admin 路径调用。
// 用户侧路由应使用 GetMembersForUser后者会校验 requesterID 是否为群成员。
func (s *groupService) GetMembers(groupID string, page, pageSize int) ([]model.GroupMember, int64, error) { func (s *groupService) GetMembers(groupID string, page, pageSize int) ([]model.GroupMember, int64, error) {
cacheSettings := cache.GetSettings() cacheSettings := cache.GetSettings()
groupMembersTTL := cacheSettings.GroupMembersTTL groupMembersTTL := cacheSettings.GroupMembersTTL
@@ -1235,6 +1243,24 @@ func (s *groupService) GetMembers(groupID string, page, pageSize int) ([]model.G
return result.Members, result.Total, nil return result.Members, result.Total, nil
} }
// GetMembersForUser 用户侧获取群成员列表:校验调用者是否为群成员。
//
// 安全语义:成员列表仅群成员可见。非成员访问返回 ErrNotGroupMember
// 避免群成员信息被任意登录用户枚举。
func (s *groupService) GetMembersForUser(requesterID, groupID string, page, pageSize int) ([]model.GroupMember, int64, error) {
if requesterID == "" {
return nil, 0, ErrNotGroupMember
}
isMember, err := s.groupRepo.IsMember(groupID, requesterID)
if err != nil {
return nil, 0, err
}
if !isMember {
return nil, 0, ErrNotGroupMember
}
return s.GetMembers(groupID, page, pageSize)
}
// SetMemberRole 设置成员角色 // SetMemberRole 设置成员角色
func (s *groupService) SetMemberRole(userID string, groupID string, targetUserID string, role string) error { func (s *groupService) SetMemberRole(userID string, groupID string, targetUserID string, role string) error {
// 检查群组是否存在 // 检查群组是否存在
@@ -1596,6 +1622,18 @@ func (s *groupService) GetMember(groupID string, userID string) (*model.GroupMem
return s.groupRepo.GetMember(groupID, userID) return s.groupRepo.GetMember(groupID, userID)
} }
// IsGroupMember 检查用户是否为群成员。
func (s *groupService) IsGroupMember(userID string, groupID string) bool {
if userID == "" || groupID == "" {
return false
}
ok, err := s.groupRepo.IsMember(groupID, userID)
if err != nil {
return false
}
return ok
}
// ==================== 游标分页方法 ==================== // ==================== 游标分页方法 ====================
// GetGroupsByCursor 游标分页获取群组列表 // GetGroupsByCursor 游标分页获取群组列表

View File

@@ -2,14 +2,31 @@ package service
import ( import (
"time" "time"
"with_you/internal/pkg/jwt" "with_you/internal/pkg/jwt"
) )
// JWTService JWT服务接口 // JWTService JWT服务接口
//
// 拆分为按令牌类型的解析 API避免历史单一 ParseToken 让 refresh token 误当 access token 使用。
// 旧的 GenerateAccessToken/GenerateRefreshToken 签名保留兼容;带 Session 的版本由 SessionService
// 配合使用,把 sid 写入 claims 支持撤销。
type JWTService interface { type JWTService interface {
GenerateAccessToken(userID, username string) (string, error) GenerateAccessToken(userID, username string) (string, error)
GenerateRefreshToken(userID, username string) (string, error) GenerateRefreshToken(userID, username string) (string, error)
// 带会话 ID 的签发sid 写入 claims供 RequireAuth 校验会话有效性。
GenerateAccessTokenWithSession(userID, username, sessionID string) (string, error)
GenerateRefreshTokenWithSession(userID, username, sessionID string) (string, error)
// 按令牌类型严格解析。
ParseAccessToken(tokenString string) (*jwt.Claims, error)
ParseRefreshToken(tokenString string) (*jwt.Claims, error)
// Deprecated: 仅做签名+过期校验,不区分令牌类型。新代码应使用 ParseAccessToken/ParseRefreshToken。
ParseToken(tokenString string) (*jwt.Claims, error) ParseToken(tokenString string) (*jwt.Claims, error)
// Deprecated: 新代码直接用 ParseAccessToken。保留仅为历史调用点平滑迁移。
ValidateToken(tokenString string) error ValidateToken(tokenString string) error
} }
@@ -25,22 +42,47 @@ func NewJWTService(secret string, accessExpire, refreshExpire int64) JWTService
} }
} }
// GenerateAccessToken 生成访问令牌 // GenerateAccessToken 生成访问令牌(不带 sid仅向后兼容
func (s *jwtServiceImpl) GenerateAccessToken(userID, username string) (string, error) { func (s *jwtServiceImpl) GenerateAccessToken(userID, username string) (string, error) {
return s.jwt.GenerateAccessToken(userID, username) return s.jwt.GenerateAccessToken(userID, username, "")
} }
// GenerateRefreshToken 生成刷新令牌 // GenerateRefreshToken 生成刷新令牌(不带 sid仅向后兼容
func (s *jwtServiceImpl) GenerateRefreshToken(userID, username string) (string, error) { func (s *jwtServiceImpl) GenerateRefreshToken(userID, username string) (string, error) {
return s.jwt.GenerateRefreshToken(userID, username) return s.jwt.GenerateRefreshToken(userID, username, "")
} }
// ParseToken 解析令牌 // GenerateAccessTokenWithSession 生成带会话 ID 的访问令牌
func (s *jwtServiceImpl) GenerateAccessTokenWithSession(userID, username, sessionID string) (string, error) {
return s.jwt.GenerateAccessToken(userID, username, sessionID)
}
// GenerateRefreshTokenWithSession 生成带会话 ID 的刷新令牌。
func (s *jwtServiceImpl) GenerateRefreshTokenWithSession(userID, username, sessionID string) (string, error) {
return s.jwt.GenerateRefreshToken(userID, username, sessionID)
}
// ParseAccessToken 解析访问令牌(仅接受 typ=access
func (s *jwtServiceImpl) ParseAccessToken(tokenString string) (*jwt.Claims, error) {
return s.jwt.ParseAccessToken(tokenString)
}
// ParseRefreshToken 解析刷新令牌(仅接受 typ=refresh
func (s *jwtServiceImpl) ParseRefreshToken(tokenString string) (*jwt.Claims, error) {
return s.jwt.ParseRefreshToken(tokenString)
}
// ParseToken 解析令牌(兼容签名)。
//
// Deprecated: 新代码使用 ParseAccessToken / ParseRefreshToken。
// 这里内部仍走 ParseAccessToken 以拒绝 refresh token避免历史调用点继续把 refresh 当 access 使用。
func (s *jwtServiceImpl) ParseToken(tokenString string) (*jwt.Claims, error) { func (s *jwtServiceImpl) ParseToken(tokenString string) (*jwt.Claims, error) {
return s.jwt.ParseToken(tokenString) return s.jwt.ParseToken(tokenString)
} }
// ValidateToken 验证令牌 // ValidateToken 验证令牌
//
// Deprecated: 新代码直接使用 ParseAccessToken。
func (s *jwtServiceImpl) ValidateToken(tokenString string) error { func (s *jwtServiceImpl) ValidateToken(tokenString string) error {
return s.jwt.ValidateToken(tokenString) return s.jwt.ValidateToken(tokenString)
} }

View File

@@ -2,14 +2,17 @@ package service
import ( import (
"context" "context"
"errors"
"time" "time"
"with_you/internal/cache" "with_you/internal/cache"
apperrors "with_you/internal/errors"
"with_you/internal/model" "with_you/internal/model"
"with_you/internal/pkg/cursor" "with_you/internal/pkg/cursor"
"with_you/internal/repository" "with_you/internal/repository"
"go.uber.org/zap" "go.uber.org/zap"
"gorm.io/gorm"
) )
// 缓存TTL常量 // 缓存TTL常量
@@ -36,7 +39,9 @@ type MessageService interface {
GetMyParticipantsBatch(ctx context.Context, convIDs []string, userID string) (map[string]*model.ConversationParticipant, error) GetMyParticipantsBatch(ctx context.Context, convIDs []string, userID string) (map[string]*model.ConversationParticipant, error)
InvalidateUserConversationCache(userID string) InvalidateUserConversationCache(userID string)
InvalidateUserUnreadCache(userID, conversationID string) InvalidateUserUnreadCache(userID, conversationID string)
GetMessagesByCursor(ctx context.Context, conversationID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Message], error) // GetMessagesByCursor 游标分页获取会话消息。
// currentUserID 用于参与者校验:非会话参与者拒绝(防止 IDOR
GetMessagesByCursor(ctx context.Context, conversationID, currentUserID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Message], error)
GetConversationsByCursor(ctx context.Context, userID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Conversation], error) GetConversationsByCursor(ctx context.Context, userID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Conversation], error)
} }
@@ -336,7 +341,22 @@ func (s *messageService) InvalidateUserUnreadCache(userID, conversationID string
} }
// GetMessagesByCursor 游标分页获取会话消息 // GetMessagesByCursor 游标分页获取会话消息
func (s *messageService) GetMessagesByCursor(ctx context.Context, conversationID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Message], error) { //
// 鉴权currentUserID 必须是会话参与者,否则返回 ErrNotConversationMember。
// 防止 IDOR任意登录用户无法仅凭 conversationID 读取他人会话消息。
func (s *messageService) GetMessagesByCursor(ctx context.Context, conversationID, currentUserID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Message], error) {
if currentUserID == "" {
return nil, apperrors.ErrUnauthorized
}
// 通过 messageRepo.GetParticipantStrict 校验参与者身份(纯查询,不创建记录)。
// 与 chatService 中 GetMessages 等方法保持一致的鉴权边界。
_, err := s.messageRepo.GetParticipantStrict(conversationID, currentUserID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, apperrors.ErrNotConversationMember
}
return nil, err
}
return s.messageRepo.GetMessagesByCursor(ctx, conversationID, req) return s.messageRepo.GetMessagesByCursor(ctx, conversationID, req)
} }

View File

@@ -96,6 +96,19 @@ func (s *roleService) AssignRole(ctx context.Context, operatorID, targetUserID,
return apperrors.ErrCannotModifyOwnRole return apperrors.ErrCannotModifyOwnRole
} }
// 纵深防御:分配 super_admin 仅 super_admin 自身可操作。
// 路由层 Authorize 已通过 admin.users.roles 限制 super_admin 能拿到此动作,
// 这里再独立校验 operator 角色,防止路由策略错配或服务被其他路径调用造成提权。
if role == model.RoleSuperAdmin {
roles, err := s.casbinSvc.GetRolesForUser(ctx, operatorID)
if err != nil {
return apperrors.ErrCasbinInternal
}
if !containsRole(roles, model.RoleSuperAdmin) {
return apperrors.ErrCannotModifySuperAdmin
}
}
// 检查目标用户是否已有该角色 // 检查目标用户是否已有该角色
hasRole, err := s.casbinSvc.HasRoleForUser(ctx, targetUserID, role) hasRole, err := s.casbinSvc.HasRoleForUser(ctx, targetUserID, role)
if err != nil { if err != nil {
@@ -126,6 +139,8 @@ func (s *roleService) RemoveRole(ctx context.Context, operatorID, targetUserID,
return apperrors.ErrCannotModifySuperAdmin return apperrors.ErrCannotModifySuperAdmin
} }
// 纵深防御:移除其他用户的 super_admin 角色同样要求 operator 是 super_admin。
// role==super_admin 已被上面拦截,这里针对未来扩展:如果允许移除其他高优先级角色再加规则。)
// 检查用户是否有该角色 // 检查用户是否有该角色
hasRole, err := s.casbinSvc.HasRoleForUser(ctx, targetUserID, role) hasRole, err := s.casbinSvc.HasRoleForUser(ctx, targetUserID, role)
if err != nil { if err != nil {
@@ -138,6 +153,16 @@ func (s *roleService) RemoveRole(ctx context.Context, operatorID, targetUserID,
return s.casbinSvc.DeleteRoleForUser(ctx, targetUserID, role) return s.casbinSvc.DeleteRoleForUser(ctx, targetUserID, role)
} }
// containsRole 检查角色集合中是否包含指定角色。
func containsRole(roles []string, role string) bool {
for _, r := range roles {
if r == role {
return true
}
}
return false
}
func (s *roleService) GetAllRoles(ctx context.Context) ([]model.Role, error) { func (s *roleService) GetAllRoles(ctx context.Context) ([]model.Role, error) {
return s.roleRepo.GetAllRoles(ctx) return s.roleRepo.GetAllRoles(ctx)
} }

View File

@@ -0,0 +1,222 @@
package service
import (
"context"
"errors"
"testing"
apperrors "with_you/internal/errors"
"with_you/internal/model"
"github.com/google/uuid"
"gorm.io/gorm"
)
// fakeRoleRepo 实现 repository.RoleRepository 接口,用于测试。
type fakeRoleRepo struct {
roles map[string]bool // userID -> role set不支持多角色测试足够
addErr error
getRoleErr error
rolesForUser []string
getRoleByName *model.Role
}
func (f *fakeRoleRepo) GetUserRoles(ctx context.Context, userID string) ([]string, error) {
return f.rolesForUser, nil
}
func (f *fakeRoleRepo) AddRoleForUser(ctx context.Context, userID, role string) error {
if f.addErr != nil {
return f.addErr
}
if f.roles == nil {
f.roles = make(map[string]bool)
}
f.roles[userID+":"+role] = true
return nil
}
func (f *fakeRoleRepo) DeleteRoleForUser(ctx context.Context, userID, role string) error {
if f.roles == nil {
return nil
}
delete(f.roles, userID+":"+role)
return nil
}
func (f *fakeRoleRepo) DeleteAllRolesForUser(ctx context.Context, userID string) error {
return nil
}
func (f *fakeRoleRepo) HasRole(ctx context.Context, userID, role string) (bool, error) {
if f.roles == nil {
return false, nil
}
return f.roles[userID+":"+role], nil
}
func (f *fakeRoleRepo) GetRole(ctx context.Context, name string) (*model.Role, error) {
if f.getRoleErr != nil {
return nil, f.getRoleErr
}
if f.getRoleByName != nil && f.getRoleByName.Name == name {
return f.getRoleByName, nil
}
return &model.Role{Name: name}, nil
}
func (f *fakeRoleRepo) GetAllRoles(ctx context.Context) ([]model.Role, error) {
return nil, nil
}
func (f *fakeRoleRepo) GetRoleUserCount(ctx context.Context, role string) (int64, error) {
return 0, nil
}
// fakeCasbinForRole 测试 CasbinService 的最小实现,覆盖 AssignRole 所需方法。
type fakeCasbinForRole struct {
roles map[string][]string // userID -> roles
hasRoleMap map[string]bool // "userID:role" -> bool
addErr error
}
func (f *fakeCasbinForRole) Enforce(ctx context.Context, sub, obj, act string) (bool, error) {
return false, nil
}
func (f *fakeCasbinForRole) EnforceForUser(ctx context.Context, userID, obj, act string) (bool, error) {
return false, nil
}
func (f *fakeCasbinForRole) AddRoleForUser(ctx context.Context, userID, role string) error {
if f.addErr != nil {
return f.addErr
}
if f.roles == nil {
f.roles = make(map[string][]string)
}
f.roles[userID] = append(f.roles[userID], role)
return nil
}
func (f *fakeCasbinForRole) DeleteRoleForUser(ctx context.Context, userID, role string) error {
return nil
}
func (f *fakeCasbinForRole) GetRolesForUser(ctx context.Context, userID string) ([]string, error) {
return f.roles[userID], nil
}
func (f *fakeCasbinForRole) HasRoleForUser(ctx context.Context, userID, role string) (bool, error) {
if f.hasRoleMap == nil {
return false, nil
}
return f.hasRoleMap[userID+":"+role], nil
}
func (f *fakeCasbinForRole) AddPolicy(ctx context.Context, role, resource, action string) error {
return nil
}
func (f *fakeCasbinForRole) RemovePolicy(ctx context.Context, role, resource, action string) error {
return nil
}
func (f *fakeCasbinForRole) LoadPolicy(ctx context.Context) error { return nil }
func (f *fakeCasbinForRole) ClearCache(ctx context.Context) error { return nil }
func (f *fakeCasbinForRole) GetPermissionsForRole(ctx context.Context, role string) ([][]string, error) {
return nil, nil
}
func (f *fakeCasbinForRole) UpdatePermissionsForRole(ctx context.Context, role string, perms [][]string) error {
return nil
}
func (f *fakeCasbinForRole) GetAllPermissions(ctx context.Context) [][]string { return nil }
// TestAssignRole_NonSuperAdminAssigningSuperAdmin 非 super_admin 分配 super_admin → 拒绝。
func TestAssignRole_NonSuperAdminAssigningSuperAdmin(t *testing.T) {
roleRepo := &fakeRoleRepo{
getRoleByName: &model.Role{Name: model.RoleSuperAdmin},
}
casbinSvc := &fakeCasbinForRole{
roles: map[string][]string{
"operator": {model.RoleAdmin}, // operator 是普通 admin不是 super_admin
},
}
svc := NewRoleService(roleRepo, casbinSvc)
err := svc.AssignRole(context.Background(), "operator", "target", model.RoleSuperAdmin)
if !errors.Is(err, apperrors.ErrCannotModifySuperAdmin) {
t.Errorf("expected ErrCannotModifySuperAdmin, got %v", err)
}
}
// TestAssignRole_SuperAdminAssigningSuperAdmin super_admin 分配 super_admin → 成功。
func TestAssignRole_SuperAdminAssigningSuperAdmin(t *testing.T) {
roleRepo := &fakeRoleRepo{
getRoleByName: &model.Role{Name: model.RoleSuperAdmin},
}
casbinSvc := &fakeCasbinForRole{
roles: map[string][]string{
"operator": {model.RoleSuperAdmin}, // operator 是 super_admin
},
}
svc := NewRoleService(roleRepo, casbinSvc)
err := svc.AssignRole(context.Background(), "operator", "target", model.RoleSuperAdmin)
if err != nil {
t.Errorf("super_admin should be able to assign super_admin, got %v", err)
}
}
// TestAssignRole_SelfModification 不能改自己的角色。
func TestAssignRole_SelfModification(t *testing.T) {
roleRepo := &fakeRoleRepo{}
casbinSvc := &fakeCasbinForRole{}
svc := NewRoleService(roleRepo, casbinSvc)
err := svc.AssignRole(context.Background(), "u1", "u1", model.RoleAdmin)
if !errors.Is(err, apperrors.ErrCannotModifyOwnRole) {
t.Errorf("expected ErrCannotModifyOwnRole, got %v", err)
}
}
// TestRemoveRole_SuperAdminRole 不能移除 super_admin 角色。
func TestRemoveRole_SuperAdminRole(t *testing.T) {
roleRepo := &fakeRoleRepo{
roles: map[string]bool{"target:" + model.RoleSuperAdmin: true},
}
casbinSvc := &fakeCasbinForRole{
hasRoleMap: map[string]bool{"target:" + model.RoleSuperAdmin: true},
}
svc := NewRoleService(roleRepo, casbinSvc)
err := svc.RemoveRole(context.Background(), "operator", "target", model.RoleSuperAdmin)
if !errors.Is(err, apperrors.ErrCannotModifySuperAdmin) {
t.Errorf("expected ErrCannotModifySuperAdmin, got %v", err)
}
}
// TestAssignRole_RoleNotFound 角色不存在 → ErrRoleNotFound。
func TestAssignRole_RoleNotFound(t *testing.T) {
roleRepo := &fakeRoleRepo{
getRoleErr: gorm.ErrRecordNotFound,
}
casbinSvc := &fakeCasbinForRole{
roles: map[string][]string{
"operator": {model.RoleSuperAdmin},
},
}
svc := NewRoleService(roleRepo, casbinSvc)
err := svc.AssignRole(context.Background(), "operator", "target", model.RoleAdmin)
if !errors.Is(err, apperrors.ErrRoleNotFound) {
t.Errorf("expected ErrRoleNotFound, got %v", err)
}
}
// 确保编译期 import 使用。
var _ = uuid.New

View File

@@ -0,0 +1,179 @@
package service
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"time"
"go.uber.org/zap"
apperrors "with_you/internal/errors"
"with_you/internal/model"
"with_you/internal/repository"
)
// SessionRefreshExpireGracePeriod 会话过期判断的容差:以 ExpiresAt 为准,避免边界条件。
const (
SessionRefreshExpireGracePeriod = 1 * time.Second
)
// SessionService 会话服务
//
// 负责会话生命周期:签发、校验、轮换、撤销。
// access/refresh token 在 JWT 层只做签名/过期/类型校验,
// 真正的撤销能力(登出/封禁后旧 token 立即失效)由会话表提供:
// - Issue创建会话返回写入了 sid 的 access/refresh token 与会话记录。
// - Validate在 refresh 端点校验 refresh token 对应会话是否仍有效。
// - Rotate刷新时撤销旧 refresh、签发新会话与令牌对。
// - Revoke/RevokeAllByUser登出/封禁时撤销。
type SessionService interface {
Issue(ctx context.Context, userID, username, userAgent, ip string, refreshExpire time.Duration) (session *model.Session, accessToken, refreshToken string, err error)
Validate(ctx context.Context, refreshToken string) (*model.Session, error)
Rotate(ctx context.Context, oldRefreshToken, username, userAgent, ip string, refreshExpire time.Duration) (session *model.Session, accessToken, refreshToken string, err error)
Revoke(ctx context.Context, sessionID string) error
RevokeAllByUser(ctx context.Context, userID string) error
}
type sessionService struct {
sessionRepo repository.SessionRepository
jwtService JWTService
}
// NewSessionService 创建会话服务。
func NewSessionService(sessionRepo repository.SessionRepository, jwtService JWTService) SessionService {
return &sessionService{
sessionRepo: sessionRepo,
jwtService: jwtService,
}
}
// hashRefreshToken 对 refresh token 明文做 SHA256 哈希。
//
// 不使用 bcryptrefresh token 本身已是高熵随机串SHA256 足够防泄露;
// bcrypt 会引入额外成本并使按 hash 反查会话不可行。
func hashRefreshToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
// Issue 创建会话并签发令牌对。
func (s *sessionService) Issue(ctx context.Context, userID, username, userAgent, ip string, refreshExpire time.Duration) (session *model.Session, accessToken, refreshToken string, err error) {
now := time.Now()
expireAt := now.Add(refreshExpire)
// 先签发 refresh tokensid 留空待补,避免一次性生成两个 JWT 与 session id 不一致)。
// 实际实现:先创建 session 得到 ID再用 sid 签发两个 token。
session = &model.Session{
UserID: userID,
UserAgent: userAgent,
IP: ip,
IssuedAt: now,
ExpiresAt: expireAt,
}
if err = s.sessionRepo.Create(ctx, session); err != nil {
return nil, "", "", err
}
accessToken, err = s.jwtService.GenerateAccessTokenWithSession(userID, username, session.ID)
if err != nil {
_ = s.sessionRepo.Revoke(ctx, session.ID)
return nil, "", "", err
}
refreshToken, err = s.jwtService.GenerateRefreshTokenWithSession(userID, username, session.ID)
if err != nil {
_ = s.sessionRepo.Revoke(ctx, session.ID)
return nil, "", "", err
}
session.RefreshTokenHash = hashRefreshToken(refreshToken)
if uerr := s.sessionRepo.Update(ctx, session); uerr != nil {
// 哈希写入失败时撤销会话,避免出现无 hash 的不可轮换会话。
_ = s.sessionRepo.Revoke(ctx, session.ID)
return nil, "", "", uerr
}
return session, accessToken, refreshToken, nil
}
// Validate 校验 refresh token 对应会话是否仍有效。
func (s *sessionService) Validate(ctx context.Context, refreshToken string) (*model.Session, error) {
if refreshToken == "" {
return nil, apperrors.ErrInvalidToken
}
// 先解析 JWT 拿到 sid避免仅靠 hash 反查;同时确保 refresh token 类型正确。
claims, err := s.jwtService.ParseRefreshToken(refreshToken)
if err != nil {
return nil, apperrors.ErrInvalidToken
}
session, err := s.sessionRepo.GetByID(ctx, claims.SessionID)
if err != nil {
return nil, apperrors.ErrSessionRevoked
}
// 校验 refresh token hash 与会话匹配,防止 sid 复用但 refresh 已轮换。
hash := hashRefreshToken(refreshToken)
if session.RefreshTokenHash != hash {
return nil, apperrors.ErrSessionRevoked
}
if session.IsRevoked() {
return nil, apperrors.ErrSessionRevoked
}
if session.IsExpired(time.Now()) {
return nil, apperrors.ErrTokenExpired
}
// 更新最近使用时间,便于审计与会话清理判断。
// 失败不阻塞鉴权主路径仅记日志LastUsedAt 仅用于审计,不参与鉴权决策。
session.LastUsedAt = time.Now()
if uerr := s.sessionRepo.Update(ctx, session); uerr != nil {
zap.L().Warn("failed to update session last_used_at",
zap.String("session_id", session.ID),
zap.Error(uerr),
)
}
return session, nil
}
// Rotate 撤销旧 refresh token、签发新会话与令牌对。
func (s *sessionService) Rotate(ctx context.Context, oldRefreshToken, username, userAgent, ip string, refreshExpire time.Duration) (session *model.Session, accessToken, refreshToken string, err error) {
oldSession, err := s.Validate(ctx, oldRefreshToken)
if err != nil {
return nil, "", "", err
}
// 撤销旧会话(其 refresh token 不再可用)。
if rerr := s.sessionRepo.Revoke(ctx, oldSession.ID); rerr != nil {
return nil, "", "", rerr
}
newSession, accessToken, refreshToken, err := s.Issue(ctx, oldSession.UserID, username, userAgent, ip, refreshExpire)
if err != nil {
return nil, "", "", err
}
return newSession, accessToken, refreshToken, nil
}
// Revoke 撤销指定会话(登出)。
func (s *sessionService) Revoke(ctx context.Context, sessionID string) error {
if sessionID == "" {
return errors.New("session id is required")
}
return s.sessionRepo.Revoke(ctx, sessionID)
}
// RevokeAllByUser 撤销用户的所有会话(封禁/重置密码)。
func (s *sessionService) RevokeAllByUser(ctx context.Context, userID string) error {
if userID == "" {
return errors.New("user id is required")
}
// 同时刷新 access tokens 关联账户access token 仅在 TTL 内可见,会话撤销后 refresh 已立即失效。
// 如未来需要 access token 即时失效,可引入 jti 黑名单,但当前 access TTL 较短可接受。
return s.sessionRepo.RevokeAllByUser(ctx, userID)
}

View File

@@ -7,9 +7,12 @@ import (
"strings" "strings"
"time" "time"
"go.uber.org/zap"
"with_you/internal/cache" "with_you/internal/cache"
apperrors "with_you/internal/errors" apperrors "with_you/internal/errors"
"with_you/internal/model" "with_you/internal/model"
"with_you/internal/pkg/auth"
"with_you/internal/pkg/utils" "with_you/internal/pkg/utils"
"with_you/internal/repository" "with_you/internal/repository"
) )
@@ -73,10 +76,15 @@ type UserService interface {
} }
// userServiceImpl 用户服务实现 // userServiceImpl 用户服务实现
//
// sessionSvc 与 cache 用于改密/重置密码后撤销该用户全部登录会话并失效 Principal 缓存,
// 让攻击者即使持有旧 access token 也无法用 refresh token 续期。
type userServiceImpl struct { type userServiceImpl struct {
userRepo repository.UserRepository userRepo repository.UserRepository
systemMessageService SystemMessageService systemMessageService SystemMessageService
emailCodeService EmailCodeService emailCodeService EmailCodeService
sessionSvc SessionService
cache cache.Cache
logService *LogService logService *LogService
} }
@@ -87,11 +95,14 @@ func NewUserService(
emailService EmailService, emailService EmailService,
cacheBackend cache.Cache, cacheBackend cache.Cache,
logService *LogService, logService *LogService,
sessionSvc SessionService,
) UserService { ) UserService {
return &userServiceImpl{ return &userServiceImpl{
userRepo: userRepo, userRepo: userRepo,
systemMessageService: systemMessageService, systemMessageService: systemMessageService,
emailCodeService: NewEmailCodeService(emailService, cacheBackend), emailCodeService: NewEmailCodeService(emailService, cacheBackend),
sessionSvc: sessionSvc,
cache: cacheBackend,
logService: logService, logService: logService,
} }
} }
@@ -687,6 +698,11 @@ func (s *userServiceImpl) ChangePassword(ctx context.Context, userID, oldPasswor
return err return err
} }
// 改密成功后撤销该用户全部登录会话并失效 Principal 缓存。
// refresh token 立即失效access token 在剩余 TTL 内仍可用(无状态 JWT 固有限制)。
// 失败仅告警不阻塞:密码已变更,缓存最终会自然过期。
s.invalidateSessionsAfterAuthChange(ctx, userID, "change_password")
// 记录数据变更日志 // 记录数据变更日志
if s.logService != nil { if s.logService != nil {
s.logService.DataChangeLog.RecordDataChange(&model.DataChangeLog{ s.logService.DataChangeLog.RecordDataChange(&model.DataChangeLog{
@@ -728,7 +744,32 @@ func (s *userServiceImpl) ResetPasswordByEmail(ctx context.Context, email, verif
return err return err
} }
user.PasswordHash = hashedPassword user.PasswordHash = hashedPassword
return s.userRepo.Update(user) if err := s.userRepo.Update(user); err != nil {
return err
}
// 重置密码成功后同样撤销该用户全部登录会话并失效 Principal 缓存。
s.invalidateSessionsAfterAuthChange(ctx, user.ID, "reset_password")
return nil
}
// invalidateSessionsAfterAuthChange 在密码变更/重置或同等敏感的认证态变更后,
// 统一执行会话撤销与 Principal 缓存失效。
//
// 失败仅告警密码已变更refresh token 即便撤销失败也无法凭旧密码换发新令牌,
// 风险窗口由 access TTL 收口;缓存最终会自然过期。
func (s *userServiceImpl) invalidateSessionsAfterAuthChange(ctx context.Context, userID, reason string) {
if s.sessionSvc != nil {
if err := s.sessionSvc.RevokeAllByUser(ctx, userID); err != nil {
zap.L().Warn("failed to revoke sessions after auth change",
zap.String("user_id", userID),
zap.String("reason", reason),
zap.Error(err),
)
}
}
auth.InvalidatePrincipalCache(s.cache, userID)
} }
// Search 搜索用户 // Search 搜索用户

View File

@@ -0,0 +1,54 @@
package wire
import (
"context"
"with_you/internal/middleware"
"with_you/internal/model"
"with_you/internal/repository"
"with_you/internal/service"
)
// ProvideIdentityProvider 提供认证管道依赖的 IdentityProvider 实现。
//
// 把 UserService + CasbinService + SessionRepository 组合为中间件所需的查询接口,
// 避免中间件直接依赖具体 service 三个包,便于测试以 fake 注入。
func ProvideIdentityProvider(
userService service.UserService,
casbinService service.CasbinService,
sessionRepo repository.SessionRepository,
) middleware.IdentityProvider {
return &identityProviderImpl{
userService: userService,
casbinService: casbinService,
sessionRepo: sessionRepo,
}
}
type identityProviderImpl struct {
userService service.UserService
casbinService service.CasbinService
sessionRepo repository.SessionRepository
}
func (i *identityProviderImpl) GetUserByID(ctx context.Context, userID string) (*model.User, error) {
return i.userService.GetUserByID(ctx, userID)
}
func (i *identityProviderImpl) GetRolesForUser(ctx context.Context, userID string) ([]string, error) {
roles, err := i.casbinService.GetRolesForUser(ctx, userID)
if err != nil {
return nil, err
}
if roles == nil {
roles = []string{}
}
return roles, nil
}
func (i *identityProviderImpl) GetSessionByID(ctx context.Context, sessionID string) (*model.Session, error) {
if sessionID == "" {
return nil, nil
}
return i.sessionRepo.GetByID(ctx, sessionID)
}

View File

@@ -10,4 +10,7 @@ var AllSet = wire.NewSet(
ServiceSet, ServiceSet,
HandlerSet, HandlerSet,
GRPCSet, GRPCSet,
// 认证管道依赖的身份提供者
ProvideIdentityProvider,
) )

View File

@@ -58,6 +58,9 @@ var RepositorySet = wire.NewSet(
// 文件上传记录(聊天文件 TTL 过期清理) // 文件上传记录(聊天文件 TTL 过期清理)
repository.NewUploadedFileRepository, repository.NewUploadedFileRepository,
// 会话(令牌撤销支持)
repository.NewSessionRepository,
) )
// ProvideUserActivityRepository 提供用户活跃数据仓储 // ProvideUserActivityRepository 提供用户活跃数据仓储

View File

@@ -28,6 +28,9 @@ var ServiceSet = wire.NewSet(
// JWT 服务(需要配置参数) // JWT 服务(需要配置参数)
ProvideJWTService, ProvideJWTService,
// 会话服务(令牌撤销支持)
ProvideSessionService,
// 基础服务 // 基础服务
ProvideEmailService, ProvideEmailService,
ProvidePostAIService, ProvidePostAIService,
@@ -97,6 +100,14 @@ func ProvideJWTService(cfg *config.Config) service.JWTService {
) )
} }
// ProvideSessionService 提供会话服务(用于令牌撤销)。
func ProvideSessionService(
sessionRepo repository.SessionRepository,
jwtService service.JWTService,
) service.SessionService {
return service.NewSessionService(sessionRepo, jwtService)
}
// ProvideEmailService 提供邮件服务 // ProvideEmailService 提供邮件服务
func ProvideEmailService(client email.Client) service.EmailService { func ProvideEmailService(client email.Client) service.EmailService {
return service.NewEmailService(client) return service.NewEmailService(client)
@@ -180,8 +191,9 @@ func ProvideUserService(
emailService service.EmailService, emailService service.EmailService,
cacheBackend cache.Cache, cacheBackend cache.Cache,
logService *service.LogService, logService *service.LogService,
sessionService service.SessionService,
) service.UserService { ) service.UserService {
return service.NewUserService(userRepo, systemMessageService, emailService, cacheBackend, logService) return service.NewUserService(userRepo, systemMessageService, emailService, cacheBackend, logService, sessionService)
} }
// ProvideStickerService 提供表情服务 // ProvideStickerService 提供表情服务
@@ -222,6 +234,7 @@ func ProvideChatService(
redisClient *redis.Client, redisClient *redis.Client,
versionLogRepo repository.ConversationVersionLogRepository, versionLogRepo repository.ConversationVersionLogRepository,
cfg *config.Config, cfg *config.Config,
groupService service.GroupService,
) service.ChatService { ) service.ChatService {
var vlogOpt []repository.ConversationVersionLogRepository var vlogOpt []repository.ConversationVersionLogRepository
if cfg.VersionLog.Enabled { if cfg.VersionLog.Enabled {
@@ -231,7 +244,9 @@ func ProvideChatService(
if redisClient != nil { if redisClient != nil {
rdb = redisClient.GetClient() rdb = redisClient.GetClient()
} }
return service.NewChatService(messageRepo, userRepo, nil, publisher, cacheBackend, uploadService, pushService, seqBufferMgr, pushWorker, rdb, vlogOpt...) // 注入 GroupAbility群消息发送前校验成员/禁言。
groupAbility := service.NewGroupAbility(groupService)
return service.NewChatService(messageRepo, userRepo, nil, publisher, cacheBackend, uploadService, pushService, seqBufferMgr, pushWorker, rdb, groupAbility, vlogOpt...)
} }
// ProvideScheduleService 提供日程服务 // ProvideScheduleService 提供日程服务
@@ -340,8 +355,14 @@ func ProvideRoleService(
} }
// ProvideAdminUserService 提供管理端用户服务 // ProvideAdminUserService 提供管理端用户服务
func ProvideAdminUserService(userRepo repository.UserRepository) service.AdminUserService { //
return service.NewAdminUserService(userRepo) // 注入 SessionService 与 cache封禁/停用用户时撤销其全部登录会话并失效 Principal 缓存。
func ProvideAdminUserService(
userRepo repository.UserRepository,
sessionService service.SessionService,
cacheBackend cache.Cache,
) service.AdminUserService {
return service.NewAdminUserService(userRepo, sessionService, cacheBackend)
} }
// ProvideAdminPostService 提供管理端帖子服务 // ProvideAdminPostService 提供管理端帖子服务