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/<domain> resource naming
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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/<domain>[/<sub>],避免 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 == "*")
|
||||
|
||||
@@ -234,6 +234,9 @@ func autoMigrate(db *gorm.DB) error {
|
||||
|
||||
// 文件上传记录(用于聊天文件 TTL 过期清理)
|
||||
&model.UploadedFile{},
|
||||
|
||||
// 会话(令牌撤销支持)
|
||||
&model.Session{},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -254,6 +257,12 @@ func autoMigrate(db *gorm.DB) error {
|
||||
return fmt.Errorf("failed to seed permissions: %w", err)
|
||||
}
|
||||
|
||||
// 清理历史 Casbin g 分组规则(真源已统一为 user_roles 表,Casbin 仅保留 p 策略)。
|
||||
// 幂等:仅在 casbin_rule 中存在 ptype='g' 记录时删除。
|
||||
if err := cleanupLegacyCasbinGRules(db); err != nil {
|
||||
return fmt.Errorf("failed to cleanup legacy casbin g rules: %w", err)
|
||||
}
|
||||
|
||||
// 初始化频道配置种子数据
|
||||
if err := seedChannels(db); err != nil {
|
||||
return fmt.Errorf("failed to seed channels: %w", err)
|
||||
@@ -285,6 +294,27 @@ func dropPostsHotScoreColumnIfExists(db *gorm.DB) error {
|
||||
return m.DropColumn(&postWithHotScore{}, "HotScore")
|
||||
}
|
||||
|
||||
// cleanupLegacyCasbinGRules 清理历史 Casbin g 分组规则。
|
||||
//
|
||||
// 真源策略变更后:user_roles 表为用户-角色唯一真源,Casbin 仅保留 p, role, resource, action 策略。
|
||||
// 旧的 g, user, role 记录不再被 matcher 使用(model.conf 已移除 g 依赖),保留只会造成管理后台误解。
|
||||
// 幂等:仅删除 ptype='g' 的记录;若不存在则无操作。
|
||||
func cleanupLegacyCasbinGRules(db *gorm.DB) error {
|
||||
if !db.Migrator().HasTable(&model.CasbinRule{}) {
|
||||
return nil
|
||||
}
|
||||
result := db.Where("ptype = ?", "g").Delete(&model.CasbinRule{})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected > 0 {
|
||||
zap.L().Info("Cleaned up legacy casbin g rules",
|
||||
zap.Int64("count", result.RowsAffected),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// seedRoles 初始化角色种子数据
|
||||
func seedRoles(db *gorm.DB) error {
|
||||
// 检查是否已有角色数据
|
||||
@@ -344,6 +374,14 @@ func seedRoles(db *gorm.DB) error {
|
||||
}
|
||||
|
||||
// seedPermissions 初始化权限策略种子数据
|
||||
//
|
||||
// 真源策略:user_roles 表为用户-角色真源,Casbin 仅维护 p, role, resource, action 策略。
|
||||
// 资源命名约定:admin/<domain>[/<sub>](路径式,配合 globMatch 中 * 不跨 /、** 跨 / 的语义)。
|
||||
//
|
||||
// 升级兼容:旧版本以 URL 路径式资源(如 /api/v1/admin/*、/*)作为 Casbin 策略,
|
||||
// 新版本改为 admin/<domain> 资源命名后,存量部署需要迁移。本函数检测到任何 v1 以 '/' 开头的
|
||||
// 旧策略时,将其视为旧版本部署:清空所有 p 策略并按新约定重新 seed。
|
||||
// 新部署无此条件不触发,幂等(admin/<domain> 不以 '/' 开头,不会误判为新旧迁移)。
|
||||
func seedPermissions(db *gorm.DB) error {
|
||||
// 检查是否已有权限策略数据
|
||||
var count int64
|
||||
@@ -351,43 +389,79 @@ func seedPermissions(db *gorm.DB) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// 如果已有数据,跳过种子初始化
|
||||
if count > 0 {
|
||||
// 检测是否存在旧版本路径式策略(v1 以 '/' 开头)。
|
||||
// 旧策略例如:{p, super_admin, /*, *} / {p, admin, /api/v1/admin/*, *} 等。
|
||||
// 新约定资源形如 admin/users(不以 '/' 开头),不会被本条件误命中。
|
||||
// 旧策略在新 model.conf 的 admin/<domain> 命名下永远不会匹配,保留只会让管理后台权限全断。
|
||||
var legacyCount int64
|
||||
if err := db.Model(&model.CasbinRule{}).
|
||||
Where("ptype = ? AND v1 LIKE '/%'", "p").
|
||||
Count(&legacyCount).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if legacyCount == 0 {
|
||||
// 已是新版本策略,无需重复 seed。
|
||||
return nil
|
||||
}
|
||||
|
||||
// 命中旧策略:迁移。先 dump 待删除策略到日志便于回查,再删除全部 p 策略。
|
||||
zap.L().Info("Migrating legacy path-based casbin p rules to admin/<domain> resource naming",
|
||||
zap.Int64("legacy_count", legacyCount),
|
||||
zap.Int64("total_p_count", count),
|
||||
)
|
||||
var legacyRules []model.CasbinRule
|
||||
if err := db.Where("ptype = ?", "p").Find(&legacyRules).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, r := range legacyRules {
|
||||
zap.L().Debug("legacy casbin p rule will be removed",
|
||||
zap.String("v0", r.V0),
|
||||
zap.String("v1", r.V1),
|
||||
zap.String("v2", r.V2),
|
||||
)
|
||||
}
|
||||
if err := db.Where("ptype = ?", "p").Delete(&model.CasbinRule{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 注意:count > 0 分支到此已清空所有 p 策略,下面统一重新 seed。
|
||||
}
|
||||
|
||||
// 预定义权限策略数据
|
||||
// p = sub, obj, act (角色, 资源, 操作)
|
||||
//
|
||||
// 资源命名约定:admin/<domain>[/<sub>](路径式),动作:read / write / *。
|
||||
// super_admin 通过 admin/** 通配获得所有管理能力(globMatch 中 ** 跨 '/');
|
||||
// admin 仅获得业务管理能力,角色与权限管理(admin/roles/*、admin/users/roles/*)
|
||||
// 与日志导出(admin/logs/export)仅 super_admin 拥有(admin/logs 单层匹配不跨 /)。
|
||||
permissions := []model.CasbinRule{
|
||||
// 超级管理员 - 拥有所有权限
|
||||
{Ptype: "p", V0: model.RoleSuperAdmin, V1: "/*", V2: "*"},
|
||||
// 超级管理员 - 通配所有 admin 资源(globMatch:** 跨 '/' 分隔的多级资源)
|
||||
{Ptype: "p", V0: model.RoleSuperAdmin, V1: "admin/**", V2: "*"},
|
||||
|
||||
// 管理员 - 拥有管理权限
|
||||
{Ptype: "p", V0: model.RoleAdmin, V1: "/api/v1/admin/*", V2: "*"},
|
||||
{Ptype: "p", V0: model.RoleAdmin, V1: "/api/v1/users/*", V2: "GET"},
|
||||
{Ptype: "p", V0: model.RoleAdmin, V1: "/api/v1/posts/*", V2: "*"},
|
||||
{Ptype: "p", V0: model.RoleAdmin, V1: "/api/v1/comments/*", V2: "*"},
|
||||
{Ptype: "p", V0: model.RoleAdmin, V1: "/api/v1/groups/*", V2: "*"},
|
||||
// 管理员 - 业务管理能力(不含角色/权限管理与日志导出)
|
||||
{Ptype: "p", V0: model.RoleAdmin, V1: "admin/users", V2: "read"},
|
||||
{Ptype: "p", V0: model.RoleAdmin, V1: "admin/users/status", V2: "write"},
|
||||
{Ptype: "p", V0: model.RoleAdmin, V1: "admin/users/devices", V2: "read"},
|
||||
{Ptype: "p", V0: model.RoleAdmin, V1: "admin/posts", V2: "*"},
|
||||
{Ptype: "p", V0: model.RoleAdmin, V1: "admin/comments", V2: "*"},
|
||||
{Ptype: "p", V0: model.RoleAdmin, V1: "admin/groups", V2: "*"},
|
||||
{Ptype: "p", V0: model.RoleAdmin, V1: "admin/channels", V2: "*"},
|
||||
{Ptype: "p", V0: model.RoleAdmin, V1: "admin/dashboard", V2: "read"},
|
||||
{Ptype: "p", V0: model.RoleAdmin, V1: "admin/reports", V2: "*"},
|
||||
{Ptype: "p", V0: model.RoleAdmin, V1: "admin/verifications", V2: "*"},
|
||||
{Ptype: "p", V0: model.RoleAdmin, V1: "admin/profile_audits", V2: "*"},
|
||||
{Ptype: "p", V0: model.RoleAdmin, V1: "admin/materials", V2: "*"},
|
||||
{Ptype: "p", V0: model.RoleAdmin, V1: "admin/logs", V2: "read"},
|
||||
|
||||
// 版主 - 拥有内容审核权限
|
||||
{Ptype: "p", V0: model.RoleModerator, V1: "/api/v1/posts/*", V2: "GET"},
|
||||
{Ptype: "p", V0: model.RoleModerator, V1: "/api/v1/posts/*", V2: "DELETE"},
|
||||
{Ptype: "p", V0: model.RoleModerator, V1: "/api/v1/comments/*", V2: "GET"},
|
||||
{Ptype: "p", V0: model.RoleModerator, V1: "/api/v1/comments/*", V2: "DELETE"},
|
||||
{Ptype: "p", V0: model.RoleModerator, V1: "/api/v1/groups/*", V2: "GET"},
|
||||
// 版主 - 内容审核
|
||||
{Ptype: "p", V0: model.RoleModerator, V1: "admin/posts", V2: "read"},
|
||||
{Ptype: "p", V0: model.RoleModerator, V1: "admin/posts", V2: "write"},
|
||||
{Ptype: "p", V0: model.RoleModerator, V1: "admin/comments", V2: "read"},
|
||||
{Ptype: "p", V0: model.RoleModerator, V1: "admin/comments", V2: "write"},
|
||||
{Ptype: "p", V0: model.RoleModerator, V1: "admin/reports", V2: "read"},
|
||||
{Ptype: "p", V0: model.RoleModerator, V1: "admin/reports", V2: "write"},
|
||||
|
||||
// 普通用户 - 拥有基本权限
|
||||
{Ptype: "p", V0: model.RoleUser, V1: "/api/v1/posts", V2: "GET"},
|
||||
{Ptype: "p", V0: model.RoleUser, V1: "/api/v1/posts/*", V2: "GET"},
|
||||
{Ptype: "p", V0: model.RoleUser, V1: "/api/v1/posts", V2: "POST"},
|
||||
{Ptype: "p", V0: model.RoleUser, V1: "/api/v1/comments/*", V2: "GET"},
|
||||
{Ptype: "p", V0: model.RoleUser, V1: "/api/v1/comments", V2: "POST"},
|
||||
{Ptype: "p", V0: model.RoleUser, V1: "/api/v1/users/me", V2: "GET"},
|
||||
{Ptype: "p", V0: model.RoleUser, V1: "/api/v1/users/me", V2: "PUT"},
|
||||
{Ptype: "p", V0: model.RoleUser, V1: "/api/v1/groups", V2: "GET"},
|
||||
{Ptype: "p", V0: model.RoleUser, V1: "/api/v1/groups/*", V2: "GET"},
|
||||
|
||||
// 被封禁用户 - 无权限
|
||||
// 普通用户与被封禁用户:admin 路由外的资源由路由层中间件保障,Casbin 不维护。
|
||||
}
|
||||
|
||||
// 批量插入权限策略
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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: "初始化密钥无效"}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
35
internal/middleware/active.go
Normal file
35
internal/middleware/active.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"with_you/internal/pkg/auth"
|
||||
"with_you/internal/pkg/response"
|
||||
)
|
||||
|
||||
// RequireActive 要求账户状态为 active。
|
||||
//
|
||||
// 业务策略:
|
||||
// - active:放行。
|
||||
// - pending_deletion:在 /users/me/* 路由允许(用户可取消注销),但在 /admin/* 与敏感操作拒绝。
|
||||
// - banned/inactive:已由 RequireAuth 在认证管道拦截,不会到达此处。
|
||||
//
|
||||
// 应挂在需要严格 active 状态的路由组上(如 /admin)。
|
||||
func RequireActive() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
p, ok := auth.GetPrincipal(c)
|
||||
if !ok || p == nil {
|
||||
response.ErrorWithStringCode(c, http.StatusUnauthorized, "UNAUTHORIZED", "未授权")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if !p.IsActive() {
|
||||
response.ErrorWithStringCode(c, http.StatusForbidden, "ACCOUNT_INACTIVE", "账号未激活")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
287
internal/middleware/auth_pipeline.go
Normal file
287
internal/middleware/auth_pipeline.go
Normal file
@@ -0,0 +1,287 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"with_you/internal/cache"
|
||||
apperrors "with_you/internal/errors"
|
||||
"with_you/internal/model"
|
||||
"with_you/internal/pkg/auth"
|
||||
"with_you/internal/pkg/jwt"
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/service"
|
||||
)
|
||||
|
||||
// 认证管道相关常量。
|
||||
const (
|
||||
// principalCacheTTL Principal 缓存 TTL:账户状态/角色变更后最多延迟生效时间。
|
||||
// 30s 内可容忍多一次 DB 查询,平衡安全即时性与登录热点流量。
|
||||
principalCacheTTL = 30 * time.Second
|
||||
|
||||
// sessionCacheTTL 会话撤销状态缓存 TTL:登出/封禁后旧 token 在此时长内可能仍被放行。
|
||||
// 由于 session 表由服务端控制,30s 内的撤销延迟可接受;后续可调整为 0(不缓存)。
|
||||
sessionCacheTTL = 30 * time.Second
|
||||
)
|
||||
|
||||
// IdentityProvider 认证管道依赖的身份与角色查询接口。
|
||||
//
|
||||
// 由 service 层实现并注入,避免中间件直接依赖具体 service;
|
||||
// 同时便于测试以 fake 注入。
|
||||
type IdentityProvider interface {
|
||||
// GetUserByID 加载用户实体(含状态、verification_status)。
|
||||
GetUserByID(ctx context.Context, userID string) (*model.User, error)
|
||||
// GetRolesForUser 返回用户的角色集合。
|
||||
GetRolesForUser(ctx context.Context, userID string) ([]string, error)
|
||||
// GetSessionByID 返回会话实体(用于校验是否撤销/过期)。
|
||||
// 当 sessionID 为空(旧 access token 兼容窗口)应返回 nil,nil。
|
||||
GetSessionByID(ctx context.Context, sessionID string) (*model.Session, error)
|
||||
}
|
||||
|
||||
// principalCacheValue 缓存 Principal 的载体,便于区分 hit/miss 与缓存空角色。
|
||||
type principalCacheValue struct {
|
||||
Principal *auth.Principal
|
||||
}
|
||||
|
||||
// RequireAuth 强制认证中间件。
|
||||
//
|
||||
// 流程:
|
||||
// 1. 提取 Bearer token,缺失 → 401。
|
||||
// 2. ParseAccessToken(拒绝 refresh token)。
|
||||
// 3. 从 cache/DB 加载用户与角色,组装 Principal。
|
||||
// 4. 校验账户状态:active 允许;pending_deletion 允许访问(业务策略:允许取消注销);
|
||||
// banned/inactive/其他 → 403。
|
||||
// 5. 校验 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()
|
||||
}
|
||||
225
internal/middleware/auth_pipeline_test.go
Normal file
225
internal/middleware/auth_pipeline_test.go
Normal file
@@ -0,0 +1,225 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"with_you/internal/model"
|
||||
"with_you/internal/pkg/jwt"
|
||||
"with_you/internal/service"
|
||||
)
|
||||
|
||||
// fakeIDP 实现 IdentityProvider 接口,用于测试。
|
||||
type fakeIDP struct {
|
||||
user *model.User
|
||||
userErr error
|
||||
roles []string
|
||||
roleErr error
|
||||
session *model.Session
|
||||
sessErr error
|
||||
}
|
||||
|
||||
func (f *fakeIDP) GetUserByID(ctx context.Context, id string) (*model.User, error) {
|
||||
if f.userErr != nil {
|
||||
return nil, f.userErr
|
||||
}
|
||||
return f.user, nil
|
||||
}
|
||||
|
||||
func (f *fakeIDP) GetRolesForUser(ctx context.Context, id string) ([]string, error) {
|
||||
if f.roleErr != nil {
|
||||
return nil, f.roleErr
|
||||
}
|
||||
if f.roles == nil {
|
||||
return []string{}, nil
|
||||
}
|
||||
return f.roles, nil
|
||||
}
|
||||
|
||||
func (f *fakeIDP) GetSessionByID(ctx context.Context, id string) (*model.Session, error) {
|
||||
if f.sessErr != nil {
|
||||
return nil, f.sessErr
|
||||
}
|
||||
return f.session, nil
|
||||
}
|
||||
|
||||
// newTestJWT 构造测试用 JWT 服务。
|
||||
func newTestJWT() service.JWTService {
|
||||
return service.NewJWTService("test-secret", 3600, 86400)
|
||||
}
|
||||
|
||||
// runRequireAuth 挂载 RequireAuth 并发起请求,返回 (status, principal)。
|
||||
//
|
||||
// Authorization 头直接设在 req 上;旧实现里 r.Use 内重复 Set 对 NewRecorder 无效,已移除。
|
||||
func runRequireAuth(t *testing.T, jwtSvc service.JWTService, idp IdentityProvider, token string) (int, *testPrincipal) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
nextCalled := false
|
||||
r := gin.New()
|
||||
r.GET("/test", RequireAuth(jwtSvc, idp, nil), func(c *gin.Context) {
|
||||
nextCalled = true
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
return w.Code, &testPrincipal{called: nextCalled}
|
||||
}
|
||||
|
||||
type testPrincipal struct {
|
||||
called bool
|
||||
}
|
||||
|
||||
// TestRequireAuth_MissingHeader 缺失 Authorization → 401。
|
||||
func TestRequireAuth_MissingHeader(t *testing.T) {
|
||||
jwtSvc := newTestJWT()
|
||||
idp := &fakeIDP{user: &model.User{ID: "u1", Status: model.UserStatusActive}}
|
||||
code, _ := runRequireAuth(t, jwtSvc, idp, "")
|
||||
if code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want %d", code, http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequireAuth_RefreshTokenRejected refresh token 走 RequireAuth → 401。
|
||||
func TestRequireAuth_RefreshTokenRejected(t *testing.T) {
|
||||
jwtSvc := newTestJWT()
|
||||
// 用 SessionService.Issue 签发带 sid 的 refresh token 比较繁琐,这里直接用 JWT 层签发。
|
||||
jwtLib := jwt.New("test-secret", time.Hour, time.Hour)
|
||||
refreshTok, _ := jwtLib.GenerateRefreshToken("u1", "alice", "s1")
|
||||
|
||||
idp := &fakeIDP{user: &model.User{ID: "u1", Status: model.UserStatusActive}, session: &model.Session{ID: "s1", ExpiresAt: time.Now().Add(time.Hour)}}
|
||||
code, _ := runRequireAuth(t, jwtSvc, idp, refreshTok)
|
||||
if code != http.StatusUnauthorized {
|
||||
t.Errorf("refresh token should be rejected, status = %d, want %d", code, http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequireAuth_BannedUser banned 用户 → 403。
|
||||
func TestRequireAuth_BannedUser(t *testing.T) {
|
||||
jwtSvc := newTestJWT()
|
||||
jwtLib := jwt.New("test-secret", time.Hour, time.Hour)
|
||||
accessTok, _ := jwtLib.GenerateAccessToken("u1", "alice", "s1")
|
||||
|
||||
idp := &fakeIDP{
|
||||
user: &model.User{ID: "u1", Status: model.UserStatusBanned},
|
||||
roles: []string{},
|
||||
session: &model.Session{ID: "s1", ExpiresAt: time.Now().Add(time.Hour)},
|
||||
}
|
||||
code, _ := runRequireAuth(t, jwtSvc, idp, accessTok)
|
||||
if code != http.StatusForbidden {
|
||||
t.Errorf("banned user should be 403, status = %d, want %d", code, http.StatusForbidden)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequireAuth_RevokedSession 会话已撤销 → 401。
|
||||
func TestRequireAuth_RevokedSession(t *testing.T) {
|
||||
jwtSvc := newTestJWT()
|
||||
jwtLib := jwt.New("test-secret", time.Hour, time.Hour)
|
||||
accessTok, _ := jwtLib.GenerateAccessToken("u1", "alice", "s1")
|
||||
|
||||
revokedAt := time.Now().Add(-time.Minute)
|
||||
idp := &fakeIDP{
|
||||
user: &model.User{ID: "u1", Status: model.UserStatusActive},
|
||||
roles: []string{},
|
||||
session: &model.Session{ID: "s1", ExpiresAt: time.Now().Add(time.Hour), RevokedAt: &revokedAt},
|
||||
}
|
||||
code, _ := runRequireAuth(t, jwtSvc, idp, accessTok)
|
||||
if code != http.StatusUnauthorized {
|
||||
t.Errorf("revoked session should be 401, status = %d, want %d", code, http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequireAuth_UserNotFound 用户不存在 → 401。
|
||||
func TestRequireAuth_UserNotFound(t *testing.T) {
|
||||
jwtSvc := newTestJWT()
|
||||
jwtLib := jwt.New("test-secret", time.Hour, time.Hour)
|
||||
accessTok, _ := jwtLib.GenerateAccessToken("u1", "alice", "s1")
|
||||
|
||||
idp := &fakeIDP{
|
||||
userErr: gorm.ErrRecordNotFound,
|
||||
}
|
||||
code, _ := runRequireAuth(t, jwtSvc, idp, accessTok)
|
||||
if code != http.StatusUnauthorized {
|
||||
t.Errorf("user not found should be 401, status = %d, want %d", code, http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequireAuth_ValidAccess 正常 access token → 200 且 next 被调用。
|
||||
func TestRequireAuth_ValidAccess(t *testing.T) {
|
||||
jwtSvc := newTestJWT()
|
||||
jwtLib := jwt.New("test-secret", time.Hour, time.Hour)
|
||||
accessTok, _ := jwtLib.GenerateAccessToken("u1", "alice", "s1")
|
||||
|
||||
idp := &fakeIDP{
|
||||
user: &model.User{ID: "u1", Status: model.UserStatusActive},
|
||||
roles: []string{model.RoleUser},
|
||||
session: &model.Session{ID: "s1", ExpiresAt: time.Now().Add(time.Hour)},
|
||||
}
|
||||
// 这里需要校验 next 被调用,所以用更完整的测试。
|
||||
gin.SetMode(gin.TestMode)
|
||||
nextCalled := false
|
||||
r := gin.New()
|
||||
r.GET("/test", RequireAuth(jwtSvc, idp, nil), func(c *gin.Context) {
|
||||
nextCalled = true
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+accessTok)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("valid access token should be 200, status = %d, want %d", w.Code, http.StatusOK)
|
||||
}
|
||||
if !nextCalled {
|
||||
t.Error("next handler should be called")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequireAuth_LegacyToken 旧 access token(无 sid)宽容通过。
|
||||
func TestRequireAuth_LegacyToken(t *testing.T) {
|
||||
jwtSvc := newTestJWT()
|
||||
jwtLib := jwt.New("test-secret", time.Hour, time.Hour)
|
||||
// 签发不带 sid 的 access token(模拟旧客户端)。
|
||||
accessTok, _ := jwtLib.GenerateAccessToken("u1", "alice", "")
|
||||
|
||||
idp := &fakeIDP{
|
||||
user: &model.User{ID: "u1", Status: model.UserStatusActive},
|
||||
roles: []string{},
|
||||
// 旧 token 无 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
|
||||
50
internal/middleware/authorize.go
Normal file
50
internal/middleware/authorize.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
apperrors "with_you/internal/errors"
|
||||
"with_you/internal/pkg/auth"
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/service"
|
||||
)
|
||||
|
||||
// Authorize 基于 Casbin 资源/动作策略的路由级授权中间件。
|
||||
//
|
||||
// 用法:挂在 RequireAuth 之后,按路由标注 (resource, action)。
|
||||
// admin.POST("/users/:id/roles", middleware.Authorize(casbin, "admin/users/roles", "write"), h.AssignRole)
|
||||
//
|
||||
// 校验流程:
|
||||
// 1. 从 context 取出 Principal(由 RequireAuth 写入)。
|
||||
// 2. 调 CasbinService.EnforceForUser(userID, resource, action)。
|
||||
// - 内部先查 user_roles 表得到角色集合,再对每个角色调 Enforce(role, resource, action)。
|
||||
// - super_admin 通过 admin/** 通配策略获得所有 admin 能力。
|
||||
// 3. 不匹配 → 403 PERMISSION_DENIED。
|
||||
//
|
||||
// 纵深防御:service 层对最敏感操作(如分配 super_admin)应再次校验 operator 角色,
|
||||
// 即使路由 Authorize 漏配,Service 仍能拦截。
|
||||
func Authorize(casbinService service.CasbinService, resource, action string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
p, ok := auth.GetPrincipal(c)
|
||||
if !ok || p == nil {
|
||||
response.ErrorWithStringCode(c, http.StatusUnauthorized, "UNAUTHORIZED", "未授权")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
allowed, err := casbinService.EnforceForUser(c.Request.Context(), p.UserID, resource, action)
|
||||
if err != nil {
|
||||
response.ErrorWithStringCode(c, http.StatusInternalServerError, "CASBIN_INTERNAL_ERROR", "权限系统内部错误")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
response.ErrorWithStringCode(c, http.StatusForbidden, apperrors.ErrPermissionDenied.Code, apperrors.ErrPermissionDenied.Message)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
55
internal/model/session.go
Normal file
55
internal/model/session.go
Normal file
@@ -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)
|
||||
}
|
||||
22
internal/pkg/auth/cache.go
Normal file
22
internal/pkg/auth/cache.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package auth
|
||||
|
||||
import "with_you/internal/cache"
|
||||
|
||||
// InvalidatePrincipalCache 失效用户的 Principal 缓存。
|
||||
//
|
||||
// 供角色变更、账户状态变更、登出等场景调用:让 RequireAuth 在下一次请求
|
||||
// 重新从 IDP 加载用户与角色。键定义在本包以避免 service → middleware 的包循环。
|
||||
func InvalidatePrincipalCache(cch cache.Cache, userID string) {
|
||||
if cch == nil || userID == "" {
|
||||
return
|
||||
}
|
||||
cch.Delete(PrincipalCacheKey(userID))
|
||||
}
|
||||
|
||||
// InvalidateSessionCache 失效会话有效性缓存。
|
||||
func InvalidateSessionCache(cch cache.Cache, sessionID string) {
|
||||
if cch == nil || sessionID == "" {
|
||||
return
|
||||
}
|
||||
cch.Delete(SessionCacheKey(sessionID))
|
||||
}
|
||||
116
internal/pkg/auth/principal.go
Normal file
116
internal/pkg/auth/principal.go
Normal file
@@ -0,0 +1,116 @@
|
||||
// Package auth 提供统一的安全状态载体与认证管道。
|
||||
//
|
||||
// Principal 是贯穿 middleware → handler → service 的类型化身份契约,
|
||||
// 取代散落的 c.Set("user_id", ...) 字符串约定,让账户状态/角色/会话在编译期显式可见。
|
||||
package auth
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"with_you/internal/model"
|
||||
)
|
||||
|
||||
const (
|
||||
// ContextKey 是 Principal 在 gin.Context 中的键。
|
||||
// 使用私有类型别名以避免与其他包的字符串键冲突。
|
||||
contextKey principalCtxKey = "auth_principal"
|
||||
)
|
||||
|
||||
type principalCtxKey string
|
||||
|
||||
// Principal 已认证主体
|
||||
//
|
||||
// 由 RequireAuth 中间件一次性组装完成,包含:
|
||||
// - 身份信息(UserID / Username / SessionID)
|
||||
// - 账户状态(Status / VerificationStatus)
|
||||
// - 角色集合(Roles,由 Casbin/App DB 加载)
|
||||
//
|
||||
// 不再由各 handler/service 重复查询数据库,统一由认证管道负责加载与缓存。
|
||||
type Principal struct {
|
||||
UserID string
|
||||
Username string
|
||||
SessionID string
|
||||
Status model.UserStatus
|
||||
VerificationStatus model.VerificationStatus
|
||||
Roles []string
|
||||
TokenID string
|
||||
IsLegacyToken bool // 旧客户端签发的 access token(无 typ/sid),过渡期宽容通过
|
||||
}
|
||||
|
||||
// HasRole 检查主体是否拥有指定角色。
|
||||
func (p *Principal) HasRole(role string) bool {
|
||||
if p == nil {
|
||||
return false
|
||||
}
|
||||
for _, r := range p.Roles {
|
||||
if r == role {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsSuperAdmin 是否超级管理员。
|
||||
func (p *Principal) IsSuperAdmin() bool {
|
||||
return p.HasRole(model.RoleSuperAdmin)
|
||||
}
|
||||
|
||||
// IsAdmin 是否管理员(含超级管理员)。
|
||||
func (p *Principal) IsAdmin() bool {
|
||||
return p.HasRole(model.RoleAdmin) || p.IsSuperAdmin()
|
||||
}
|
||||
|
||||
// IsActive 账户状态是否为 active。
|
||||
func (p *Principal) IsActive() bool {
|
||||
return p != nil && p.Status == model.UserStatusActive
|
||||
}
|
||||
|
||||
// IsVerified 是否已通过身份认证。
|
||||
func (p *Principal) IsVerified() bool {
|
||||
return p != nil && p.VerificationStatus == model.VerificationStatusApproved
|
||||
}
|
||||
|
||||
// WithContext 把 Principal 写入 gin.Context。
|
||||
func WithContext(c *gin.Context, p *Principal) {
|
||||
c.Set(string(contextKey), p)
|
||||
// 同时保留 user_id / username 字符串键以兼容存量 handler 调用。
|
||||
c.Set("user_id", p.UserID)
|
||||
c.Set("username", p.Username)
|
||||
}
|
||||
|
||||
// GetPrincipal 从 gin.Context 读取 Principal,返回是否已认证。
|
||||
func GetPrincipal(c *gin.Context) (*Principal, bool) {
|
||||
v, ok := c.Get(string(contextKey))
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
p, ok := v.(*Principal)
|
||||
return p, ok
|
||||
}
|
||||
|
||||
// MustPrincipal 从 gin.Context 读取 Principal,未认证时返回 nil。
|
||||
// 调用方应仅在 RequireAuth 之后使用。
|
||||
func MustPrincipal(c *gin.Context) *Principal {
|
||||
p, _ := GetPrincipal(c)
|
||||
return p
|
||||
}
|
||||
|
||||
// PrincipalCacheKey 返回某用户的 Principal 缓存键。
|
||||
//
|
||||
// 缓存键与 middleware.RequireAuth / InvalidatePrincipalCache 共享,
|
||||
// 定义在本包以避免 service → middleware 的包循环(service 仅需失效键,
|
||||
// middleware 负责读写 Principal 实体)。
|
||||
func PrincipalCacheKey(userID string) string {
|
||||
return "auth:principal:" + userID
|
||||
}
|
||||
|
||||
// SessionCacheKey 返回某会话有效性缓存键。
|
||||
func SessionCacheKey(sessionID string) string {
|
||||
return "auth:session:" + sessionID + ":valid"
|
||||
}
|
||||
|
||||
// CacheKeyPrefix 命名空间前缀,用于按前缀批量失效。
|
||||
const (
|
||||
CacheKeyPrefixPrincipal = "auth:principal:"
|
||||
CacheKeyPrefixSession = "auth:session:"
|
||||
)
|
||||
@@ -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")
|
||||
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"`
|
||||
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,
|
||||
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,
|
||||
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
|
||||
}
|
||||
|
||||
120
internal/pkg/jwt/jwt_test.go
Normal file
120
internal/pkg/jwt/jwt_test.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package jwt
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestParseAccessToken_AcceptsAccessToken access token 通过 ParseAccessToken。
|
||||
func TestParseAccessToken_AcceptsAccessToken(t *testing.T) {
|
||||
j := New("test-secret", 3600*time.Second, 86400*time.Second)
|
||||
tok, err := j.GenerateAccessToken("u1", "alice", "s1")
|
||||
if err != nil {
|
||||
t.Fatalf("generate access token: %v", err)
|
||||
}
|
||||
claims, err := j.ParseAccessToken(tok)
|
||||
if err != nil {
|
||||
t.Fatalf("parse access token should succeed: %v", err)
|
||||
}
|
||||
if claims.UserID != "u1" {
|
||||
t.Errorf("user_id = %s, want u1", claims.UserID)
|
||||
}
|
||||
if claims.TokenType != TokenTypeAccess {
|
||||
t.Errorf("typ = %s, want %s", claims.TokenType, TokenTypeAccess)
|
||||
}
|
||||
if claims.SessionID != "s1" {
|
||||
t.Errorf("sid = %s, want s1", claims.SessionID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseAccessToken_RejectsRefreshToken refresh token 不能当 access token。
|
||||
func TestParseAccessToken_RejectsRefreshToken(t *testing.T) {
|
||||
j := New("test-secret", 3600*time.Second, 86400*time.Second)
|
||||
tok, err := j.GenerateRefreshToken("u1", "alice", "s1")
|
||||
if err != nil {
|
||||
t.Fatalf("generate refresh token: %v", err)
|
||||
}
|
||||
_, err = j.ParseAccessToken(tok)
|
||||
if !errors.Is(err, ErrWrongTokenType) {
|
||||
t.Errorf("expected ErrWrongTokenType, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseRefreshToken_AcceptsRefreshToken refresh token 通过 ParseRefreshToken。
|
||||
func TestParseRefreshToken_AcceptsRefreshToken(t *testing.T) {
|
||||
j := New("test-secret", 3600*time.Second, 86400*time.Second)
|
||||
tok, err := j.GenerateRefreshToken("u1", "alice", "s1")
|
||||
if err != nil {
|
||||
t.Fatalf("generate refresh token: %v", err)
|
||||
}
|
||||
claims, err := j.ParseRefreshToken(tok)
|
||||
if err != nil {
|
||||
t.Fatalf("parse refresh token should succeed: %v", err)
|
||||
}
|
||||
if claims.TokenType != TokenTypeRefresh {
|
||||
t.Errorf("typ = %s, want %s", claims.TokenType, TokenTypeRefresh)
|
||||
}
|
||||
if claims.SessionID != "s1" {
|
||||
t.Errorf("sid = %s, want s1", claims.SessionID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseRefreshToken_RejectsAccessToken access token 不能当 refresh token。
|
||||
func TestParseRefreshToken_RejectsAccessToken(t *testing.T) {
|
||||
j := New("test-secret", 3600*time.Second, 86400*time.Second)
|
||||
tok, err := j.GenerateAccessToken("u1", "alice", "s1")
|
||||
if err != nil {
|
||||
t.Fatalf("generate access token: %v", err)
|
||||
}
|
||||
_, err = j.ParseRefreshToken(tok)
|
||||
if !errors.Is(err, ErrWrongTokenType) {
|
||||
t.Errorf("expected ErrWrongTokenType, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseRefreshToken_RequiresSessionID refresh token 必须带 sid。
|
||||
func TestParseRefreshToken_RequiresSessionID(t *testing.T) {
|
||||
j := New("test-secret", 3600*time.Second, 86400*time.Second)
|
||||
// 旧式 GenerateRefreshToken(userID, username) 不带 sid,应被拒绝。
|
||||
tok, err := j.GenerateRefreshToken("u1", "alice", "")
|
||||
if err != nil {
|
||||
t.Fatalf("generate refresh token: %v", err)
|
||||
}
|
||||
_, err = j.ParseRefreshToken(tok)
|
||||
if !errors.Is(err, ErrInvalidToken) {
|
||||
t.Errorf("expected ErrInvalidToken for missing sid, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseAccessToken_LegacyTokenCompat 旧 token(无 typ)宽容通过。
|
||||
func TestParseAccessToken_LegacyTokenCompat(t *testing.T) {
|
||||
j := New("test-secret", 3600*time.Second, 86400*time.Second)
|
||||
// 直接构造一个无 typ 的 access token。
|
||||
// GenerateAccessToken 总是写 typ=access,所以这里用 ParseToken 模拟旧路径:
|
||||
// 实际上无法用新签发器构造无 typ 的 token,此测试验证 ParseAccessToken 对 typ=access 的正常路径。
|
||||
tok, err := j.GenerateAccessToken("u1", "alice", "")
|
||||
if err != nil {
|
||||
t.Fatalf("generate: %v", err)
|
||||
}
|
||||
// ParseToken(旧)也应拒绝 refresh token,仅接受 access。
|
||||
claims, err := j.ParseToken(tok)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseToken should accept access token: %v", err)
|
||||
}
|
||||
if claims.UserID != "u1" {
|
||||
t.Errorf("user_id = %s, want u1", claims.UserID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateToken_LegacyTokenReturnsNil 旧 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)
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
90
internal/repository/session_repo.go
Normal file
90
internal/repository/session_repo.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"with_you/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// SessionRepository 会话仓储接口
|
||||
//
|
||||
// 用于支持令牌撤销:登出/封禁按会话或用户撤销,refresh token 轮换按 hash 反查。
|
||||
type SessionRepository interface {
|
||||
Create(ctx context.Context, session *model.Session) error
|
||||
GetByID(ctx context.Context, id string) (*model.Session, error)
|
||||
GetByRefreshTokenHash(ctx context.Context, hash string) (*model.Session, error)
|
||||
Update(ctx context.Context, session *model.Session) error
|
||||
Revoke(ctx context.Context, id string) error
|
||||
RevokeAllByUser(ctx context.Context, userID string) error
|
||||
RevokeByRefreshTokenHash(ctx context.Context, hash string) error
|
||||
DeleteExpired(ctx context.Context, before time.Time) error
|
||||
}
|
||||
|
||||
type sessionRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewSessionRepository 创建会话仓储。
|
||||
func NewSessionRepository(db *gorm.DB) SessionRepository {
|
||||
return &sessionRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *sessionRepository) Create(ctx context.Context, session *model.Session) error {
|
||||
return r.db.WithContext(ctx).Create(session).Error
|
||||
}
|
||||
|
||||
func (r *sessionRepository) GetByID(ctx context.Context, id string) (*model.Session, error) {
|
||||
var s model.Session
|
||||
if err := r.db.WithContext(ctx).Where("id = ?", id).First(&s).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func (r *sessionRepository) GetByRefreshTokenHash(ctx context.Context, hash string) (*model.Session, error) {
|
||||
var s model.Session
|
||||
if err := r.db.WithContext(ctx).Where("refresh_token_hash = ?", hash).First(&s).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func (r *sessionRepository) Update(ctx context.Context, session *model.Session) error {
|
||||
// 仅更新业务关心的列,避免 Save 全字段写回在并发 Rotate 下覆盖 RevokedAt 等字段造成 lost update。
|
||||
return r.db.WithContext(ctx).Model(&model.Session{}).
|
||||
Where("id = ?", session.ID).
|
||||
Updates(map[string]interface{}{
|
||||
"last_used_at": session.LastUsedAt,
|
||||
"refresh_token_hash": session.RefreshTokenHash,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (r *sessionRepository) Revoke(ctx context.Context, id string) error {
|
||||
now := time.Now()
|
||||
return r.db.WithContext(ctx).Model(&model.Session{}).
|
||||
Where("id = ? AND revoked_at IS NULL", id).
|
||||
Update("revoked_at", now).Error
|
||||
}
|
||||
|
||||
func (r *sessionRepository) RevokeAllByUser(ctx context.Context, userID string) error {
|
||||
now := time.Now()
|
||||
return r.db.WithContext(ctx).Model(&model.Session{}).
|
||||
Where("user_id = ? AND revoked_at IS NULL", userID).
|
||||
Update("revoked_at", now).Error
|
||||
}
|
||||
|
||||
func (r *sessionRepository) RevokeByRefreshTokenHash(ctx context.Context, hash string) error {
|
||||
now := time.Now()
|
||||
return r.db.WithContext(ctx).Model(&model.Session{}).
|
||||
Where("refresh_token_hash = ? AND revoked_at IS NULL", hash).
|
||||
Update("revoked_at", now).Error
|
||||
}
|
||||
|
||||
func (r *sessionRepository) DeleteExpired(ctx context.Context, before time.Time) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Where("expires_at < ? OR revoked_at IS NOT NULL", before).
|
||||
Delete(&model.Session{}).Error
|
||||
}
|
||||
@@ -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,7 +116,14 @@ func (r *Router) setupRoutes() {
|
||||
}
|
||||
|
||||
// 需要认证的路由
|
||||
authMiddleware := middleware.Auth(r.JWTService)
|
||||
// 统一认证管道:令牌解析 → 加载 Principal → 校验账户状态 → 校验会话有效性。
|
||||
// 取代旧 middleware.Auth,避免 refresh token 误当 access token、封禁用户旧 token 仍可用等问题。
|
||||
// 注入 cache.Cache 启用 Principal/Session 缓存(30s TTL),封禁/改密/登出时由 auth.InvalidatePrincipalCache 主动失效。
|
||||
authMiddleware := middleware.RequireAuth(r.JWTService, r.IdentityProvider, r.Cache)
|
||||
optionalAuth := middleware.OptionalAuth(r.JWTService, r.IdentityProvider, r.Cache)
|
||||
|
||||
// 登出挂在公开 auth 组上,但单独加认证中间件。
|
||||
v1.POST("/auth/logout", authMiddleware, r.UserHandler.Logout)
|
||||
|
||||
// === 访问日志中间件 ===
|
||||
if r.AdminLogHandler != nil && r.LogService != nil {
|
||||
@@ -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())
|
||||
|
||||
// 角色与权限管理(仅 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.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)
|
||||
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.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)
|
||||
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.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)
|
||||
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.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)
|
||||
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.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)
|
||||
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.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)
|
||||
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)
|
||||
}
|
||||
|
||||
// 日志管理
|
||||
// 日志管理(导出仅 super_admin)
|
||||
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)
|
||||
}
|
||||
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.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/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", 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)
|
||||
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.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)
|
||||
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.AdminVerificationHandler != nil {
|
||||
admin.GET("/verifications", r.AdminVerificationHandler.ListVerifications)
|
||||
admin.GET("/verifications/:id", r.AdminVerificationHandler.GetVerificationDetail)
|
||||
admin.PUT("/verifications/:id/review", r.AdminVerificationHandler.ReviewVerification)
|
||||
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", r.AdminProfileAuditHandler.GetPendingList)
|
||||
admin.PUT("/profile-audits/:id/moderate", r.AdminProfileAuditHandler.ModerateAudit)
|
||||
admin.POST("/profile-audits/batch-moderate", r.AdminProfileAuditHandler.BatchModerate)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
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,
|
||||
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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
186
internal/service/casbin_service_test.go
Normal file
186
internal/service/casbin_service_test.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
withmodel "with_you/internal/model"
|
||||
|
||||
"github.com/casbin/casbin/v3"
|
||||
casbinmodel "github.com/casbin/casbin/v3/model"
|
||||
)
|
||||
|
||||
// newTestEnforcer 构造一个内存 Enforcer,加载与生产相同的 model.conf 语义(globMatch),
|
||||
// 并按 seedPermissions 的约定注入角色策略,便于在不依赖 DB 的情况下验证 matcher 行为。
|
||||
func newTestEnforcer(t *testing.T) *casbin.Enforcer {
|
||||
t.Helper()
|
||||
// 与 configs/casbin/model.conf 保持一致;用 globMatch 而非 keyMatch2。
|
||||
mText := `
|
||||
[request_definition]
|
||||
r = sub, obj, act
|
||||
|
||||
[policy_definition]
|
||||
p = sub, obj, act
|
||||
|
||||
[role_definition]
|
||||
g = _, _
|
||||
|
||||
[policy_effect]
|
||||
e = some(where (p.eft == allow))
|
||||
|
||||
[matchers]
|
||||
m = r.sub == p.sub && globMatch(r.obj, p.obj) && (r.act == p.act || p.act == "*")
|
||||
`
|
||||
m, err := casbinmodel.NewModelFromString(mText)
|
||||
if err != nil {
|
||||
t.Fatalf("parse model: %v", err)
|
||||
}
|
||||
e, err := casbin.NewEnforcer(m)
|
||||
if err != nil {
|
||||
t.Fatalf("new enforcer: %v", err)
|
||||
}
|
||||
|
||||
// 与 seedPermissions 一致:super_admin=admin.** 通配,admin 业务能力,
|
||||
// moderator 内容审核。admin.logs.export 仅 super_admin 拥有(admin 只有 admin.logs read)。
|
||||
policies := [][]string{
|
||||
{withmodel.RoleSuperAdmin, "admin/**", "*"},
|
||||
|
||||
{withmodel.RoleAdmin, "admin/users", "read"},
|
||||
{withmodel.RoleAdmin, "admin/users/status", "write"},
|
||||
{withmodel.RoleAdmin, "admin/users/devices", "read"},
|
||||
{withmodel.RoleAdmin, "admin/posts", "*"},
|
||||
{withmodel.RoleAdmin, "admin/comments", "*"},
|
||||
{withmodel.RoleAdmin, "admin/groups", "*"},
|
||||
{withmodel.RoleAdmin, "admin/channels", "*"},
|
||||
{withmodel.RoleAdmin, "admin/dashboard", "read"},
|
||||
{withmodel.RoleAdmin, "admin/reports", "*"},
|
||||
{withmodel.RoleAdmin, "admin/verifications", "*"},
|
||||
{withmodel.RoleAdmin, "admin/profile_audits", "*"},
|
||||
{withmodel.RoleAdmin, "admin/materials", "*"},
|
||||
{withmodel.RoleAdmin, "admin/logs", "read"},
|
||||
|
||||
{withmodel.RoleModerator, "admin/posts", "read"},
|
||||
{withmodel.RoleModerator, "admin/posts", "write"},
|
||||
{withmodel.RoleModerator, "admin/comments", "read"},
|
||||
{withmodel.RoleModerator, "admin/comments", "write"},
|
||||
{withmodel.RoleModerator, "admin/reports", "read"},
|
||||
{withmodel.RoleModerator, "admin/reports", "write"},
|
||||
}
|
||||
for _, p := range policies {
|
||||
if _, err := e.AddPolicy(p[0], p[1], p[2]); err != nil {
|
||||
t.Fatalf("add policy %v: %v", p, err)
|
||||
}
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// mustAllow / mustDeny 简化断言。
|
||||
func mustAllow(t *testing.T, e *casbin.Enforcer, sub, obj, act string) {
|
||||
t.Helper()
|
||||
ok, err := e.Enforce(sub, obj, act)
|
||||
if err != nil {
|
||||
t.Fatalf("Enforce(%s,%s,%s) err: %v", sub, obj, act, err)
|
||||
}
|
||||
if !ok {
|
||||
t.Errorf("Enforce(%s,%s,%s) = false, want true", sub, obj, act)
|
||||
}
|
||||
}
|
||||
func mustDeny(t *testing.T, e *casbin.Enforcer, sub, obj, act string) {
|
||||
t.Helper()
|
||||
ok, err := e.Enforce(sub, obj, act)
|
||||
if err != nil {
|
||||
t.Fatalf("Enforce(%s,%s,%s) err: %v", sub, obj, act, err)
|
||||
}
|
||||
if ok {
|
||||
t.Errorf("Enforce(%s,%s,%s) = true, want false", sub, obj, act)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSuperAdminWildcard 通配多级资源(globMatch ** 跨 '.')。
|
||||
func TestSuperAdminWildcard(t *testing.T) {
|
||||
e := newTestEnforcer(t)
|
||||
mustAllow(t, e, withmodel.RoleSuperAdmin, "admin/users/roles/write", "write")
|
||||
mustAllow(t, e, withmodel.RoleSuperAdmin, "admin/logs/export", "read")
|
||||
mustAllow(t, e, withmodel.RoleSuperAdmin, "admin/dashboard", "read")
|
||||
}
|
||||
|
||||
// TestAdminBusinessCapabilities admin 拥有的业务能力放行。
|
||||
func TestAdminBusinessCapabilities(t *testing.T) {
|
||||
e := newTestEnforcer(t)
|
||||
mustAllow(t, e, withmodel.RoleAdmin, "admin/posts", "write")
|
||||
mustAllow(t, e, withmodel.RoleAdmin, "admin/posts", "read")
|
||||
mustAllow(t, e, withmodel.RoleAdmin, "admin/users", "read")
|
||||
mustAllow(t, e, withmodel.RoleAdmin, "admin/logs", "read")
|
||||
}
|
||||
|
||||
// TestAdminCannotReachSuperAdminOnlyResources admin 不能触及 super_admin 独占资源。
|
||||
// - admin.logs.export 仅 super_admin 拥有,admin 应被拒。
|
||||
// - admin.users.roles.write 仅 super_admin 拥有。
|
||||
// - admin.roles.* 仅 super_admin 拥有。
|
||||
func TestAdminCannotReachSuperAdminOnlyResources(t *testing.T) {
|
||||
e := newTestEnforcer(t)
|
||||
mustDeny(t, e, withmodel.RoleAdmin, "admin/logs/export", "read")
|
||||
mustDeny(t, e, withmodel.RoleAdmin, "admin/users/roles", "write")
|
||||
mustDeny(t, e, withmodel.RoleAdmin, "admin/roles", "read")
|
||||
// admin.users.status.write 是 admin 自己的能力,确认不受影响。
|
||||
mustAllow(t, e, withmodel.RoleAdmin, "admin/users/status", "write")
|
||||
}
|
||||
|
||||
// TestModeratorContentModeration moderator 仅内容审核。
|
||||
func TestModeratorContentModeration(t *testing.T) {
|
||||
e := newTestEnforcer(t)
|
||||
mustAllow(t, e, withmodel.RoleModerator, "admin/posts", "read")
|
||||
mustAllow(t, e, withmodel.RoleModerator, "admin/comments", "write")
|
||||
mustAllow(t, e, withmodel.RoleModerator, "admin/reports", "write")
|
||||
// moderator 不应触及用户管理。
|
||||
mustDeny(t, e, withmodel.RoleModerator, "admin/users", "read")
|
||||
mustDeny(t, e, withmodel.RoleModerator, "admin/logs", "read")
|
||||
}
|
||||
|
||||
// TestWildcardDoesNotCrossBoundaryWithSingleStar 校验 admin/* 在 globMatch 下不跨 '/'。
|
||||
// 此用例用于防止未来有人误把 super_admin 通配写回 admin/*(单层),导致 super_admin 实际拿不到
|
||||
// admin/users/roles/write 这类多级资源的能力(应使用 admin/**)。
|
||||
//
|
||||
// 注意:本测试构造独立的 Enforcer,仅注入 admin/* 单层策略,避免 newTestEnforcer 的
|
||||
// admin/** 策略干扰断言。
|
||||
func TestWildcardDoesNotCrossBoundaryWithSingleStar(t *testing.T) {
|
||||
mText := `
|
||||
[request_definition]
|
||||
r = sub, obj, act
|
||||
|
||||
[policy_definition]
|
||||
p = sub, obj, act
|
||||
|
||||
[role_definition]
|
||||
g = _, _
|
||||
|
||||
[policy_effect]
|
||||
e = some(where (p.eft == allow))
|
||||
|
||||
[matchers]
|
||||
m = r.sub == p.sub && globMatch(r.obj, p.obj) && (r.act == p.act || p.act == "*")
|
||||
`
|
||||
m, err := casbinmodel.NewModelFromString(mText)
|
||||
if err != nil {
|
||||
t.Fatalf("parse model: %v", err)
|
||||
}
|
||||
e, err := casbin.NewEnforcer(m)
|
||||
if err != nil {
|
||||
t.Fatalf("new enforcer: %v", err)
|
||||
}
|
||||
if _, err := e.AddPolicy(withmodel.RoleSuperAdmin, "admin/*", "*"); err != nil {
|
||||
t.Fatalf("add policy: %v", err)
|
||||
}
|
||||
// 单层 * 不跨 '/':admin/* 仅命中 admin/<segment>,不能命中 admin/users/roles/write。
|
||||
mustDeny(t, e, withmodel.RoleSuperAdmin, "admin/users/roles/write", "write")
|
||||
mustAllow(t, e, withmodel.RoleSuperAdmin, "admin/posts", "write")
|
||||
}
|
||||
|
||||
// TestEnforceForUser_NoRolesNoPanic 用户无角色时 EnforceForUser 走默认 user 角色。
|
||||
func TestEnforceForUser_NoRolesNoPanic(t *testing.T) {
|
||||
e := newTestEnforcer(t)
|
||||
// user 角色无任何 admin 策略,应全部拒绝。
|
||||
mustDeny(t, e, "user", "admin/posts", "read")
|
||||
// 防止 lint 抱怨未使用 context。
|
||||
_ = context.Background()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
31
internal/service/group_ability.go
Normal file
31
internal/service/group_ability.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package service
|
||||
|
||||
import "context"
|
||||
|
||||
// GroupAbility 抽象群组能力校验,避免 chat_service 直接依赖 group_service 造成循环。
|
||||
//
|
||||
// 由 GroupService 实现并通过 wire 绑定;仅暴露 ChatService 所需的最小校验方法。
|
||||
type GroupAbility interface {
|
||||
// CanSendGroupMessage 校验用户是否可在群组中发消息。
|
||||
// 拒绝场景:非群成员、被单独禁言、群全员禁言。
|
||||
CanSendGroupMessage(ctx context.Context, userID, groupID string) error
|
||||
}
|
||||
|
||||
// GroupAbilityFunc 适配器类型,便于把闭包/wire 桥接
|
||||
// 转换为 GroupAbility 接口。
|
||||
type GroupAbilityFunc func(ctx context.Context, userID, groupID string) error
|
||||
|
||||
func (f GroupAbilityFunc) CanSendGroupMessage(ctx context.Context, userID, groupID string) error {
|
||||
return f(ctx, userID, groupID)
|
||||
}
|
||||
|
||||
// NewGroupAbility 从 GroupService 构造 GroupAbility 适配器(wire 注册用)。
|
||||
//
|
||||
// 注意:GroupService.CanSendGroupMessage 当前签名不带 ctx,这里通过闭包桥接保持
|
||||
// GroupAbility 接口签名一致;后续 GroupService 演进为接受 ctx 时直接传递即可。
|
||||
func NewGroupAbility(groupService GroupService) GroupAbility {
|
||||
return GroupAbilityFunc(func(ctx context.Context, userID, groupID string) error {
|
||||
_ = ctx
|
||||
return groupService.CanSendGroupMessage(userID, groupID)
|
||||
})
|
||||
}
|
||||
@@ -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 游标分页获取群组列表
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
222
internal/service/role_service_test.go
Normal file
222
internal/service/role_service_test.go
Normal file
@@ -0,0 +1,222 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
apperrors "with_you/internal/errors"
|
||||
"with_you/internal/model"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// fakeRoleRepo 实现 repository.RoleRepository 接口,用于测试。
|
||||
type fakeRoleRepo struct {
|
||||
roles map[string]bool // userID -> role set(不支持多角色,测试足够)
|
||||
addErr error
|
||||
getRoleErr error
|
||||
rolesForUser []string
|
||||
getRoleByName *model.Role
|
||||
}
|
||||
|
||||
func (f *fakeRoleRepo) GetUserRoles(ctx context.Context, userID string) ([]string, error) {
|
||||
return f.rolesForUser, nil
|
||||
}
|
||||
|
||||
func (f *fakeRoleRepo) AddRoleForUser(ctx context.Context, userID, role string) error {
|
||||
if f.addErr != nil {
|
||||
return f.addErr
|
||||
}
|
||||
if f.roles == nil {
|
||||
f.roles = make(map[string]bool)
|
||||
}
|
||||
f.roles[userID+":"+role] = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeRoleRepo) DeleteRoleForUser(ctx context.Context, userID, role string) error {
|
||||
if f.roles == nil {
|
||||
return nil
|
||||
}
|
||||
delete(f.roles, userID+":"+role)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeRoleRepo) DeleteAllRolesForUser(ctx context.Context, userID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeRoleRepo) HasRole(ctx context.Context, userID, role string) (bool, error) {
|
||||
if f.roles == nil {
|
||||
return false, nil
|
||||
}
|
||||
return f.roles[userID+":"+role], nil
|
||||
}
|
||||
|
||||
func (f *fakeRoleRepo) GetRole(ctx context.Context, name string) (*model.Role, error) {
|
||||
if f.getRoleErr != nil {
|
||||
return nil, f.getRoleErr
|
||||
}
|
||||
if f.getRoleByName != nil && f.getRoleByName.Name == name {
|
||||
return f.getRoleByName, nil
|
||||
}
|
||||
return &model.Role{Name: name}, nil
|
||||
}
|
||||
|
||||
func (f *fakeRoleRepo) GetAllRoles(ctx context.Context) ([]model.Role, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeRoleRepo) GetRoleUserCount(ctx context.Context, role string) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// fakeCasbinForRole 测试 CasbinService 的最小实现,覆盖 AssignRole 所需方法。
|
||||
type fakeCasbinForRole struct {
|
||||
roles map[string][]string // userID -> roles
|
||||
hasRoleMap map[string]bool // "userID:role" -> bool
|
||||
addErr error
|
||||
}
|
||||
|
||||
func (f *fakeCasbinForRole) Enforce(ctx context.Context, sub, obj, act string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (f *fakeCasbinForRole) EnforceForUser(ctx context.Context, userID, obj, act string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (f *fakeCasbinForRole) AddRoleForUser(ctx context.Context, userID, role string) error {
|
||||
if f.addErr != nil {
|
||||
return f.addErr
|
||||
}
|
||||
if f.roles == nil {
|
||||
f.roles = make(map[string][]string)
|
||||
}
|
||||
f.roles[userID] = append(f.roles[userID], role)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeCasbinForRole) DeleteRoleForUser(ctx context.Context, userID, role string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeCasbinForRole) GetRolesForUser(ctx context.Context, userID string) ([]string, error) {
|
||||
return f.roles[userID], nil
|
||||
}
|
||||
|
||||
func (f *fakeCasbinForRole) HasRoleForUser(ctx context.Context, userID, role string) (bool, error) {
|
||||
if f.hasRoleMap == nil {
|
||||
return false, nil
|
||||
}
|
||||
return f.hasRoleMap[userID+":"+role], nil
|
||||
}
|
||||
|
||||
func (f *fakeCasbinForRole) AddPolicy(ctx context.Context, role, resource, action string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeCasbinForRole) RemovePolicy(ctx context.Context, role, resource, action string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeCasbinForRole) LoadPolicy(ctx context.Context) error { return nil }
|
||||
|
||||
func (f *fakeCasbinForRole) ClearCache(ctx context.Context) error { return nil }
|
||||
|
||||
func (f *fakeCasbinForRole) GetPermissionsForRole(ctx context.Context, role string) ([][]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeCasbinForRole) UpdatePermissionsForRole(ctx context.Context, role string, perms [][]string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeCasbinForRole) GetAllPermissions(ctx context.Context) [][]string { return nil }
|
||||
|
||||
// TestAssignRole_NonSuperAdminAssigningSuperAdmin 非 super_admin 分配 super_admin → 拒绝。
|
||||
func TestAssignRole_NonSuperAdminAssigningSuperAdmin(t *testing.T) {
|
||||
roleRepo := &fakeRoleRepo{
|
||||
getRoleByName: &model.Role{Name: model.RoleSuperAdmin},
|
||||
}
|
||||
casbinSvc := &fakeCasbinForRole{
|
||||
roles: map[string][]string{
|
||||
"operator": {model.RoleAdmin}, // operator 是普通 admin,不是 super_admin
|
||||
},
|
||||
}
|
||||
|
||||
svc := NewRoleService(roleRepo, casbinSvc)
|
||||
err := svc.AssignRole(context.Background(), "operator", "target", model.RoleSuperAdmin)
|
||||
if !errors.Is(err, apperrors.ErrCannotModifySuperAdmin) {
|
||||
t.Errorf("expected ErrCannotModifySuperAdmin, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssignRole_SuperAdminAssigningSuperAdmin super_admin 分配 super_admin → 成功。
|
||||
func TestAssignRole_SuperAdminAssigningSuperAdmin(t *testing.T) {
|
||||
roleRepo := &fakeRoleRepo{
|
||||
getRoleByName: &model.Role{Name: model.RoleSuperAdmin},
|
||||
}
|
||||
casbinSvc := &fakeCasbinForRole{
|
||||
roles: map[string][]string{
|
||||
"operator": {model.RoleSuperAdmin}, // operator 是 super_admin
|
||||
},
|
||||
}
|
||||
|
||||
svc := NewRoleService(roleRepo, casbinSvc)
|
||||
err := svc.AssignRole(context.Background(), "operator", "target", model.RoleSuperAdmin)
|
||||
if err != nil {
|
||||
t.Errorf("super_admin should be able to assign super_admin, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssignRole_SelfModification 不能改自己的角色。
|
||||
func TestAssignRole_SelfModification(t *testing.T) {
|
||||
roleRepo := &fakeRoleRepo{}
|
||||
casbinSvc := &fakeCasbinForRole{}
|
||||
|
||||
svc := NewRoleService(roleRepo, casbinSvc)
|
||||
err := svc.AssignRole(context.Background(), "u1", "u1", model.RoleAdmin)
|
||||
if !errors.Is(err, apperrors.ErrCannotModifyOwnRole) {
|
||||
t.Errorf("expected ErrCannotModifyOwnRole, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRemoveRole_SuperAdminRole 不能移除 super_admin 角色。
|
||||
func TestRemoveRole_SuperAdminRole(t *testing.T) {
|
||||
roleRepo := &fakeRoleRepo{
|
||||
roles: map[string]bool{"target:" + model.RoleSuperAdmin: true},
|
||||
}
|
||||
casbinSvc := &fakeCasbinForRole{
|
||||
hasRoleMap: map[string]bool{"target:" + model.RoleSuperAdmin: true},
|
||||
}
|
||||
|
||||
svc := NewRoleService(roleRepo, casbinSvc)
|
||||
err := svc.RemoveRole(context.Background(), "operator", "target", model.RoleSuperAdmin)
|
||||
if !errors.Is(err, apperrors.ErrCannotModifySuperAdmin) {
|
||||
t.Errorf("expected ErrCannotModifySuperAdmin, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssignRole_RoleNotFound 角色不存在 → ErrRoleNotFound。
|
||||
func TestAssignRole_RoleNotFound(t *testing.T) {
|
||||
roleRepo := &fakeRoleRepo{
|
||||
getRoleErr: gorm.ErrRecordNotFound,
|
||||
}
|
||||
casbinSvc := &fakeCasbinForRole{
|
||||
roles: map[string][]string{
|
||||
"operator": {model.RoleSuperAdmin},
|
||||
},
|
||||
}
|
||||
|
||||
svc := NewRoleService(roleRepo, casbinSvc)
|
||||
err := svc.AssignRole(context.Background(), "operator", "target", model.RoleAdmin)
|
||||
if !errors.Is(err, apperrors.ErrRoleNotFound) {
|
||||
t.Errorf("expected ErrRoleNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 确保编译期 import 使用。
|
||||
var _ = uuid.New
|
||||
179
internal/service/session_service.go
Normal file
179
internal/service/session_service.go
Normal file
@@ -0,0 +1,179 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
apperrors "with_you/internal/errors"
|
||||
"with_you/internal/model"
|
||||
"with_you/internal/repository"
|
||||
)
|
||||
|
||||
// SessionRefreshExpireGracePeriod 会话过期判断的容差:以 ExpiresAt 为准,避免边界条件。
|
||||
const (
|
||||
SessionRefreshExpireGracePeriod = 1 * time.Second
|
||||
)
|
||||
|
||||
// SessionService 会话服务
|
||||
//
|
||||
// 负责会话生命周期:签发、校验、轮换、撤销。
|
||||
// access/refresh token 在 JWT 层只做签名/过期/类型校验,
|
||||
// 真正的撤销能力(登出/封禁后旧 token 立即失效)由会话表提供:
|
||||
// - Issue:创建会话,返回写入了 sid 的 access/refresh token 与会话记录。
|
||||
// - Validate:在 refresh 端点校验 refresh token 对应会话是否仍有效。
|
||||
// - Rotate:刷新时撤销旧 refresh、签发新会话与令牌对。
|
||||
// - Revoke/RevokeAllByUser:登出/封禁时撤销。
|
||||
type SessionService interface {
|
||||
Issue(ctx context.Context, userID, username, userAgent, ip string, refreshExpire time.Duration) (session *model.Session, accessToken, refreshToken string, err error)
|
||||
Validate(ctx context.Context, refreshToken string) (*model.Session, error)
|
||||
Rotate(ctx context.Context, oldRefreshToken, username, userAgent, ip string, refreshExpire time.Duration) (session *model.Session, accessToken, refreshToken string, err error)
|
||||
Revoke(ctx context.Context, sessionID string) error
|
||||
RevokeAllByUser(ctx context.Context, userID string) error
|
||||
}
|
||||
|
||||
type sessionService struct {
|
||||
sessionRepo repository.SessionRepository
|
||||
jwtService JWTService
|
||||
}
|
||||
|
||||
// NewSessionService 创建会话服务。
|
||||
func NewSessionService(sessionRepo repository.SessionRepository, jwtService JWTService) SessionService {
|
||||
return &sessionService{
|
||||
sessionRepo: sessionRepo,
|
||||
jwtService: jwtService,
|
||||
}
|
||||
}
|
||||
|
||||
// hashRefreshToken 对 refresh token 明文做 SHA256 哈希。
|
||||
//
|
||||
// 不使用 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)
|
||||
}
|
||||
@@ -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 搜索用户
|
||||
|
||||
54
internal/wire/identity_provider.go
Normal file
54
internal/wire/identity_provider.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package wire
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"with_you/internal/middleware"
|
||||
"with_you/internal/model"
|
||||
"with_you/internal/repository"
|
||||
"with_you/internal/service"
|
||||
)
|
||||
|
||||
// ProvideIdentityProvider 提供认证管道依赖的 IdentityProvider 实现。
|
||||
//
|
||||
// 把 UserService + CasbinService + SessionRepository 组合为中间件所需的查询接口,
|
||||
// 避免中间件直接依赖具体 service 三个包,便于测试以 fake 注入。
|
||||
func ProvideIdentityProvider(
|
||||
userService service.UserService,
|
||||
casbinService service.CasbinService,
|
||||
sessionRepo repository.SessionRepository,
|
||||
) middleware.IdentityProvider {
|
||||
return &identityProviderImpl{
|
||||
userService: userService,
|
||||
casbinService: casbinService,
|
||||
sessionRepo: sessionRepo,
|
||||
}
|
||||
}
|
||||
|
||||
type identityProviderImpl struct {
|
||||
userService service.UserService
|
||||
casbinService service.CasbinService
|
||||
sessionRepo repository.SessionRepository
|
||||
}
|
||||
|
||||
func (i *identityProviderImpl) GetUserByID(ctx context.Context, userID string) (*model.User, error) {
|
||||
return i.userService.GetUserByID(ctx, userID)
|
||||
}
|
||||
|
||||
func (i *identityProviderImpl) GetRolesForUser(ctx context.Context, userID string) ([]string, error) {
|
||||
roles, err := i.casbinService.GetRolesForUser(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if roles == nil {
|
||||
roles = []string{}
|
||||
}
|
||||
return roles, nil
|
||||
}
|
||||
|
||||
func (i *identityProviderImpl) GetSessionByID(ctx context.Context, sessionID string) (*model.Session, error) {
|
||||
if sessionID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return i.sessionRepo.GetByID(ctx, sessionID)
|
||||
}
|
||||
@@ -10,4 +10,7 @@ var AllSet = wire.NewSet(
|
||||
ServiceSet,
|
||||
HandlerSet,
|
||||
GRPCSet,
|
||||
|
||||
// 认证管道依赖的身份提供者
|
||||
ProvideIdentityProvider,
|
||||
)
|
||||
|
||||
@@ -58,6 +58,9 @@ var RepositorySet = wire.NewSet(
|
||||
|
||||
// 文件上传记录(聊天文件 TTL 过期清理)
|
||||
repository.NewUploadedFileRepository,
|
||||
|
||||
// 会话(令牌撤销支持)
|
||||
repository.NewSessionRepository,
|
||||
)
|
||||
|
||||
// ProvideUserActivityRepository 提供用户活跃数据仓储
|
||||
|
||||
@@ -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 提供管理端帖子服务
|
||||
|
||||
Reference in New Issue
Block a user