From eb931bf1c6d4979eea186750e72c1a0f94a552fc Mon Sep 17 00:00:00 2001 From: lafay <2021211506@stu.hit.edu.cn> Date: Sun, 5 Jul 2026 18:28:08 +0800 Subject: [PATCH] feat(auth): implement session-based token management with revocation support 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/ resource naming --- cmd/server/wire.go | 12 +- cmd/server/wire_gen.go | 29 +- configs/casbin/model.conf | 11 +- internal/database/database.go | 130 +++++++-- internal/database/database_test.go | 6 +- internal/dto/group_dto.go | 1 + internal/errors/app_errors.go | 7 + internal/handler/group_handler.go | 42 ++- internal/handler/message_handler.go | 6 +- internal/handler/user_handler.go | 97 +++++-- internal/handler/ws_handler.go | 3 +- internal/middleware/active.go | 35 +++ internal/middleware/auth.go | 82 +----- internal/middleware/auth_pipeline.go | 287 ++++++++++++++++++++ internal/middleware/auth_pipeline_test.go | 225 ++++++++++++++++ internal/middleware/authorize.go | 50 ++++ internal/model/session.go | 55 ++++ internal/pkg/auth/cache.go | 22 ++ internal/pkg/auth/principal.go | 116 ++++++++ internal/pkg/jwt/jwt.go | 127 +++++++-- internal/pkg/jwt/jwt_test.go | 120 +++++++++ internal/pkg/response/response.go | 6 +- internal/repository/message_repo.go | 19 ++ internal/repository/session_repo.go | 90 +++++++ internal/router/router.go | 310 +++++++++++----------- internal/service/admin_user_service.go | 37 ++- internal/service/casbin_service.go | 59 ++-- internal/service/casbin_service_test.go | 186 +++++++++++++ internal/service/chat_service.go | 29 +- internal/service/group_ability.go | 31 +++ internal/service/group_service.go | 38 +++ internal/service/jwt_service.go | 54 +++- internal/service/message_service.go | 24 +- internal/service/role_service.go | 25 ++ internal/service/role_service_test.go | 222 ++++++++++++++++ internal/service/session_service.go | 179 +++++++++++++ internal/service/user_service.go | 43 ++- internal/wire/identity_provider.go | 54 ++++ internal/wire/provider_set.go | 3 + internal/wire/repository.go | 3 + internal/wire/service.go | 29 +- 41 files changed, 2544 insertions(+), 360 deletions(-) create mode 100644 internal/middleware/active.go create mode 100644 internal/middleware/auth_pipeline.go create mode 100644 internal/middleware/auth_pipeline_test.go create mode 100644 internal/middleware/authorize.go create mode 100644 internal/model/session.go create mode 100644 internal/pkg/auth/cache.go create mode 100644 internal/pkg/auth/principal.go create mode 100644 internal/pkg/jwt/jwt_test.go create mode 100644 internal/repository/session_repo.go create mode 100644 internal/service/casbin_service_test.go create mode 100644 internal/service/group_ability.go create mode 100644 internal/service/role_service_test.go create mode 100644 internal/service/session_service.go create mode 100644 internal/wire/identity_provider.go diff --git a/cmd/server/wire.go b/cmd/server/wire.go index 26f83fa..084e257 100644 --- a/cmd/server/wire.go +++ b/cmd/server/wire.go @@ -4,13 +4,15 @@ package main import ( + "github.com/google/wire" + + "with_you/internal/cache" "with_you/internal/handler" + "with_you/internal/middleware" "with_you/internal/repository" "with_you/internal/router" "with_you/internal/service" appwire "with_you/internal/wire" - - "github.com/google/wire" ) // InitializeApp 初始化应用程序 @@ -33,6 +35,9 @@ func ProvideRouter( notificationHandler *handler.NotificationHandler, uploadHandler *handler.UploadHandler, jwtService service.JWTService, + sessionService service.SessionService, + identityProvider middleware.IdentityProvider, + cache cache.Cache, pushHandler *handler.PushHandler, systemMessageHandler *handler.SystemMessageHandler, groupHandler *handler.GroupHandler, @@ -76,6 +81,9 @@ func ProvideRouter( NotificationHandler: notificationHandler, UploadHandler: uploadHandler, JWTService: jwtService, + SessionService: sessionService, + IdentityProvider: identityProvider, + Cache: cache, PushHandler: pushHandler, SystemMessageHandler: systemMessageHandler, GroupHandler: groupHandler, diff --git a/cmd/server/wire_gen.go b/cmd/server/wire_gen.go index 3f82d92..a1d6345 100644 --- a/cmd/server/wire_gen.go +++ b/cmd/server/wire_gen.go @@ -7,7 +7,9 @@ package main import ( + "with_you/internal/cache" "with_you/internal/handler" + "with_you/internal/middleware" "with_you/internal/repository" "with_you/internal/router" "with_you/internal/service" @@ -49,7 +51,10 @@ func InitializeApp() (*App, error) { loginLogService := wire.ProvideLoginLogService(asyncLogManager, loginLogRepository) dataChangeLogService := wire.ProvideDataChangeLogService(asyncLogManager, dataChangeLogRepository) 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) userActivityService := wire.ProvideUserActivityService(userActivityRepository) userProfileAuditRepository := repository.NewUserProfileAuditRepository(db) @@ -82,17 +87,20 @@ func InitializeApp() (*App, error) { seqBufferManager := wire.ProvideSeqBufferManager(config, client, cache) pushWorker := wire.ProvidePushWorker(config, client, messagePublisher, pushService, messageRepository, userRepository) 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) groupJoinRequestRepository := repository.NewGroupJoinRequestRepository(db) 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) notificationRepository := repository.NewNotificationRepository(db) notificationService := wire.ProvideNotificationService(notificationRepository, cache) notificationHandler := handler.NewNotificationHandler(notificationService) 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) systemMessageHandler := wire.ProvideSystemMessageHandler(systemMessageService) groupHandler := wire.ProvideGroupHandler(groupService, userService) @@ -118,12 +126,9 @@ func InitializeApp() (*App, error) { emptyClassroomRepository := repository.NewEmptyClassroomRepository(db) emptyClassroomSyncService := wire.ProvideEmptyClassroomSyncService(taskManager, emptyClassroomRepository, logger) 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) roleHandler := handler.NewRoleHandler(roleService) - adminUserService := wire.ProvideAdminUserService(userRepository) + adminUserService := wire.ProvideAdminUserService(userRepository, sessionService, cache) adminUserHandler := handler.NewAdminUserHandler(adminUserService, pushService) adminPostService := wire.ProvideAdminPostService(postRepository, cache, logService) adminPostHandler := handler.NewAdminPostHandler(adminPostService) @@ -162,7 +167,7 @@ func InitializeApp() (*App, error) { wsHandler := wire.ProvideWSHandler(messagePublisher, chatService, groupService, jwtService, callService, userService) liveKitService := wire.ProvideLiveKitService(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) fileCleanupWorker := wire.ProvideFileCleanupWorker(config, uploadedFileRepository, s3Client, client) deviceCleanupWorker := wire.ProvideDeviceCleanupWorker(config, deviceTokenRepository, client) @@ -185,6 +190,9 @@ func ProvideRouter( notificationHandler *handler.NotificationHandler, uploadHandler *handler.UploadHandler, jwtService service.JWTService, + sessionService service.SessionService, + identityProvider middleware.IdentityProvider, cache2 cache.Cache, + pushHandler *handler.PushHandler, systemMessageHandler *handler.SystemMessageHandler, groupHandler *handler.GroupHandler, @@ -228,6 +236,9 @@ func ProvideRouter( NotificationHandler: notificationHandler, UploadHandler: uploadHandler, JWTService: jwtService, + SessionService: sessionService, + IdentityProvider: identityProvider, + Cache: cache2, PushHandler: pushHandler, SystemMessageHandler: systemMessageHandler, GroupHandler: groupHandler, diff --git a/configs/casbin/model.conf b/configs/casbin/model.conf index 907f24e..77d64dc 100644 --- a/configs/casbin/model.conf +++ b/configs/casbin/model.conf @@ -10,5 +10,14 @@ g = _, _ [policy_effect] 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 使用 globMatch(doublestar 语义): +# - '* 匹配单层资源段(不跨 '/'); +# - '**' 跨多层(如 admin/** 命中 admin/users/roles/write)。 +# 资源命名采用路径式 admin/[/],避免 keyMatch2 把 '*' 当作正则通配 +# 跨 '.' 而误匹配;也避免 globMatch 在点号分隔下 '*' 仍跨 '.' 的过宽问题。 [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 == "*") diff --git a/internal/database/database.go b/internal/database/database.go index 5bd369e..d5352e2 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -234,6 +234,9 @@ func autoMigrate(db *gorm.DB) error { // 文件上传记录(用于聊天文件 TTL 过期清理) &model.UploadedFile{}, + + // 会话(令牌撤销支持) + &model.Session{}, ) if err != nil { return err @@ -254,6 +257,12 @@ func autoMigrate(db *gorm.DB) error { return fmt.Errorf("failed to seed permissions: %w", err) } + // 清理历史 Casbin g 分组规则(真源已统一为 user_roles 表,Casbin 仅保留 p 策略)。 + // 幂等:仅在 casbin_rule 中存在 ptype='g' 记录时删除。 + if err := cleanupLegacyCasbinGRules(db); err != nil { + return fmt.Errorf("failed to cleanup legacy casbin g rules: %w", err) + } + // 初始化频道配置种子数据 if err := seedChannels(db); err != nil { return fmt.Errorf("failed to seed channels: %w", err) @@ -285,6 +294,27 @@ func dropPostsHotScoreColumnIfExists(db *gorm.DB) error { return m.DropColumn(&postWithHotScore{}, "HotScore") } +// cleanupLegacyCasbinGRules 清理历史 Casbin g 分组规则。 +// +// 真源策略变更后:user_roles 表为用户-角色唯一真源,Casbin 仅保留 p, role, resource, action 策略。 +// 旧的 g, user, role 记录不再被 matcher 使用(model.conf 已移除 g 依赖),保留只会造成管理后台误解。 +// 幂等:仅删除 ptype='g' 的记录;若不存在则无操作。 +func cleanupLegacyCasbinGRules(db *gorm.DB) error { + if !db.Migrator().HasTable(&model.CasbinRule{}) { + return nil + } + result := db.Where("ptype = ?", "g").Delete(&model.CasbinRule{}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected > 0 { + zap.L().Info("Cleaned up legacy casbin g rules", + zap.Int64("count", result.RowsAffected), + ) + } + return nil +} + // seedRoles 初始化角色种子数据 func seedRoles(db *gorm.DB) error { // 检查是否已有角色数据 @@ -344,6 +374,14 @@ func seedRoles(db *gorm.DB) error { } // seedPermissions 初始化权限策略种子数据 +// +// 真源策略:user_roles 表为用户-角色真源,Casbin 仅维护 p, role, resource, action 策略。 +// 资源命名约定:admin/[/](路径式,配合 globMatch 中 * 不跨 /、** 跨 / 的语义)。 +// +// 升级兼容:旧版本以 URL 路径式资源(如 /api/v1/admin/*、/*)作为 Casbin 策略, +// 新版本改为 admin/ 资源命名后,存量部署需要迁移。本函数检测到任何 v1 以 '/' 开头的 +// 旧策略时,将其视为旧版本部署:清空所有 p 策略并按新约定重新 seed。 +// 新部署无此条件不触发,幂等(admin/ 不以 '/' 开头,不会误判为新旧迁移)。 func seedPermissions(db *gorm.DB) error { // 检查是否已有权限策略数据 var count int64 @@ -351,43 +389,79 @@ func seedPermissions(db *gorm.DB) error { return err } - // 如果已有数据,跳过种子初始化 if count > 0 { - return nil + // 检测是否存在旧版本路径式策略(v1 以 '/' 开头)。 + // 旧策略例如:{p, super_admin, /*, *} / {p, admin, /api/v1/admin/*, *} 等。 + // 新约定资源形如 admin/users(不以 '/' 开头),不会被本条件误命中。 + // 旧策略在新 model.conf 的 admin/ 命名下永远不会匹配,保留只会让管理后台权限全断。 + var legacyCount int64 + if err := db.Model(&model.CasbinRule{}). + Where("ptype = ? AND v1 LIKE '/%'", "p"). + Count(&legacyCount).Error; err != nil { + return err + } + if legacyCount == 0 { + // 已是新版本策略,无需重复 seed。 + return nil + } + + // 命中旧策略:迁移。先 dump 待删除策略到日志便于回查,再删除全部 p 策略。 + zap.L().Info("Migrating legacy path-based casbin p rules to admin/ resource naming", + zap.Int64("legacy_count", legacyCount), + zap.Int64("total_p_count", count), + ) + var legacyRules []model.CasbinRule + if err := db.Where("ptype = ?", "p").Find(&legacyRules).Error; err != nil { + return err + } + for _, r := range legacyRules { + zap.L().Debug("legacy casbin p rule will be removed", + zap.String("v0", r.V0), + zap.String("v1", r.V1), + zap.String("v2", r.V2), + ) + } + if err := db.Where("ptype = ?", "p").Delete(&model.CasbinRule{}).Error; err != nil { + return err + } + // 注意:count > 0 分支到此已清空所有 p 策略,下面统一重新 seed。 } // 预定义权限策略数据 // p = sub, obj, act (角色, 资源, 操作) + // + // 资源命名约定:admin/[/](路径式),动作:read / write / *。 + // super_admin 通过 admin/** 通配获得所有管理能力(globMatch 中 ** 跨 '/'); + // admin 仅获得业务管理能力,角色与权限管理(admin/roles/*、admin/users/roles/*) + // 与日志导出(admin/logs/export)仅 super_admin 拥有(admin/logs 单层匹配不跨 /)。 permissions := []model.CasbinRule{ - // 超级管理员 - 拥有所有权限 - {Ptype: "p", V0: model.RoleSuperAdmin, V1: "/*", V2: "*"}, + // 超级管理员 - 通配所有 admin 资源(globMatch:** 跨 '/' 分隔的多级资源) + {Ptype: "p", V0: model.RoleSuperAdmin, V1: "admin/**", V2: "*"}, - // 管理员 - 拥有管理权限 - {Ptype: "p", V0: model.RoleAdmin, V1: "/api/v1/admin/*", V2: "*"}, - {Ptype: "p", V0: model.RoleAdmin, V1: "/api/v1/users/*", V2: "GET"}, - {Ptype: "p", V0: model.RoleAdmin, V1: "/api/v1/posts/*", V2: "*"}, - {Ptype: "p", V0: model.RoleAdmin, V1: "/api/v1/comments/*", V2: "*"}, - {Ptype: "p", V0: model.RoleAdmin, V1: "/api/v1/groups/*", V2: "*"}, + // 管理员 - 业务管理能力(不含角色/权限管理与日志导出) + {Ptype: "p", V0: model.RoleAdmin, V1: "admin/users", V2: "read"}, + {Ptype: "p", V0: model.RoleAdmin, V1: "admin/users/status", V2: "write"}, + {Ptype: "p", V0: model.RoleAdmin, V1: "admin/users/devices", V2: "read"}, + {Ptype: "p", V0: model.RoleAdmin, V1: "admin/posts", V2: "*"}, + {Ptype: "p", V0: model.RoleAdmin, V1: "admin/comments", V2: "*"}, + {Ptype: "p", V0: model.RoleAdmin, V1: "admin/groups", V2: "*"}, + {Ptype: "p", V0: model.RoleAdmin, V1: "admin/channels", V2: "*"}, + {Ptype: "p", V0: model.RoleAdmin, V1: "admin/dashboard", V2: "read"}, + {Ptype: "p", V0: model.RoleAdmin, V1: "admin/reports", V2: "*"}, + {Ptype: "p", V0: model.RoleAdmin, V1: "admin/verifications", V2: "*"}, + {Ptype: "p", V0: model.RoleAdmin, V1: "admin/profile_audits", V2: "*"}, + {Ptype: "p", V0: model.RoleAdmin, V1: "admin/materials", V2: "*"}, + {Ptype: "p", V0: model.RoleAdmin, V1: "admin/logs", V2: "read"}, - // 版主 - 拥有内容审核权限 - {Ptype: "p", V0: model.RoleModerator, V1: "/api/v1/posts/*", V2: "GET"}, - {Ptype: "p", V0: model.RoleModerator, V1: "/api/v1/posts/*", V2: "DELETE"}, - {Ptype: "p", V0: model.RoleModerator, V1: "/api/v1/comments/*", V2: "GET"}, - {Ptype: "p", V0: model.RoleModerator, V1: "/api/v1/comments/*", V2: "DELETE"}, - {Ptype: "p", V0: model.RoleModerator, V1: "/api/v1/groups/*", V2: "GET"}, + // 版主 - 内容审核 + {Ptype: "p", V0: model.RoleModerator, V1: "admin/posts", V2: "read"}, + {Ptype: "p", V0: model.RoleModerator, V1: "admin/posts", V2: "write"}, + {Ptype: "p", V0: model.RoleModerator, V1: "admin/comments", V2: "read"}, + {Ptype: "p", V0: model.RoleModerator, V1: "admin/comments", V2: "write"}, + {Ptype: "p", V0: model.RoleModerator, V1: "admin/reports", V2: "read"}, + {Ptype: "p", V0: model.RoleModerator, V1: "admin/reports", V2: "write"}, - // 普通用户 - 拥有基本权限 - {Ptype: "p", V0: model.RoleUser, V1: "/api/v1/posts", V2: "GET"}, - {Ptype: "p", V0: model.RoleUser, V1: "/api/v1/posts/*", V2: "GET"}, - {Ptype: "p", V0: model.RoleUser, V1: "/api/v1/posts", V2: "POST"}, - {Ptype: "p", V0: model.RoleUser, V1: "/api/v1/comments/*", V2: "GET"}, - {Ptype: "p", V0: model.RoleUser, V1: "/api/v1/comments", V2: "POST"}, - {Ptype: "p", V0: model.RoleUser, V1: "/api/v1/users/me", V2: "GET"}, - {Ptype: "p", V0: model.RoleUser, V1: "/api/v1/users/me", V2: "PUT"}, - {Ptype: "p", V0: model.RoleUser, V1: "/api/v1/groups", V2: "GET"}, - {Ptype: "p", V0: model.RoleUser, V1: "/api/v1/groups/*", V2: "GET"}, - - // 被封禁用户 - 无权限 + // 普通用户与被封禁用户:admin 路由外的资源由路由层中间件保障,Casbin 不维护。 } // 批量插入权限策略 diff --git a/internal/database/database_test.go b/internal/database/database_test.go index 39b34fd..a807ea3 100644 --- a/internal/database/database_test.go +++ b/internal/database/database_test.go @@ -109,10 +109,10 @@ func TestSeedPermissions(t *testing.T) { t.Fatal("expected seeded permissions, got 0") } - // 超级管理员 /* 通配 + // 超级管理员 admin/** 通配(globMatch 中 ** 跨 '/') var rule model.CasbinRule - if err := db.Where("ptype=? AND v0=? AND v1=?", "p", model.RoleSuperAdmin, "/*").First(&rule).Error; err != nil { - t.Errorf("expected superadmin wildcard permission: %v", err) + if err := db.Where("ptype=? AND v0=? AND v1=?", "p", model.RoleSuperAdmin, "admin/**").First(&rule).Error; err != nil { + t.Errorf("expected superadmin admin/** wildcard permission: %v", err) } } diff --git a/internal/dto/group_dto.go b/internal/dto/group_dto.go index eac82a0..61f408b 100644 --- a/internal/dto/group_dto.go +++ b/internal/dto/group_dto.go @@ -69,6 +69,7 @@ type GroupResponse struct { MaxMembers int `json:"max_members"` JoinType int `json:"join_type"` MuteAll bool `json:"mute_all"` + IsMember bool `json:"is_member"` CreatedAt string `json:"created_at"` } diff --git a/internal/errors/app_errors.go b/internal/errors/app_errors.go index 207241a..5b4d152 100644 --- a/internal/errors/app_errors.go +++ b/internal/errors/app_errors.go @@ -141,6 +141,13 @@ var ( ErrVerificationAlreadyHandled = &AppError{Code: "VERIFICATION_ALREADY_HANDLED", 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: "超级管理员已初始化,无法重复设置"} ErrInvalidSetupSecret = &AppError{Code: "INVALID_SETUP_SECRET", Message: "初始化密钥无效"} diff --git a/internal/handler/group_handler.go b/internal/handler/group_handler.go index 4969170..78e8701 100644 --- a/internal/handler/group_handler.go +++ b/internal/handler/group_handler.go @@ -838,6 +838,8 @@ func (h *GroupHandler) HandleCreateAnnouncement(c *gin.Context) { // HandleGetAnnouncements 获取群公告列表 // GET /api/v1/groups/:id/announcements +// +// 可见性策略:仅群成员可见。非成员返回 403 NOT_GROUP_MEMBER。 func (h *GroupHandler) HandleGetAnnouncements(c *gin.Context) { userID := parseUserID(c) if userID == "" { @@ -851,6 +853,12 @@ func (h *GroupHandler) HandleGetAnnouncements(c *gin.Context) { return } + // 成员校验:公告仅群成员可见。 + if !h.groupService.IsGroupMember(userID, groupID) { + response.Forbidden(c, "不是群成员") + return + } + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) @@ -1387,7 +1395,9 @@ func (h *GroupHandler) GetMembersByCursor(c *gin.Context) { } // 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) { userID := parseUserID(c) if userID == "" { @@ -1401,6 +1411,12 @@ func (h *GroupHandler) GetAnnouncementsByCursor(c *gin.Context) { return } + // 成员校验 + if !h.groupService.IsGroupMember(userID, groupID) { + response.Forbidden(c, "不是群成员") + return + } + // 解析游标分页请求 req := h.parseCursorRequest(c) @@ -1937,6 +1953,8 @@ func (h *GroupHandler) HandleRespondInvite(c *gin.Context) { // HandleGetGroupInfo 获取群信息 // GET /api/v1/groups/:id +// +// 可见性策略:成员返回完整详情;非成员仅返回公开字段(name/avatar/description/member_count)。 func (h *GroupHandler) HandleGetGroupInfo(c *gin.Context) { userID := parseUserID(c) if userID == "" { @@ -1967,11 +1985,26 @@ func (h *GroupHandler) HandleGetGroupInfo(c *gin.Context) { resp := dto.GroupToResponse(group) 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) } // HandleGetGroupMemberList 获取群成员列表 // GET /api/v1/groups/:id/members +// +// 可见性策略:仅群成员可见。非成员返回 403 NOT_GROUP_MEMBER。 func (h *GroupHandler) HandleGetGroupMemberList(c *gin.Context) { userID := parseUserID(c) if userID == "" { @@ -1988,8 +2021,13 @@ func (h *GroupHandler) HandleGetGroupMemberList(c *gin.Context) { page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) 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 errors.Is(err, service.ErrNotGroupMember) { + response.Forbidden(c, "不是群成员") + return + } if errors.Is(err, service.ErrGroupNotFound) { response.NotFound(c, "群组不存在") return diff --git a/internal/handler/message_handler.go b/internal/handler/message_handler.go index 7bd507a..e62b7fa 100644 --- a/internal/handler/message_handler.go +++ b/internal/handler/message_handler.go @@ -1108,10 +1108,10 @@ func (h *MessageHandler) GetMessagesByCursor(c *gin.Context) { // 解析游标分页参数 pageReq := parseCursorPageRequest(c) - // 调用服务层 - result, err := h.messageService.GetMessagesByCursor(c.Request.Context(), conversationID, pageReq) + // 调用服务层(内部会校验 currentUserID 是否是会话参与者,否则拒绝) + result, err := h.messageService.GetMessagesByCursor(c.Request.Context(), conversationID, userID, pageReq) if err != nil { - response.InternalServerError(c, "failed to get messages") + response.HandleError(c, err, "failed to get messages") return } diff --git a/internal/handler/user_handler.go b/internal/handler/user_handler.go index 6075b94..b5cb72b 100644 --- a/internal/handler/user_handler.go +++ b/internal/handler/user_handler.go @@ -6,12 +6,17 @@ import ( "crypto/sha256" "fmt" "strconv" + "time" "github.com/gin-gonic/gin" + "go.uber.org/zap" + "with_you/internal/cache" "with_you/internal/dto" "with_you/internal/middleware" "with_you/internal/model" + apperrors "with_you/internal/errors" + "with_you/internal/pkg/auth" "with_you/internal/pkg/response" "with_you/internal/service" ) @@ -37,6 +42,8 @@ type UserHandler struct { activityService service.UserActivityService profileAuditService service.UserProfileAuditService jwtService service.JWTService + sessionService service.SessionService + cache cache.Cache logService *service.LogService } @@ -62,6 +69,16 @@ func (h *UserHandler) SetActivityService(activityService service.UserActivitySer 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 func generateTokenID(token string) string { h := sha256.New() @@ -92,12 +109,16 @@ func (h *UserHandler) Register(c *gin.Context) { return } - // 生成Token - accessToken, _ := h.jwtService.GenerateAccessToken(user.ID, user.Username) - refreshToken, _ := h.jwtService.GenerateRefreshToken(user.ID, user.Username) - + // 签发带会话的令牌对:sid 写入 JWT,支持登出/封禁后撤销。 ip := c.ClientIP() 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 { @@ -161,12 +182,15 @@ func (h *UserHandler) Login(c *gin.Context) { middleware.ResetLoginFailures(c.ClientIP()) - // 生成Token - accessToken, _ := h.jwtService.GenerateAccessToken(user.ID, user.Username) - refreshToken, _ := h.jwtService.GenerateRefreshToken(user.ID, user.Username) - + // 签发带会话的令牌对。 ip := c.ClientIP() 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 { @@ -179,8 +203,6 @@ func (h *UserHandler) Login(c *gin.Context) { // 更新登录日志(补充IP和UserAgent) if h.logService != nil { - ip := c.ClientIP() - userAgent := c.GetHeader("User-Agent") go func() { h.logService.LoginLog.RecordLogin(&model.LoginLog{ UserID: user.ID, @@ -463,6 +485,11 @@ func (h *UserHandler) VerifyEmail(c *gin.Context) { } // RefreshToken 刷新Token +// +// 安全语义: +// - 仅接受 refresh token(jwt.ParseRefreshToken 严格校验 typ=refresh)。 +// - 通过 SessionService 校验会话未撤销未过期,并轮换会话(旧 refresh token 失效)。 +// - 不接受 access token,access token 不能在刷新端点换取新令牌。 func (h *UserHandler) RefreshToken(c *gin.Context) { type RefreshRequest struct { RefreshToken string `json:"refresh_token" binding:"required"` @@ -474,26 +501,34 @@ func (h *UserHandler) RefreshToken(c *gin.Context) { return } - // 解析 refresh token - claims, err := h.jwtService.ParseToken(req.RefreshToken) + // 获取用户信息用于填充新令牌的 username 声明。 + // 先解析 refresh 拿 userID,再查 DB;不直接信任 JWT 中的 username。 + claims, err := h.jwtService.ParseRefreshToken(req.RefreshToken) if err != nil { - response.Unauthorized(c, "invalid refresh token") + response.HandleError(c, apperrors.ErrInvalidToken, "invalid refresh token") return } - // 获取用户信息 user, err := h.userService.GetUserByID(c.Request.Context(), claims.UserID) if err != nil { - response.InternalServerError(c, "user not found") + response.HandleError(c, apperrors.ErrUserNotFound, "user not found") return } - // 生成新 token - accessToken, _ := h.jwtService.GenerateAccessToken(user.ID, user.Username) - refreshToken, _ := h.jwtService.GenerateRefreshToken(user.ID, user.Username) + if user.Status != model.UserStatusActive && user.Status != model.UserStatusPendingDeletion { + response.HandleError(c, apperrors.ErrUserBanned, "user banned") + return + } ip := c.ClientIP() 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 { @@ -801,9 +836,14 @@ func (h *UserHandler) SendChangePasswordCode(c *gin.Context) { } // Logout 用户登出 +// +// 安全语义:撤销当前会话,使 refresh token 立即失效。 +// access token 在剩余 TTL 内仍可用(无状态 JWT 的固有限制),但 refresh token 已撤销, +// 攻击者无法用 refresh token 续期,access token 也会在短 TTL 内自然过期。 +// 如需 access token 即时失效,可后续引入 jti 黑名单。 func (h *UserHandler) Logout(c *gin.Context) { - currentUserID := c.GetString("user_id") - if currentUserID == "" { + principal, ok := auth.GetPrincipal(c) + if !ok || principal == nil { response.Unauthorized(c, "not logged in") return } @@ -811,8 +851,23 @@ func (h *UserHandler) Logout(c *gin.Context) { ip := c.ClientIP() 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 { response.InternalServerError(c, "user not found") return diff --git a/internal/handler/ws_handler.go b/internal/handler/ws_handler.go index 840df5d..278414d 100644 --- a/internal/handler/ws_handler.go +++ b/internal/handler/ws_handler.go @@ -125,7 +125,8 @@ func (h *WSHandler) HandleWebSocket(c *gin.Context) { return } - claims, err := h.jwtService.ParseToken(token) + // 仅接受 access token,防止 refresh token 通过 WebSocket 入口绕过限制。 + claims, err := h.jwtService.ParseAccessToken(token) if err != nil { response.Unauthorized(c, "invalid token") return diff --git a/internal/middleware/active.go b/internal/middleware/active.go new file mode 100644 index 0000000..10ecb28 --- /dev/null +++ b/internal/middleware/active.go @@ -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() + } +} \ No newline at end of file diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index 5fe5e1e..09c3e8b 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -1,76 +1,6 @@ -package middleware - -import ( - "strings" - - "github.com/gin-gonic/gin" - - "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() - } -} +// Package middleware 提供路由层中间件。 +// +// 注意:旧的 Auth/OptionalAuth 函数已被 auth_pipeline.go 中的 +// RequireAuth / OptionalAuth 替代,新版本内置令牌类型校验、账户状态校验与会话校验。 +// 路由层应使用 RequireAuth(jwt, idp, cache) 与 OptionalAuth(jwt, idp, cache)。 +package middleware \ No newline at end of file diff --git a/internal/middleware/auth_pipeline.go b/internal/middleware/auth_pipeline.go new file mode 100644 index 0000000..64cdaf9 --- /dev/null +++ b/internal/middleware/auth_pipeline.go @@ -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. 校验 sessionID(sid 非空时):撤销/过期 → 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 不写入 context,c.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() +} \ No newline at end of file diff --git a/internal/middleware/auth_pipeline_test.go b/internal/middleware/auth_pipeline_test.go new file mode 100644 index 0000000..3df76d4 --- /dev/null +++ b/internal/middleware/auth_pipeline_test.go @@ -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 无 sid,GetSessionByID 不应被调用。 + } + 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 \ No newline at end of file diff --git a/internal/middleware/authorize.go b/internal/middleware/authorize.go new file mode 100644 index 0000000..acca77a --- /dev/null +++ b/internal/middleware/authorize.go @@ -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() + } +} \ No newline at end of file diff --git a/internal/model/session.go b/internal/model/session.go new file mode 100644 index 0000000..496f940 --- /dev/null +++ b/internal/model/session.go @@ -0,0 +1,55 @@ +package model + +import ( + "time" + + "gorm.io/gorm" +) + +// Session 用户登录会话 +// +// 用于支持令牌撤销: +// - access token 携带 SessionID(JWT 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) +} \ No newline at end of file diff --git a/internal/pkg/auth/cache.go b/internal/pkg/auth/cache.go new file mode 100644 index 0000000..3165aa7 --- /dev/null +++ b/internal/pkg/auth/cache.go @@ -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)) +} diff --git a/internal/pkg/auth/principal.go b/internal/pkg/auth/principal.go new file mode 100644 index 0000000..6093315 --- /dev/null +++ b/internal/pkg/auth/principal.go @@ -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:" +) \ No newline at end of file diff --git a/internal/pkg/jwt/jwt.go b/internal/pkg/jwt/jwt.go index e433ba2..47fe86b 100644 --- a/internal/pkg/jwt/jwt.go +++ b/internal/pkg/jwt/jwt.go @@ -2,20 +2,38 @@ package jwt import ( "errors" + "fmt" "time" "github.com/golang-jwt/jwt/v5" ) +// 令牌类型常量。引入显式 typ 字段,避免 access/refresh 仅靠 RegisteredClaims.ID 隐式区分。 +const ( + TokenTypeAccess = "access" + TokenTypeRefresh = "refresh" +) + var ( - ErrInvalidToken = errors.New("invalid token") - ErrExpiredToken = errors.New("token has expired") + ErrInvalidToken = errors.New("invalid token") + ErrExpiredToken = errors.New("token has expired") + ErrWrongTokenType = errors.New("wrong token type") + ErrLegacyToken = errors.New("legacy token without type") ) // Claims JWT 声明 +// +// 引入 TokenType 与 SessionID 字段,使令牌用途与会话在编译期显式可见: +// - TokenType: "access" 或 "refresh",由 ParseAccessToken/ParseRefreshToken 严格校验。 +// - SessionID: 关联 sessions 表,支持登出/封禁后的令牌撤销。 +// +// 旧客户端签发的 token 不含 typ/sid,ParseAccessToken 在过渡期宽容通过; +// ParseRefreshToken 始终严格要求 typ=refresh,强制客户端重新登录获取新 refresh token。 type Claims struct { - UserID string `json:"user_id"` - Username string `json:"username"` + UserID string `json:"sub"` + Username string `json:"username"` + TokenType string `json:"typ,omitempty"` + SessionID string `json:"sid,omitempty"` jwt.RegisteredClaims } @@ -36,11 +54,15 @@ func New(secret string, accessExpire, refreshExpire time.Duration) *JWT { } // 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() claims := Claims{ - UserID: userID, - Username: username, + UserID: userID, + Username: username, + TokenType: TokenTypeAccess, + SessionID: sessionID, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(now.Add(j.accessTokenExpire)), IssuedAt: jwt.NewNumericDate(now), @@ -54,17 +76,20 @@ func (j *JWT) GenerateAccessToken(userID, username string) (string, error) { } // 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() claims := Claims{ - UserID: userID, - Username: username, + UserID: userID, + Username: username, + TokenType: TokenTypeRefresh, + SessionID: sessionID, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(now.Add(j.refreshTokenExpire)), IssuedAt: jwt.NewNumericDate(now), NotBefore: jwt.NewNumericDate(now), Issuer: "carrot_bbs", - ID: "refresh", }, } @@ -72,8 +97,8 @@ func (j *JWT) GenerateRefreshToken(userID, username string) (string, error) { return token.SignedString([]byte(j.secretKey)) } -// ParseToken 解析令牌 -func (j *JWT) ParseToken(tokenString string) (*Claims, error) { +// parseWithKey 解析 token 字符串为 Claims,仅做签名与过期校验。 +func (j *JWT) parseWithKey(tokenString string) (*Claims, error) { token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { return []byte(j.secretKey), nil }) @@ -89,17 +114,75 @@ func (j *JWT) ParseToken(tokenString string) (*Claims, error) { return nil, ErrInvalidToken } -// ValidateToken 验证令牌 -func (j *JWT) ValidateToken(tokenString string) error { - claims, err := j.ParseToken(tokenString) +// ParseAccessToken 仅接受 typ=access 的令牌。 +// +// 过渡兼容:若 typ 为空(旧客户端签发),宽容通过并返回 ErrLegacyToken 包装的错误, +// 调用方可据此打告警日志;旧 token 在一个 access TTL 周期内自然过期后即不再被宽容。 +// 不接受 refresh token,明确返回 ErrWrongTokenType。 +func (j *JWT) ParseAccessToken(tokenString string) (*Claims, error) { + claims, err := j.parseWithKey(tokenString) if err != nil { - return err + return nil, err } - // 检查是否是刷新令牌 - if claims.ID == "refresh" { - return errors.New("cannot use refresh token as access token") + switch claims.TokenType { + case TokenTypeAccess: + return claims, nil + case "": + // 旧客户端兼容窗口:旧 token 不含 typ,仅在剩余 TTL 内通过。 + return claims, fmt.Errorf("%w: sub=%s", ErrLegacyToken, claims.UserID) + default: + return nil, ErrWrongTokenType } - - return nil +} + +// 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 + } + if errors.Is(err, ErrLegacyToken) { + // 旧 token 在兼容窗口内视为有效。 + return nil + } + return err } diff --git a/internal/pkg/jwt/jwt_test.go b/internal/pkg/jwt/jwt_test.go new file mode 100644 index 0000000..886303d --- /dev/null +++ b/internal/pkg/jwt/jwt_test.go @@ -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 旧 token(typ=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) + } +} \ No newline at end of file diff --git a/internal/pkg/response/response.go b/internal/pkg/response/response.go index 9a9ad62..d6bea10 100644 --- a/internal/pkg/response/response.go +++ b/internal/pkg/response/response.go @@ -131,15 +131,17 @@ func HandleError(c *gin.Context, err error, defaultMessage string) { func statusCodeFromAppErrorCode(code string) int { switch code { 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 - case "UNAUTHORIZED": + case "UNAUTHORIZED", "INVALID_TOKEN", "TOKEN_EXPIRED", "SESSION_REVOKED": return http.StatusUnauthorized case "INVALID_CREDENTIALS": return http.StatusBadRequest case "FORBIDDEN", "NOT_GROUP_MEMBER", "NOT_GROUP_ADMIN", "NOT_GROUP_OWNER", "USER_BANNED", "USER_BLOCKED", "MUTED", "MUTE_ALL_ENABLED", "SCHEDULE_FORBIDDEN", "UNAUTHORIZED_NOTIFICATION", + "ACCOUNT_INACTIVE", "WRONG_TOKEN_TYPE", "VERIFICATION_REQUIRED", "SETUP_ALREADY_COMPLETED": return http.StatusForbidden case "BAD_REQUEST", "INVALID_USERNAME", "INVALID_EMAIL", "INVALID_PHONE", diff --git a/internal/repository/message_repo.go b/internal/repository/message_repo.go index 72c8384..933a737 100644 --- a/internal/repository/message_repo.go +++ b/internal/repository/message_repo.go @@ -26,6 +26,10 @@ type MessageRepository interface { GetMessagesBeforeSeq(conversationID string, beforeSeq int64, limit int) ([]*model.Message, error) GetConversationParticipants(conversationID 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 UpdatePinned(conversationID string, userID string, isPinned bool) error UpdateNotificationMuted(conversationID string, userID string, notificationMuted bool) error @@ -253,6 +257,10 @@ func (r *messageRepository) GetConversationParticipants(conversationID string) ( // GetParticipant 获取用户在会话中的参与者信息 // userID 参数为 string 类型(UUID格式),与JWT中user_id保持一致 +// +// 注意:本方法在参与者记录不存在但会话存在时会**自动创建**参与者记录。 +// 该副作用适用于"确保参与者存在"的写路径;鉴权/读取路径应改用 GetParticipantStrict, +// 否则非参与者的查询会被静默升级为成员(鉴权绕过)。 func (r *messageRepository) GetParticipant(conversationID string, userID string) (*model.ConversationParticipant, error) { var participant model.ConversationParticipant 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 } +// 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 更新已读位置(防回退:仅当新值大于当前值时才更新) // userID 参数为 string 类型(UUID格式),与JWT中user_id保持一致 func (r *messageRepository) UpdateLastReadSeq(conversationID string, userID string, lastReadSeq int64) error { diff --git a/internal/repository/session_repo.go b/internal/repository/session_repo.go new file mode 100644 index 0000000..3a54d0a --- /dev/null +++ b/internal/repository/session_repo.go @@ -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 +} \ No newline at end of file diff --git a/internal/router/router.go b/internal/router/router.go index 9a70d7f..42401e7 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -3,9 +3,9 @@ package router import ( "github.com/gin-gonic/gin" + "with_you/internal/cache" "with_you/internal/handler" "with_you/internal/middleware" - "with_you/internal/model" "with_you/internal/repository" "with_you/internal/service" ) @@ -49,6 +49,9 @@ type RouterDeps struct { TradeHandler *handler.TradeHandler WSHandler *handler.WSHandler JWTService service.JWTService + SessionService service.SessionService + IdentityProvider middleware.IdentityProvider + Cache cache.Cache LogService *service.LogService ActivityService service.UserActivityService CasbinService service.CasbinService @@ -67,6 +70,8 @@ type Router struct { func New(deps RouterDeps) *Router { deps.UserHandler.SetJWTService(deps.JWTService) deps.UserHandler.SetActivityService(deps.ActivityService) + deps.UserHandler.SetSessionService(deps.SessionService) + deps.UserHandler.SetCache(deps.Cache) r := &Router{ RouterDeps: &deps, @@ -92,17 +97,17 @@ func (r *Router) setupRoutes() { v1 := r.engine.Group("/api/v1") { // 认证路由(公开,按接口类型设置不同速率限制) - auth := v1.Group("/auth") - auth.Use(middleware.GlobalAuthRateLimit()) - auth.Use(middleware.IPBanMiddleware()) + // 注意:登出端点在下面 authMiddleware 定义后单独挂载,避免前向引用。 + authPublic := v1.Group("/auth") + authPublic.Use(middleware.GlobalAuthRateLimit()) + authPublic.Use(middleware.IPBanMiddleware()) { - auth.POST("/register", middleware.RegisterRateLimit(), r.UserHandler.Register) - auth.POST("/register/send-code", middleware.SendCodeRateLimit(), r.UserHandler.SendRegisterCode) - auth.POST("/login", middleware.LoginRateLimit(), r.UserHandler.Login) - auth.POST("/password/send-code", middleware.PasswordResetRateLimit(), r.UserHandler.SendPasswordResetCode) - auth.POST("/password/reset", middleware.PasswordResetRateLimit(), r.UserHandler.ResetPassword) - auth.POST("/logout", r.UserHandler.Logout) - auth.POST("/refresh", r.UserHandler.RefreshToken) + authPublic.POST("/register", middleware.RegisterRateLimit(), r.UserHandler.Register) + authPublic.POST("/register/send-code", middleware.SendCodeRateLimit(), r.UserHandler.SendRegisterCode) + authPublic.POST("/login", middleware.LoginRateLimit(), r.UserHandler.Login) + authPublic.POST("/password/send-code", middleware.PasswordResetRateLimit(), r.UserHandler.SendPasswordResetCode) + authPublic.POST("/password/reset", middleware.PasswordResetRateLimit(), r.UserHandler.ResetPassword) + authPublic.POST("/refresh", r.UserHandler.RefreshToken) } // 初始化超级管理员(公开接口,只能使用一次) @@ -111,14 +116,21 @@ 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 { accessLogConfig := &middleware.AccessLogConfig{ - Enable: true, - SkipPaths: []string{"/health", "/api/v1/health"}, - ServerIP: "0.0.0.0", + Enable: true, + SkipPaths: []string{"/health", "/api/v1/health"}, + ServerIP: "0.0.0.0", ServerPort: 8080, } r.engine.Use(middleware.AccessLogMiddleware( @@ -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.DELETE("/:id/follow", authMiddleware, r.UserHandler.UnfollowUser) users.POST("/:id/block", authMiddleware, r.UserHandler.BlockUser) @@ -169,8 +181,8 @@ func (r *Router) setupRoutes() { users.GET("/:id/followers", authMiddleware, r.UserHandler.GetFollowersList) // 用户帖子 - 使用 OptionalAuth 获取当前用户点赞/收藏状态 - users.GET("/:id/posts", middleware.OptionalAuth(r.JWTService), r.PostHandler.GetUserPosts) - users.GET("/:id/favorites", middleware.OptionalAuth(r.JWTService), r.PostHandler.GetFavorites) + users.GET("/:id/posts", optionalAuth, r.PostHandler.GetUserPosts) + 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") { // 使用 OptionalAuth 中间件来获取用户登录状态 - posts.GET("", middleware.OptionalAuth(r.JWTService), r.PostHandler.List) - posts.GET("/search", middleware.OptionalAuth(r.JWTService), r.PostHandler.Search) - posts.GET("/suggest", middleware.OptionalAuth(r.JWTService), r.PostHandler.Suggest) - posts.GET("/:id", middleware.OptionalAuth(r.JWTService), r.PostHandler.GetByID) + posts.GET("", optionalAuth, r.PostHandler.List) + posts.GET("/search", optionalAuth, r.PostHandler.Search) + posts.GET("/suggest", optionalAuth, r.PostHandler.Suggest) + posts.GET("/:id", optionalAuth, r.PostHandler.GetByID) posts.POST("", authMiddleware, middleware.RequireVerified(r.UserService), r.PostHandler.Create) posts.PUT("/:id", authMiddleware, middleware.RequireVerified(r.UserService), r.PostHandler.Update) 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/refs", middleware.OptionalAuth(r.JWTService), r.PostHandler.GetReferencedPosts) - posts.POST("/:id/ref-click", middleware.OptionalAuth(r.JWTService), r.PostHandler.RecordRefClick) + posts.GET("/:id/related", optionalAuth, r.PostHandler.GetRelatedPosts) + posts.GET("/:id/refs", optionalAuth, r.PostHandler.GetReferencedPosts) + posts.POST("/:id/ref-click", optionalAuth, r.PostHandler.RecordRefClick) // 点赞 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.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.DELETE("/:id/vote", authMiddleware, middleware.RequireVerified(r.UserService), r.VoteHandler.Unvote) // 取消投票 } @@ -257,13 +269,13 @@ func (r *Router) setupRoutes() { if r.TradeHandler != nil { trade := v1.Group("/trade") { - trade.GET("", middleware.OptionalAuth(r.JWTService), r.TradeHandler.List) - trade.GET("/:id", middleware.OptionalAuth(r.JWTService), r.TradeHandler.GetByID) + trade.GET("", optionalAuth, r.TradeHandler.List) + trade.GET("/:id", optionalAuth, r.TradeHandler.GetByID) trade.POST("", authMiddleware, middleware.RequireVerified(r.UserService), r.TradeHandler.Create) trade.PUT("/:id", authMiddleware, middleware.RequireVerified(r.UserService), r.TradeHandler.Update) trade.DELETE("/:id", authMiddleware, middleware.RequireVerified(r.UserService), r.TradeHandler.Delete) 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.DELETE("/:id/favorite", authMiddleware, middleware.RequireVerified(r.UserService), r.TradeHandler.Unfavorite) } @@ -337,15 +349,15 @@ func (r *Router) setupRoutes() { // 评论路由 comments := v1.Group("/comments") { - comments.GET("/post/:id", middleware.OptionalAuth(r.JWTService), r.CommentHandler.GetByPostID) - comments.GET("/post/:id/cursor", middleware.OptionalAuth(r.JWTService), r.CommentHandler.GetByPostIDByCursor) // 帖子评论游标分页 - comments.GET("/:id", middleware.OptionalAuth(r.JWTService), r.CommentHandler.GetByID) + comments.GET("/post/:id", optionalAuth, r.CommentHandler.GetByPostID) + comments.GET("/post/:id/cursor", optionalAuth, r.CommentHandler.GetByPostIDByCursor) // 帖子评论游标分页 + comments.GET("/:id", optionalAuth, r.CommentHandler.GetByID) comments.POST("", authMiddleware, middleware.RequireVerified(r.UserService), r.CommentHandler.Create) comments.PUT("/:id", authMiddleware, middleware.RequireVerified(r.UserService), r.CommentHandler.Update) 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/flat", middleware.OptionalAuth(r.JWTService), r.CommentHandler.GetRepliesByRootID) // 扁平化分页获取回复 - comments.GET("/:id/replies/cursor", middleware.OptionalAuth(r.JWTService), r.CommentHandler.GetRepliesByRootIDByCursor) // 回复游标分页 + comments.GET("/:id/replies", optionalAuth, r.CommentHandler.GetReplies) + comments.GET("/:id/replies/flat", optionalAuth, r.CommentHandler.GetRepliesByRootID) // 扁平化分页获取回复 + comments.GET("/:id/replies/cursor", optionalAuth, r.CommentHandler.GetRepliesByRootIDByCursor) // 回复游标分页 // 评论点赞 comments.POST("/:id/like", authMiddleware, middleware.RequireVerified(r.UserService), r.CommentHandler.Like) 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 { admin := v1.Group("/admin") admin.Use(authMiddleware) - admin.Use(middleware.RequireRole(r.CasbinService, model.RoleAdmin, model.RoleSuperAdmin)) - { - // 角色管理 - admin.GET("/roles", r.RoleHandler.GetAllRoles) - admin.GET("/roles/:name", r.RoleHandler.GetRoleDetail) - admin.GET("/roles/:name/permissions", r.RoleHandler.GetRolePermissions) - admin.PUT("/roles/:name/permissions", r.RoleHandler.UpdateRolePermissions) - admin.GET("/permissions", r.RoleHandler.GetAllPermissions) - admin.GET("/users/:id/roles", r.RoleHandler.GetUserRoles) - admin.POST("/users/:id/roles", r.RoleHandler.AssignRole) - admin.DELETE("/users/:id/roles/:role", r.RoleHandler.RemoveRole) + admin.Use(middleware.RequireActive()) - // 用户管理 - if r.AdminUserHandler != nil { - admin.GET("/users", r.AdminUserHandler.GetUserList) - admin.GET("/users/:id", r.AdminUserHandler.GetUserDetail) - admin.PUT("/users/:id/status", r.AdminUserHandler.UpdateUserStatus) - admin.GET("/users/:id/devices", r.AdminUserHandler.GetUserDevices) - } + // 角色与权限管理(仅 super_admin) + admin.GET("/roles", middleware.Authorize(r.CasbinService, "admin/roles", "read"), r.RoleHandler.GetAllRoles) + admin.GET("/roles/:name", middleware.Authorize(r.CasbinService, "admin/roles", "read"), r.RoleHandler.GetRoleDetail) + admin.GET("/roles/:name/permissions", middleware.Authorize(r.CasbinService, "admin/roles/permissions", "read"), r.RoleHandler.GetRolePermissions) + admin.PUT("/roles/:name/permissions", middleware.Authorize(r.CasbinService, "admin/roles/permissions", "write"), r.RoleHandler.UpdateRolePermissions) + admin.GET("/permissions", middleware.Authorize(r.CasbinService, "admin/roles", "read"), r.RoleHandler.GetAllPermissions) + admin.GET("/users/:id/roles", middleware.Authorize(r.CasbinService, "admin/users/roles", "read"), r.RoleHandler.GetUserRoles) + admin.POST("/users/:id/roles", middleware.Authorize(r.CasbinService, "admin/users/roles", "write"), r.RoleHandler.AssignRole) + admin.DELETE("/users/:id/roles/:role", middleware.Authorize(r.CasbinService, "admin/users/roles", "write"), r.RoleHandler.RemoveRole) - // 帖子管理 - if r.AdminPostHandler != nil { - admin.GET("/posts", r.AdminPostHandler.GetPostList) - admin.GET("/posts/:id", r.AdminPostHandler.GetPostDetail) - admin.DELETE("/posts/:id", r.AdminPostHandler.DeletePost) - admin.PUT("/posts/:id/moderate", r.AdminPostHandler.ModeratePost) - admin.POST("/posts/batch-delete", r.AdminPostHandler.BatchDeletePosts) - admin.POST("/posts/batch-moderate", r.AdminPostHandler.BatchModeratePosts) - admin.PUT("/posts/:id/pin", r.AdminPostHandler.SetPostPin) - admin.PUT("/posts/:id/feature", r.AdminPostHandler.SetPostFeature) - } + // 用户管理 + if r.AdminUserHandler != nil { + admin.GET("/users", middleware.Authorize(r.CasbinService, "admin/users", "read"), r.AdminUserHandler.GetUserList) + admin.GET("/users/:id", middleware.Authorize(r.CasbinService, "admin/users", "read"), r.AdminUserHandler.GetUserDetail) + admin.PUT("/users/:id/status", middleware.Authorize(r.CasbinService, "admin/users/status", "write"), r.AdminUserHandler.UpdateUserStatus) + admin.GET("/users/:id/devices", middleware.Authorize(r.CasbinService, "admin/users/devices", "read"), r.AdminUserHandler.GetUserDevices) + } - // 频道管理 - if r.ChannelHandler != nil { - admin.GET("/channels", r.ChannelHandler.AdminList) - admin.POST("/channels", r.ChannelHandler.AdminCreate) - admin.PUT("/channels/:id", r.ChannelHandler.AdminUpdate) - admin.DELETE("/channels/:id", r.ChannelHandler.AdminDelete) - } + // 帖子管理 + if r.AdminPostHandler != nil { + admin.GET("/posts", middleware.Authorize(r.CasbinService, "admin/posts", "read"), r.AdminPostHandler.GetPostList) + admin.GET("/posts/:id", middleware.Authorize(r.CasbinService, "admin/posts", "read"), r.AdminPostHandler.GetPostDetail) + admin.DELETE("/posts/:id", middleware.Authorize(r.CasbinService, "admin/posts", "write"), r.AdminPostHandler.DeletePost) + admin.PUT("/posts/:id/moderate", middleware.Authorize(r.CasbinService, "admin/posts", "write"), r.AdminPostHandler.ModeratePost) + admin.POST("/posts/batch-delete", middleware.Authorize(r.CasbinService, "admin/posts", "write"), r.AdminPostHandler.BatchDeletePosts) + admin.POST("/posts/batch-moderate", middleware.Authorize(r.CasbinService, "admin/posts", "write"), r.AdminPostHandler.BatchModeratePosts) + admin.PUT("/posts/:id/pin", middleware.Authorize(r.CasbinService, "admin/posts", "write"), r.AdminPostHandler.SetPostPin) + admin.PUT("/posts/:id/feature", middleware.Authorize(r.CasbinService, "admin/posts", "write"), r.AdminPostHandler.SetPostFeature) + } - // 评论管理 - if r.AdminCommentHandler != nil { - admin.GET("/comments", r.AdminCommentHandler.GetCommentList) - admin.GET("/comments/:id", r.AdminCommentHandler.GetCommentDetail) - admin.DELETE("/comments/:id", r.AdminCommentHandler.DeleteComment) - admin.PUT("/comments/:id/moderate", r.AdminCommentHandler.ModerateComment) - admin.POST("/comments/batch-delete", r.AdminCommentHandler.BatchDeleteComments) - admin.POST("/comments/batch-moderate", r.AdminCommentHandler.BatchModerateComments) - admin.GET("/comments/post/:postId", r.AdminCommentHandler.GetPostComments) - } + // 频道管理 + if r.ChannelHandler != nil { + admin.GET("/channels", middleware.Authorize(r.CasbinService, "admin/channels", "read"), r.ChannelHandler.AdminList) + admin.POST("/channels", middleware.Authorize(r.CasbinService, "admin/channels", "write"), r.ChannelHandler.AdminCreate) + admin.PUT("/channels/:id", middleware.Authorize(r.CasbinService, "admin/channels", "write"), r.ChannelHandler.AdminUpdate) + admin.DELETE("/channels/:id", middleware.Authorize(r.CasbinService, "admin/channels", "write"), r.ChannelHandler.AdminDelete) + } - // 群组管理 - if r.AdminGroupHandler != nil { - admin.GET("/groups", r.AdminGroupHandler.GetGroupList) - admin.GET("/groups/:id", r.AdminGroupHandler.GetGroupDetail) - admin.GET("/groups/:id/members", r.AdminGroupHandler.GetGroupMembers) - admin.DELETE("/groups/:id", r.AdminGroupHandler.DeleteGroup) - admin.PUT("/groups/:id/status", r.AdminGroupHandler.UpdateGroupStatus) - admin.DELETE("/groups/:id/members/:userId", r.AdminGroupHandler.RemoveMember) - admin.PUT("/groups/:id/transfer", r.AdminGroupHandler.TransferOwner) - } + // 评论管理 + if r.AdminCommentHandler != nil { + admin.GET("/comments", middleware.Authorize(r.CasbinService, "admin/comments", "read"), r.AdminCommentHandler.GetCommentList) + admin.GET("/comments/:id", middleware.Authorize(r.CasbinService, "admin/comments", "read"), r.AdminCommentHandler.GetCommentDetail) + admin.DELETE("/comments/:id", middleware.Authorize(r.CasbinService, "admin/comments", "write"), r.AdminCommentHandler.DeleteComment) + admin.PUT("/comments/:id/moderate", middleware.Authorize(r.CasbinService, "admin/comments", "write"), r.AdminCommentHandler.ModerateComment) + admin.POST("/comments/batch-delete", middleware.Authorize(r.CasbinService, "admin/comments", "write"), r.AdminCommentHandler.BatchDeleteComments) + admin.POST("/comments/batch-moderate", middleware.Authorize(r.CasbinService, "admin/comments", "write"), r.AdminCommentHandler.BatchModerateComments) + admin.GET("/comments/post/:postId", middleware.Authorize(r.CasbinService, "admin/comments", "read"), r.AdminCommentHandler.GetPostComments) + } - // 仪表盘 - if r.AdminDashboardHandler != nil { - admin.GET("/dashboard/stats", r.AdminDashboardHandler.GetStats) - admin.GET("/dashboard/user-activity", r.AdminDashboardHandler.GetUserActivityTrend) - admin.GET("/dashboard/content-stats", r.AdminDashboardHandler.GetContentStats) - admin.GET("/dashboard/pending-content", r.AdminDashboardHandler.GetPendingContent) - admin.GET("/dashboard/online-users", r.AdminDashboardHandler.GetOnlineUsers) - } + // 群组管理 + if r.AdminGroupHandler != nil { + admin.GET("/groups", middleware.Authorize(r.CasbinService, "admin/groups", "read"), r.AdminGroupHandler.GetGroupList) + admin.GET("/groups/:id", middleware.Authorize(r.CasbinService, "admin/groups", "read"), r.AdminGroupHandler.GetGroupDetail) + admin.GET("/groups/:id/members", middleware.Authorize(r.CasbinService, "admin/groups", "read"), r.AdminGroupHandler.GetGroupMembers) + admin.DELETE("/groups/:id", middleware.Authorize(r.CasbinService, "admin/groups", "write"), r.AdminGroupHandler.DeleteGroup) + admin.PUT("/groups/:id/status", middleware.Authorize(r.CasbinService, "admin/groups", "write"), r.AdminGroupHandler.UpdateGroupStatus) + admin.DELETE("/groups/:id/members/:userId", middleware.Authorize(r.CasbinService, "admin/groups", "write"), r.AdminGroupHandler.RemoveMember) + admin.PUT("/groups/:id/transfer", middleware.Authorize(r.CasbinService, "admin/groups", "write"), r.AdminGroupHandler.TransferOwner) + } - // 日志管理 - if r.AdminLogHandler != nil { - logs := admin.Group("/logs") - { - logs.GET("/operations", r.AdminLogHandler.GetOperationLogs) - logs.GET("/logins", r.AdminLogHandler.GetLoginLogs) - logs.GET("/data-changes", r.AdminLogHandler.GetDataChangeLogs) - logs.GET("/statistics", r.AdminLogHandler.GetLogStatistics) - logs.GET("/export", r.AdminLogHandler.ExportLogs) - } - } + // 仪表盘 + if r.AdminDashboardHandler != nil { + admin.GET("/dashboard/stats", middleware.Authorize(r.CasbinService, "admin/dashboard", "read"), r.AdminDashboardHandler.GetStats) + admin.GET("/dashboard/user-activity", middleware.Authorize(r.CasbinService, "admin/dashboard", "read"), r.AdminDashboardHandler.GetUserActivityTrend) + admin.GET("/dashboard/content-stats", middleware.Authorize(r.CasbinService, "admin/dashboard", "read"), r.AdminDashboardHandler.GetContentStats) + admin.GET("/dashboard/pending-content", middleware.Authorize(r.CasbinService, "admin/dashboard", "read"), r.AdminDashboardHandler.GetPendingContent) + admin.GET("/dashboard/online-users", middleware.Authorize(r.CasbinService, "admin/dashboard", "read"), r.AdminDashboardHandler.GetOnlineUsers) + } - // 学习资料管理 - if r.MaterialHandler != nil { - // 学科管理 - admin.GET("/materials/subjects", r.MaterialHandler.AdminListSubjects) - admin.POST("/materials/subjects", r.MaterialHandler.AdminCreateSubject) - admin.PUT("/materials/subjects/:id", r.MaterialHandler.AdminUpdateSubject) - admin.DELETE("/materials/subjects/:id", r.MaterialHandler.AdminDeleteSubject) - // 资料管理 - admin.GET("/materials", r.MaterialHandler.AdminListMaterials) - admin.GET("/materials/:id", r.MaterialHandler.AdminGetMaterial) - admin.POST("/materials", r.MaterialHandler.AdminCreateMaterial) - admin.PUT("/materials/:id", r.MaterialHandler.AdminUpdateMaterial) - admin.DELETE("/materials/:id", r.MaterialHandler.AdminDeleteMaterial) - } + // 日志管理(导出仅 super_admin) + if r.AdminLogHandler != nil { + 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) + admin.GET("/logs/data-changes", middleware.Authorize(r.CasbinService, "admin/logs", "read"), r.AdminLogHandler.GetDataChangeLogs) + admin.GET("/logs/statistics", middleware.Authorize(r.CasbinService, "admin/logs", "read"), r.AdminLogHandler.GetLogStatistics) + admin.GET("/logs/export", middleware.Authorize(r.CasbinService, "admin/logs/export", "read"), r.AdminLogHandler.ExportLogs) + } - // 举报管理 - if r.AdminReportHandler != nil { - admin.GET("/reports", r.AdminReportHandler.List) - admin.GET("/reports/:id", r.AdminReportHandler.Detail) - admin.PUT("/reports/:id/handle", r.AdminReportHandler.Handle) - admin.POST("/reports/batch-handle", r.AdminReportHandler.BatchHandle) - } + // 学习资料管理 + if r.MaterialHandler != nil { + // 学科管理 + admin.GET("/materials/subjects", middleware.Authorize(r.CasbinService, "admin/materials", "read"), r.MaterialHandler.AdminListSubjects) + admin.POST("/materials/subjects", middleware.Authorize(r.CasbinService, "admin/materials", "write"), r.MaterialHandler.AdminCreateSubject) + admin.PUT("/materials/subjects/:id", middleware.Authorize(r.CasbinService, "admin/materials", "write"), r.MaterialHandler.AdminUpdateSubject) + admin.DELETE("/materials/subjects/:id", middleware.Authorize(r.CasbinService, "admin/materials", "write"), r.MaterialHandler.AdminDeleteSubject) + // 资料管理 + admin.GET("/materials", middleware.Authorize(r.CasbinService, "admin/materials", "read"), r.MaterialHandler.AdminListMaterials) + admin.GET("/materials/:id", middleware.Authorize(r.CasbinService, "admin/materials", "read"), r.MaterialHandler.AdminGetMaterial) + admin.POST("/materials", middleware.Authorize(r.CasbinService, "admin/materials", "write"), r.MaterialHandler.AdminCreateMaterial) + admin.PUT("/materials/:id", middleware.Authorize(r.CasbinService, "admin/materials", "write"), r.MaterialHandler.AdminUpdateMaterial) + admin.DELETE("/materials/:id", middleware.Authorize(r.CasbinService, "admin/materials", "write"), r.MaterialHandler.AdminDeleteMaterial) + } - // 身份认证管理 - if r.AdminVerificationHandler != nil { - admin.GET("/verifications", r.AdminVerificationHandler.ListVerifications) - admin.GET("/verifications/:id", r.AdminVerificationHandler.GetVerificationDetail) - admin.PUT("/verifications/:id/review", r.AdminVerificationHandler.ReviewVerification) - } + // 举报管理 + if r.AdminReportHandler != nil { + admin.GET("/reports", middleware.Authorize(r.CasbinService, "admin/reports", "read"), r.AdminReportHandler.List) + admin.GET("/reports/:id", middleware.Authorize(r.CasbinService, "admin/reports", "read"), r.AdminReportHandler.Detail) + admin.PUT("/reports/:id/handle", middleware.Authorize(r.CasbinService, "admin/reports", "write"), r.AdminReportHandler.Handle) + admin.POST("/reports/batch-handle", middleware.Authorize(r.CasbinService, "admin/reports", "write"), r.AdminReportHandler.BatchHandle) + } - // 用户资料审核管理 - if r.AdminProfileAuditHandler != nil { - admin.GET("/profile-audits", r.AdminProfileAuditHandler.GetPendingList) - admin.PUT("/profile-audits/:id/moderate", r.AdminProfileAuditHandler.ModerateAudit) - admin.POST("/profile-audits/batch-moderate", r.AdminProfileAuditHandler.BatchModerate) - } + // 身份认证管理 + if r.AdminVerificationHandler != nil { + admin.GET("/verifications", middleware.Authorize(r.CasbinService, "admin/verifications", "read"), r.AdminVerificationHandler.ListVerifications) + admin.GET("/verifications/:id", middleware.Authorize(r.CasbinService, "admin/verifications", "read"), r.AdminVerificationHandler.GetVerificationDetail) + admin.PUT("/verifications/:id/review", middleware.Authorize(r.CasbinService, "admin/verifications", "write"), r.AdminVerificationHandler.ReviewVerification) + } + + // 用户资料审核管理 + if r.AdminProfileAuditHandler != nil { + admin.GET("/profile-audits", middleware.Authorize(r.CasbinService, "admin/profile_audits", "read"), r.AdminProfileAuditHandler.GetPendingList) + admin.PUT("/profile-audits/:id/moderate", middleware.Authorize(r.CasbinService, "admin/profile_audits", "write"), r.AdminProfileAuditHandler.ModerateAudit) + admin.POST("/profile-audits/batch-moderate", middleware.Authorize(r.CasbinService, "admin/profile_audits", "write"), r.AdminProfileAuditHandler.BatchModerate) } } } diff --git a/internal/service/admin_user_service.go b/internal/service/admin_user_service.go index 8f0855f..8e40dc6 100644 --- a/internal/service/admin_user_service.go +++ b/internal/service/admin_user_service.go @@ -3,8 +3,12 @@ package service import ( "context" + "go.uber.org/zap" + + "with_you/internal/cache" "with_you/internal/dto" "with_you/internal/model" + "with_you/internal/pkg/auth" "with_you/internal/repository" ) @@ -19,14 +23,21 @@ type AdminUserService interface { } // adminUserServiceImpl 管理端用户服务实现 +// +// sessionSvc 与 cache 用于在封禁/停用用户时撤销其全部登录会话并失效 Principal 缓存, +// 让 RequireAuth 在下一次请求时重新加载用户状态(避免 30s 缓存窗口内仍可访问)。 type adminUserServiceImpl struct { - userRepo repository.UserRepository + userRepo repository.UserRepository + sessionSvc SessionService + cache cache.Cache } // NewAdminUserService 创建管理端用户服务 -func NewAdminUserService(userRepo repository.UserRepository) AdminUserService { +func NewAdminUserService(userRepo repository.UserRepository, sessionSvc SessionService, cache cache.Cache) AdminUserService { 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 } + // 状态变更后的会话/缓存联动: + // - 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) if err != nil { diff --git a/internal/service/casbin_service.go b/internal/service/casbin_service.go index 3a3f3f4..f4a2035 100644 --- a/internal/service/casbin_service.go +++ b/internal/service/casbin_service.go @@ -7,6 +7,7 @@ import ( "with_you/internal/cache" "with_you/internal/config" + "with_you/internal/pkg/auth" "with_you/internal/repository" "github.com/casbin/casbin/v3" @@ -88,9 +89,11 @@ func (s *casbinService) Enforce(ctx context.Context, sub, obj, act string) (bool return true, nil } - // 检查缓存 + // 缓存键与策略维度对齐:sub/obj/act 直接对应 p, role, resource, action。 + // user_roles 表为用户-角色真源,由 EnforceForUser 解析后再调本方法; + // 这里 sub 已经是角色名,避免再走 g(user, role) 分组(model 中 matcher 已不再依赖 g)。 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 allowed, ok := cached.(bool); ok { return allowed, nil @@ -106,7 +109,7 @@ func (s *casbinService) Enforce(ctx context.Context, sub, obj, act string) (bool // 缓存结果 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) } @@ -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 { - // 添加到数据库 + // 真源策略:user_roles 表为唯一真源,Casbin 不再承载 g, user, role 分组策略。 + // 减少双写漂移与缓存失效盲区;EnforceForUser 内部先查 DB 角色再对每个角色调 Enforce(role,...)。 if err := s.roleRepo.AddRoleForUser(ctx, userID, role); err != nil { return err } - // 添加到 Casbin - if _, err := s.enforcer.AddRoleForUser(userID, role); err != nil { - return err - } - - // 清除缓存 - return s.clearUserCache(ctx, userID) + // 失效该用户的角色缓存,以及与该角色相关的策略缓存。 + s.clearUserRoleCache(ctx, userID) + return s.clearPolicyCache(ctx) } func (s *casbinService) DeleteRoleForUser(ctx context.Context, userID, role string) error { - // 从数据库删除 if err := s.roleRepo.DeleteRoleForUser(ctx, userID, role); err != nil { return err } - // 从 Casbin 删除 - if _, err := s.enforcer.DeleteRoleForUser(userID, role); err != nil { - return err - } - - // 清除缓存 - return s.clearUserCache(ctx, userID) + s.clearUserRoleCache(ctx, userID) + return s.clearPolicyCache(ctx) } 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 { if s.cache != nil { - // 清除所有 casbin 相关缓存 + // 清除所有 casbin 相关缓存:策略缓存 + 用户角色缓存。 s.cache.DeleteByPrefix("casbin:") } 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 { - cacheKey := fmt.Sprintf("casbin:user:roles:%s", userID) - s.cache.Delete(cacheKey) + s.cache.DeleteByPrefix("casbin:policy:") } 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) { return s.enforcer.GetPermissionsForUser(role) } diff --git a/internal/service/casbin_service_test.go b/internal/service/casbin_service_test.go new file mode 100644 index 0000000..6fc31d6 --- /dev/null +++ b/internal/service/casbin_service_test.go @@ -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/,不能命中 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() +} diff --git a/internal/service/chat_service.go b/internal/service/chat_service.go index befa0df..b285baa 100644 --- a/internal/service/chat_service.go +++ b/internal/service/chat_service.go @@ -91,9 +91,16 @@ type chatServiceImpl struct { versionLogMaxGap int64 pushWorker *PushWorker redisClient *redis.Client + + // groupAbility 用于群消息发送前的群成员/禁言校验。 + // 可选依赖:为 nil 时跳过群能力校验(向后兼容旧测试构造),生产环境应注入。 + groupAbility GroupAbility } // NewChatService 创建聊天服务 +// +// groupAbility 可选注入:群消息发送前的成员/禁言校验由此完成。 +// 生产环境必须注入,否则群禁言规则无法生效。 func NewChatService( repo repository.MessageRepository, userRepo repository.UserRepository, @@ -105,6 +112,7 @@ func NewChatService( seqBufferMgr *cache.SeqBufferManager, pushWorker *PushWorker, redisClient *redis.Client, + groupAbility GroupAbility, versionLogRepo ...repository.ConversationVersionLogRepository, ) ChatService { // 创建适配器 @@ -131,6 +139,7 @@ func NewChatService( uploadService: uploadService, pushWorker: pushWorker, redisClient: redisClient, + groupAbility: groupAbility, } 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) } + // 群消息发送:校验发送者是否仍是群成员、是否被禁言、群是否全员禁言。 + // 私聊会话不参与该校验。 + 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 err := s.uploadService.ValidateChatMessageImageSegments(segments); err != nil { return nil, err @@ -1054,8 +1071,8 @@ func (s *chatServiceImpl) IsUserOnline(userID string) bool { // SaveMessage 仅保存消息到数据库,不发送实时推送 // 适用于群聊等由调用方自行负责推送的场景 func (s *chatServiceImpl) SaveMessage(ctx context.Context, senderID string, conversationID string, segments model.MessageSegments, replyToID *string) (*model.Message, error) { - // 验证会话是否存在 - _, err := s.getConversation(ctx, conversationID) + // 验证会话是否存在(保留 conv 用于群消息能力校验) + conv, err := s.getConversation(ctx, conversationID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { 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) } + // 群消息发送:校验发送者是否仍是群成员、是否被禁言、群是否全员禁言。 + // 私聊会话不参与该校验。 + 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 err := s.uploadService.ValidateChatMessageImageSegments(segments); err != nil { return nil, err diff --git a/internal/service/group_ability.go b/internal/service/group_ability.go new file mode 100644 index 0000000..31b8a13 --- /dev/null +++ b/internal/service/group_ability.go @@ -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) + }) +} \ No newline at end of file diff --git a/internal/service/group_service.go b/internal/service/group_service.go index 4c4ef84..16253f1 100644 --- a/internal/service/group_service.go +++ b/internal/service/group_service.go @@ -70,6 +70,8 @@ type GroupService interface { LeaveGroup(userID string, groupID string) error RemoveMember(userID string, groupID string, targetUserID string) 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 SetMemberNickname(userID string, groupID string, nickname string) 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) + // IsGroupMember 检查用户是否为群成员(用户侧可见性校验)。 + IsGroupMember(userID string, groupID string) bool + // 游标分页方法 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) @@ -1192,6 +1197,9 @@ func (s *groupService) RemoveMember(userID string, groupID string, targetUserID } // GetMembers 获取群成员列表(带缓存) +// +// 注意:本方法不校验调用者是否为群成员,仅供内部/admin 路径调用。 +// 用户侧路由应使用 GetMembersForUser,后者会校验 requesterID 是否为群成员。 func (s *groupService) GetMembers(groupID string, page, pageSize int) ([]model.GroupMember, int64, error) { cacheSettings := cache.GetSettings() groupMembersTTL := cacheSettings.GroupMembersTTL @@ -1235,6 +1243,24 @@ func (s *groupService) GetMembers(groupID string, page, pageSize int) ([]model.G 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 设置成员角色 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) } +// 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 游标分页获取群组列表 diff --git a/internal/service/jwt_service.go b/internal/service/jwt_service.go index 25d7ef2..e5e15a4 100644 --- a/internal/service/jwt_service.go +++ b/internal/service/jwt_service.go @@ -2,14 +2,31 @@ package service import ( "time" + "with_you/internal/pkg/jwt" ) // JWTService JWT服务接口 +// +// 拆分为按令牌类型的解析 API,避免历史单一 ParseToken 让 refresh token 误当 access token 使用。 +// 旧的 GenerateAccessToken/GenerateRefreshToken 签名保留兼容;带 Session 的版本由 SessionService +// 配合使用,把 sid 写入 claims 支持撤销。 type JWTService interface { GenerateAccessToken(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) + + // Deprecated: 新代码直接用 ParseAccessToken。保留仅为历史调用点平滑迁移。 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) { - return s.jwt.GenerateAccessToken(userID, username) + return s.jwt.GenerateAccessToken(userID, username, "") } -// GenerateRefreshToken 生成刷新令牌 +// GenerateRefreshToken 生成刷新令牌(不带 sid,仅向后兼容)。 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) { return s.jwt.ParseToken(tokenString) } -// ValidateToken 验证令牌 +// ValidateToken 验证令牌。 +// +// Deprecated: 新代码直接使用 ParseAccessToken。 func (s *jwtServiceImpl) ValidateToken(tokenString string) error { return s.jwt.ValidateToken(tokenString) } diff --git a/internal/service/message_service.go b/internal/service/message_service.go index 868f995..36f3d66 100644 --- a/internal/service/message_service.go +++ b/internal/service/message_service.go @@ -2,14 +2,17 @@ package service import ( "context" + "errors" "time" "with_you/internal/cache" + apperrors "with_you/internal/errors" "with_you/internal/model" "with_you/internal/pkg/cursor" "with_you/internal/repository" "go.uber.org/zap" + "gorm.io/gorm" ) // 缓存TTL常量 @@ -36,7 +39,9 @@ type MessageService interface { GetMyParticipantsBatch(ctx context.Context, convIDs []string, userID string) (map[string]*model.ConversationParticipant, error) InvalidateUserConversationCache(userID 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) } @@ -336,7 +341,22 @@ func (s *messageService) InvalidateUserUnreadCache(userID, conversationID string } // 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) } diff --git a/internal/service/role_service.go b/internal/service/role_service.go index 9995b96..3e09894 100644 --- a/internal/service/role_service.go +++ b/internal/service/role_service.go @@ -96,6 +96,19 @@ func (s *roleService) AssignRole(ctx context.Context, operatorID, targetUserID, 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) if err != nil { @@ -126,6 +139,8 @@ func (s *roleService) RemoveRole(ctx context.Context, operatorID, targetUserID, return apperrors.ErrCannotModifySuperAdmin } + // 纵深防御:移除其他用户的 super_admin 角色同样要求 operator 是 super_admin。 + // (role==super_admin 已被上面拦截,这里针对未来扩展:如果允许移除其他高优先级角色再加规则。) // 检查用户是否有该角色 hasRole, err := s.casbinSvc.HasRoleForUser(ctx, targetUserID, role) if err != nil { @@ -138,6 +153,16 @@ func (s *roleService) RemoveRole(ctx context.Context, operatorID, targetUserID, 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) { return s.roleRepo.GetAllRoles(ctx) } diff --git a/internal/service/role_service_test.go b/internal/service/role_service_test.go new file mode 100644 index 0000000..ea8b073 --- /dev/null +++ b/internal/service/role_service_test.go @@ -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 \ No newline at end of file diff --git a/internal/service/session_service.go b/internal/service/session_service.go new file mode 100644 index 0000000..8bc16d6 --- /dev/null +++ b/internal/service/session_service.go @@ -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 哈希。 +// +// 不使用 bcrypt:refresh 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 token(sid 留空待补,避免一次性生成两个 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) +} \ No newline at end of file diff --git a/internal/service/user_service.go b/internal/service/user_service.go index 0e9a50f..4104c4e 100644 --- a/internal/service/user_service.go +++ b/internal/service/user_service.go @@ -7,9 +7,12 @@ import ( "strings" "time" + "go.uber.org/zap" + "with_you/internal/cache" apperrors "with_you/internal/errors" "with_you/internal/model" + "with_you/internal/pkg/auth" "with_you/internal/pkg/utils" "with_you/internal/repository" ) @@ -73,10 +76,15 @@ type UserService interface { } // userServiceImpl 用户服务实现 +// +// sessionSvc 与 cache 用于改密/重置密码后撤销该用户全部登录会话并失效 Principal 缓存, +// 让攻击者即使持有旧 access token 也无法用 refresh token 续期。 type userServiceImpl struct { userRepo repository.UserRepository systemMessageService SystemMessageService emailCodeService EmailCodeService + sessionSvc SessionService + cache cache.Cache logService *LogService } @@ -87,11 +95,14 @@ func NewUserService( emailService EmailService, cacheBackend cache.Cache, logService *LogService, + sessionSvc SessionService, ) UserService { return &userServiceImpl{ userRepo: userRepo, systemMessageService: systemMessageService, emailCodeService: NewEmailCodeService(emailService, cacheBackend), + sessionSvc: sessionSvc, + cache: cacheBackend, logService: logService, } } @@ -687,6 +698,11 @@ func (s *userServiceImpl) ChangePassword(ctx context.Context, userID, oldPasswor return err } + // 改密成功后撤销该用户全部登录会话并失效 Principal 缓存。 + // refresh token 立即失效;access token 在剩余 TTL 内仍可用(无状态 JWT 固有限制)。 + // 失败仅告警不阻塞:密码已变更,缓存最终会自然过期。 + s.invalidateSessionsAfterAuthChange(ctx, userID, "change_password") + // 记录数据变更日志 if s.logService != nil { s.logService.DataChangeLog.RecordDataChange(&model.DataChangeLog{ @@ -728,7 +744,32 @@ func (s *userServiceImpl) ResetPasswordByEmail(ctx context.Context, email, verif return err } 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 搜索用户 diff --git a/internal/wire/identity_provider.go b/internal/wire/identity_provider.go new file mode 100644 index 0000000..15d1c50 --- /dev/null +++ b/internal/wire/identity_provider.go @@ -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) +} \ No newline at end of file diff --git a/internal/wire/provider_set.go b/internal/wire/provider_set.go index 142a29e..0875a5f 100644 --- a/internal/wire/provider_set.go +++ b/internal/wire/provider_set.go @@ -10,4 +10,7 @@ var AllSet = wire.NewSet( ServiceSet, HandlerSet, GRPCSet, + + // 认证管道依赖的身份提供者 + ProvideIdentityProvider, ) diff --git a/internal/wire/repository.go b/internal/wire/repository.go index 63dd20a..61f1339 100644 --- a/internal/wire/repository.go +++ b/internal/wire/repository.go @@ -58,6 +58,9 @@ var RepositorySet = wire.NewSet( // 文件上传记录(聊天文件 TTL 过期清理) repository.NewUploadedFileRepository, + + // 会话(令牌撤销支持) + repository.NewSessionRepository, ) // ProvideUserActivityRepository 提供用户活跃数据仓储 diff --git a/internal/wire/service.go b/internal/wire/service.go index b0b1739..37908db 100644 --- a/internal/wire/service.go +++ b/internal/wire/service.go @@ -28,6 +28,9 @@ var ServiceSet = wire.NewSet( // JWT 服务(需要配置参数) ProvideJWTService, + // 会话服务(令牌撤销支持) + ProvideSessionService, + // 基础服务 ProvideEmailService, 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 提供邮件服务 func ProvideEmailService(client email.Client) service.EmailService { return service.NewEmailService(client) @@ -180,8 +191,9 @@ func ProvideUserService( emailService service.EmailService, cacheBackend cache.Cache, logService *service.LogService, + sessionService service.SessionService, ) service.UserService { - return service.NewUserService(userRepo, systemMessageService, emailService, cacheBackend, logService) + return service.NewUserService(userRepo, systemMessageService, emailService, cacheBackend, logService, sessionService) } // ProvideStickerService 提供表情服务 @@ -222,6 +234,7 @@ func ProvideChatService( redisClient *redis.Client, versionLogRepo repository.ConversationVersionLogRepository, cfg *config.Config, + groupService service.GroupService, ) service.ChatService { var vlogOpt []repository.ConversationVersionLogRepository if cfg.VersionLog.Enabled { @@ -231,7 +244,9 @@ func ProvideChatService( if redisClient != nil { 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 提供日程服务 @@ -340,8 +355,14 @@ func ProvideRoleService( } // 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 提供管理端帖子服务