Files
backend/internal/service/admin_user_service.go
lafay eb931bf1c6
All checks were successful
Build Backend / build (push) Successful in 2m19s
Build Backend / build-docker (push) Successful in 46s
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
2026-07-05 18:28:08 +08:00

177 lines
5.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"
)
// AdminUserService 管理端用户服务接口
type AdminUserService interface {
// GetUserList 获取用户列表(分页、搜索、状态筛选)
GetUserList(ctx context.Context, page, pageSize int, keyword, status string) ([]dto.AdminUserListResponse, int64, error)
// GetUserDetail 获取用户详情(含统计信息)
GetUserDetail(ctx context.Context, userID string) (*dto.AdminUserDetailResponse, error)
// UpdateUserStatus 更新用户状态
UpdateUserStatus(ctx context.Context, userID string, status model.UserStatus) (*dto.AdminUserDetailResponse, error)
}
// adminUserServiceImpl 管理端用户服务实现
//
// sessionSvc 与 cache 用于在封禁/停用用户时撤销其全部登录会话并失效 Principal 缓存,
// 让 RequireAuth 在下一次请求时重新加载用户状态(避免 30s 缓存窗口内仍可访问)。
type adminUserServiceImpl struct {
userRepo repository.UserRepository
sessionSvc SessionService
cache cache.Cache
}
// NewAdminUserService 创建管理端用户服务
func NewAdminUserService(userRepo repository.UserRepository, sessionSvc SessionService, cache cache.Cache) AdminUserService {
return &adminUserServiceImpl{
userRepo: userRepo,
sessionSvc: sessionSvc,
cache: cache,
}
}
// GetUserList 获取用户列表
func (s *adminUserServiceImpl) GetUserList(ctx context.Context, page, pageSize int, keyword, status string) ([]dto.AdminUserListResponse, int64, error) {
users, total, err := s.userRepo.AdminList(page, pageSize, keyword, status)
if err != nil {
return nil, 0, err
}
responses := make([]dto.AdminUserListResponse, len(users))
for i, user := range users {
responses[i] = convertUserToAdminListResponse(user)
}
return responses, total, nil
}
// GetUserDetail 获取用户详情
func (s *adminUserServiceImpl) GetUserDetail(ctx context.Context, userID string) (*dto.AdminUserDetailResponse, error) {
user, err := s.userRepo.GetByID(userID)
if err != nil {
return nil, err
}
// 获取帖子数
postsCount, _ := s.userRepo.GetPostsCount(userID)
// 获取评论数
commentsCount, _ := s.userRepo.GetCommentsCount(userID)
return convertUserToAdminDetailResponse(user, postsCount, commentsCount), nil
}
// UpdateUserStatus 更新用户状态
func (s *adminUserServiceImpl) UpdateUserStatus(ctx context.Context, userID string, status model.UserStatus) (*dto.AdminUserDetailResponse, error) {
// 先检查用户是否存在
user, err := s.userRepo.GetByID(userID)
if err != nil {
return nil, err
}
// 更新状态
if err := s.userRepo.UpdateStatus(userID, status); err != nil {
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 {
return nil, err
}
// 获取统计信息
postsCount, _ := s.userRepo.GetPostsCount(userID)
commentsCount, _ := s.userRepo.GetCommentsCount(userID)
return convertUserToAdminDetailResponse(user, postsCount, commentsCount), nil
}
// convertUserToAdminListResponse 转换用户为管理端列表响应
func convertUserToAdminListResponse(user *model.User) dto.AdminUserListResponse {
var lastLoginAt string
if user.LastLoginAt != nil {
lastLoginAt = user.LastLoginAt.Format("2006-01-02 15:04:05")
}
return dto.AdminUserListResponse{
ID: user.ID,
Username: user.Username,
Nickname: user.Nickname,
Email: user.Email,
Phone: user.Phone,
EmailVerified: user.EmailVerified,
Avatar: user.Avatar,
Status: string(user.Status),
PostsCount: user.PostsCount,
FollowersCount: user.FollowersCount,
FollowingCount: user.FollowingCount,
LastLoginAt: lastLoginAt,
LastLoginIP: user.LastLoginIP,
CreatedAt: user.CreatedAt.Format("2006-01-02 15:04:05"),
}
}
// convertUserToAdminDetailResponse 转换用户为管理端详情响应
func convertUserToAdminDetailResponse(user *model.User, postsCount, commentsCount int64) *dto.AdminUserDetailResponse {
var lastLoginAt string
if user.LastLoginAt != nil {
lastLoginAt = user.LastLoginAt.Format("2006-01-02 15:04:05")
}
return &dto.AdminUserDetailResponse{
ID: user.ID,
Username: user.Username,
Nickname: user.Nickname,
Email: user.Email,
Phone: user.Phone,
EmailVerified: user.EmailVerified,
Avatar: user.Avatar,
CoverURL: user.CoverURL,
Bio: user.Bio,
Website: user.Website,
Location: user.Location,
IsVerified: user.IsVerified,
Status: string(user.Status),
PostsCount: int(postsCount),
CommentsCount: commentsCount,
FollowersCount: user.FollowersCount,
FollowingCount: user.FollowingCount,
LastLoginAt: lastLoginAt,
LastLoginIP: user.LastLoginIP,
CreatedAt: user.CreatedAt.Format("2006-01-02 15:04:05"),
UpdatedAt: user.UpdatedAt.Format("2006-01-02 15:04:05"),
}
}