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:
@@ -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 {
|
||||
|
||||
@@ -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 搜索用户
|
||||
|
||||
Reference in New Issue
Block a user