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

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

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

View File

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

View File

@@ -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)
}

View File

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

View File

@@ -91,9 +91,16 @@ type chatServiceImpl struct {
versionLogMaxGap int64
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

View File

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

View File

@@ -70,6 +70,8 @@ type GroupService interface {
LeaveGroup(userID string, groupID string) error
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 游标分页获取群组列表

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

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

View File

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

View File

@@ -7,9 +7,12 @@ import (
"strings"
"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 搜索用户