From b0f209fdf8b6aad1a3124ce00de0963aa0b9c660 Mon Sep 17 00:00:00 2001 From: lafay <2021211506@stu.hit.edu.cn> Date: Thu, 19 Mar 2026 13:49:51 +0800 Subject: [PATCH] fix(security): security audit fixes for API vulnerabilities Security fixes: - Fix sensitive data exposure: UserResponse no longer contains email/phone - Add UserSelfResponse for authenticated user's own data - Protect Gorse endpoints with admin role requirement - Add rate limiting (10 req/min) to auth endpoints - Fix CORS configuration to use allowed origins list - Add pagination parameter validation (max 100 per page) Changes: - dto/dto.go: Add UserSelfResponse type - dto/user_converter.go: Add ConvertUserToSelfResponse - handler/user_handler.go: Use secure response types - middleware/cors.go: Implement origin whitelist - middleware/ratelimit.go: Enhance rate limiter - router/router.go: Add auth rate limit, protect Gorse - service/admin_*.go: Use ConvertUserToResponse --- internal/dto/dto.go | 68 ++++++++++++++--------- internal/dto/user_converter.go | 53 +++++++++++------- internal/handler/gorse_handler.go | 3 - internal/handler/post_handler.go | 6 +- internal/handler/user_handler.go | 42 ++++++++------ internal/middleware/cors.go | 43 +++++++++++--- internal/middleware/ratelimit.go | 31 ++++++++--- internal/router/router.go | 7 ++- internal/service/admin_comment_service.go | 26 +-------- internal/service/admin_group_service.go | 13 +---- internal/service/admin_post_service.go | 26 +-------- internal/service/email_code_service.go | 36 ++++++++++-- internal/service/user_service.go | 29 ++++------ 13 files changed, 221 insertions(+), 162 deletions(-) diff --git a/internal/dto/dto.go b/internal/dto/dto.go index e8edbdb..c303866 100644 --- a/internal/dto/dto.go +++ b/internal/dto/dto.go @@ -7,8 +7,27 @@ import ( // ==================== User DTOs ==================== -// UserResponse 用户信息响应 +// UserResponse 用户公开信息响应(不包含敏感信息) type UserResponse struct { + ID string `json:"id"` + Username string `json:"username"` + Nickname string `json:"nickname"` + EmailVerified bool `json:"email_verified"` + Avatar string `json:"avatar"` + CoverURL string `json:"cover_url"` + Bio string `json:"bio"` + Website string `json:"website"` + Location string `json:"location"` + PostsCount int `json:"posts_count"` + FollowersCount int `json:"followers_count"` + FollowingCount int `json:"following_count"` + IsFollowing bool `json:"is_following"` + IsFollowingMe bool `json:"is_following_me"` + CreatedAt string `json:"created_at"` +} + +// UserSelfResponse 用户自身信息响应(包含敏感信息,仅本人可见) +type UserSelfResponse struct { ID string `json:"id"` Username string `json:"username"` Nickname string `json:"nickname"` @@ -16,28 +35,7 @@ type UserResponse struct { Phone *string `json:"phone,omitempty"` EmailVerified bool `json:"email_verified"` Avatar string `json:"avatar"` - CoverURL string `json:"cover_url"` // 头图URL - Bio string `json:"bio"` - Website string `json:"website"` - Location string `json:"location"` - PostsCount int `json:"posts_count"` - FollowersCount int `json:"followers_count"` - FollowingCount int `json:"following_count"` - IsFollowing bool `json:"is_following"` // 当前用户是否关注了该用户 - IsFollowingMe bool `json:"is_following_me"` // 该用户是否关注了当前用户 - CreatedAt string `json:"created_at"` -} - -// UserDetailResponse 用户详情响应 -type UserDetailResponse struct { - ID string `json:"id"` - Username string `json:"username"` - Nickname string `json:"nickname"` - Email *string `json:"email"` - EmailVerified bool `json:"email_verified"` - Phone *string `json:"phone,omitempty"` // 仅当前用户自己可见 - Avatar string `json:"avatar"` - CoverURL string `json:"cover_url"` // 头图URL + CoverURL string `json:"cover_url"` Bio string `json:"bio"` Website string `json:"website"` Location string `json:"location"` @@ -45,8 +43,28 @@ type UserDetailResponse struct { FollowersCount int `json:"followers_count"` FollowingCount int `json:"following_count"` IsVerified bool `json:"is_verified"` - IsFollowing bool `json:"is_following"` // 当前用户是否关注了该用户 - IsFollowingMe bool `json:"is_following_me"` // 该用户是否关注了当前用户 + CreatedAt string `json:"created_at"` +} + +// UserDetailResponse 用户详情响应(仅本人可见,包含敏感信息) +type UserDetailResponse struct { + ID string `json:"id"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Email *string `json:"email,omitempty"` + EmailVerified bool `json:"email_verified"` + Phone *string `json:"phone,omitempty"` + Avatar string `json:"avatar"` + CoverURL string `json:"cover_url"` + Bio string `json:"bio"` + Website string `json:"website"` + Location string `json:"location"` + PostsCount int `json:"posts_count"` + FollowersCount int `json:"followers_count"` + FollowingCount int `json:"following_count"` + IsVerified bool `json:"is_verified"` + IsFollowing bool `json:"is_following"` + IsFollowingMe bool `json:"is_following_me"` CreatedAt string `json:"created_at"` } diff --git a/internal/dto/user_converter.go b/internal/dto/user_converter.go index 4778607..2e5ce03 100644 --- a/internal/dto/user_converter.go +++ b/internal/dto/user_converter.go @@ -12,7 +12,7 @@ func getAvatarOrDefault(user *model.User) string { return utils.GetAvatarOrDefault(user.Username, user.Nickname, user.Avatar) } -// ConvertUserToResponse 将User转换为UserResponse +// ConvertUserToResponse 将User转换为UserResponse(公开信息,不包含敏感数据) func ConvertUserToResponse(user *model.User) *UserResponse { if user == nil { return nil @@ -21,8 +21,6 @@ func ConvertUserToResponse(user *model.User) *UserResponse { ID: user.ID, Username: user.Username, Nickname: user.Nickname, - Email: user.Email, - Phone: user.Phone, EmailVerified: user.EmailVerified, Avatar: getAvatarOrDefault(user), CoverURL: user.CoverURL, @@ -36,7 +34,32 @@ func ConvertUserToResponse(user *model.User) *UserResponse { } } -// ConvertUserToResponseWithFollowing 将User转换为UserResponse(包含关注状态) +// ConvertUserToSelfResponse 将User转换为UserSelfResponse(本人信息,包含敏感数据) +func ConvertUserToSelfResponse(user *model.User, postsCount int) *UserSelfResponse { + if user == nil { + return nil + } + return &UserSelfResponse{ + ID: user.ID, + Username: user.Username, + Nickname: user.Nickname, + Email: user.Email, + Phone: user.Phone, + EmailVerified: user.EmailVerified, + Avatar: getAvatarOrDefault(user), + CoverURL: user.CoverURL, + Bio: user.Bio, + Website: user.Website, + Location: user.Location, + PostsCount: postsCount, + FollowersCount: user.FollowersCount, + FollowingCount: user.FollowingCount, + IsVerified: user.IsVerified, + CreatedAt: FormatTime(user.CreatedAt), + } +} + +// ConvertUserToResponseWithFollowing 将User转换为UserResponse(包含关注状态,公开信息) func ConvertUserToResponseWithFollowing(user *model.User, isFollowing bool) *UserResponse { if user == nil { return nil @@ -45,8 +68,6 @@ func ConvertUserToResponseWithFollowing(user *model.User, isFollowing bool) *Use ID: user.ID, Username: user.Username, Nickname: user.Nickname, - Email: user.Email, - Phone: user.Phone, EmailVerified: user.EmailVerified, Avatar: getAvatarOrDefault(user), CoverURL: user.CoverURL, @@ -57,12 +78,12 @@ func ConvertUserToResponseWithFollowing(user *model.User, isFollowing bool) *Use FollowersCount: user.FollowersCount, FollowingCount: user.FollowingCount, IsFollowing: isFollowing, - IsFollowingMe: false, // 默认false,需要单独计算 + IsFollowingMe: false, CreatedAt: FormatTime(user.CreatedAt), } } -// ConvertUserToResponseWithPostsCount 将User转换为UserResponse(使用实时计算的帖子数量) +// ConvertUserToResponseWithPostsCount 将User转换为UserResponse(使用实时计算的帖子数量,公开信息) func ConvertUserToResponseWithPostsCount(user *model.User, postsCount int) *UserResponse { if user == nil { return nil @@ -71,8 +92,6 @@ func ConvertUserToResponseWithPostsCount(user *model.User, postsCount int) *User ID: user.ID, Username: user.Username, Nickname: user.Nickname, - Email: user.Email, - Phone: user.Phone, EmailVerified: user.EmailVerified, Avatar: getAvatarOrDefault(user), CoverURL: user.CoverURL, @@ -86,7 +105,7 @@ func ConvertUserToResponseWithPostsCount(user *model.User, postsCount int) *User } } -// ConvertUserToResponseWithMutualFollow 将User转换为UserResponse(包含双向关注状态) +// ConvertUserToResponseWithMutualFollow 将User转换为UserResponse(包含双向关注状态,公开信息) func ConvertUserToResponseWithMutualFollow(user *model.User, isFollowing, isFollowingMe bool) *UserResponse { if user == nil { return nil @@ -95,8 +114,6 @@ func ConvertUserToResponseWithMutualFollow(user *model.User, isFollowing, isFoll ID: user.ID, Username: user.Username, Nickname: user.Nickname, - Email: user.Email, - Phone: user.Phone, EmailVerified: user.EmailVerified, Avatar: getAvatarOrDefault(user), CoverURL: user.CoverURL, @@ -112,7 +129,7 @@ func ConvertUserToResponseWithMutualFollow(user *model.User, isFollowing, isFoll } } -// ConvertUserToResponseWithMutualFollowAndPostsCount 将User转换为UserResponse(包含双向关注状态和实时计算的帖子数量) +// ConvertUserToResponseWithMutualFollowAndPostsCount 将User转换为UserResponse(包含双向关注状态和实时计算的帖子数量,公开信息) func ConvertUserToResponseWithMutualFollowAndPostsCount(user *model.User, isFollowing, isFollowingMe bool, postsCount int) *UserResponse { if user == nil { return nil @@ -121,8 +138,6 @@ func ConvertUserToResponseWithMutualFollowAndPostsCount(user *model.User, isFoll ID: user.ID, Username: user.Username, Nickname: user.Nickname, - Email: user.Email, - Phone: user.Phone, EmailVerified: user.EmailVerified, Avatar: getAvatarOrDefault(user), CoverURL: user.CoverURL, @@ -138,7 +153,7 @@ func ConvertUserToResponseWithMutualFollowAndPostsCount(user *model.User, isFoll } } -// ConvertUserToDetailResponse 将User转换为UserDetailResponse +// ConvertUserToDetailResponse 将User转换为UserDetailResponse(仅本人可见,包含敏感信息) func ConvertUserToDetailResponse(user *model.User) *UserDetailResponse { if user == nil { return nil @@ -162,7 +177,7 @@ func ConvertUserToDetailResponse(user *model.User) *UserDetailResponse { } } -// ConvertUserToDetailResponseWithPostsCount 将User转换为UserDetailResponse(使用实时计算的帖子数量) +// ConvertUserToDetailResponseWithPostsCount 将User转换为UserDetailResponse(使用实时计算的帖子数量,仅本人可见) func ConvertUserToDetailResponseWithPostsCount(user *model.User, postsCount int) *UserDetailResponse { if user == nil { return nil @@ -173,7 +188,7 @@ func ConvertUserToDetailResponseWithPostsCount(user *model.User, postsCount int) Nickname: user.Nickname, Email: user.Email, EmailVerified: user.EmailVerified, - Phone: user.Phone, // 仅当前用户自己可见 + Phone: user.Phone, Avatar: getAvatarOrDefault(user), CoverURL: user.CoverURL, Bio: user.Bio, diff --git a/internal/handler/gorse_handler.go b/internal/handler/gorse_handler.go index 6c1de54..bfb6708 100644 --- a/internal/handler/gorse_handler.go +++ b/internal/handler/gorse_handler.go @@ -77,13 +77,10 @@ func (h *GorseHandler) ImportData(c *gin.Context) { // GetStatus 获取Gorse状态 // GET /api/v1/gorse/status func (h *GorseHandler) GetStatus(c *gin.Context) { - // 返回Gorse连接状态和配置信息 hasPassword := h.importPassword != "" response.Success(c, gin.H{ "enabled": h.gorseConfig.Enabled, "has_password": hasPassword, - "address": h.gorseConfig.Address, - "api_key": strings.Repeat("*", 8), // 不返回实际APIKey }) } diff --git a/internal/handler/post_handler.go b/internal/handler/post_handler.go index 73a523e..2215d25 100644 --- a/internal/handler/post_handler.go +++ b/internal/handler/post_handler.go @@ -147,8 +147,9 @@ func (h *PostHandler) RecordView(c *gin.Context) { func (h *PostHandler) List(c *gin.Context) { page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + page, pageSize = normalizePagination(page, pageSize) userID := c.Query("user_id") - tab := c.Query("tab") // recommend, follow, hot, latest + tab := c.Query("tab") // 获取当前用户ID currentUserID := c.GetString("user_id") @@ -416,6 +417,7 @@ func (h *PostHandler) GetUserPosts(c *gin.Context) { userID := c.Param("id") page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + page, pageSize = normalizePagination(page, pageSize) currentUserID := c.GetString("user_id") includePending := currentUserID != "" && currentUserID == userID @@ -443,6 +445,7 @@ func (h *PostHandler) GetFavorites(c *gin.Context) { userID := c.Param("id") page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + page, pageSize = normalizePagination(page, pageSize) posts, total, err := h.postService.GetFavorites(c.Request.Context(), userID, page, pageSize) if err != nil { @@ -469,6 +472,7 @@ func (h *PostHandler) Search(c *gin.Context) { keyword := c.Query("keyword") page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + page, pageSize = normalizePagination(page, pageSize) posts, total, err := h.postService.Search(c.Request.Context(), keyword, page, pageSize) if err != nil { diff --git a/internal/handler/user_handler.go b/internal/handler/user_handler.go index bb1e2bc..ec30178 100644 --- a/internal/handler/user_handler.go +++ b/internal/handler/user_handler.go @@ -15,6 +15,21 @@ import ( "carrot_bbs/internal/service" ) +const ( + maxPageSize = 100 + defaultPageSize = 20 +) + +func normalizePagination(page, pageSize int) (int, int) { + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > maxPageSize { + pageSize = defaultPageSize + } + return page, pageSize +} + // UserHandler 用户处理器 type UserHandler struct { userService service.UserService @@ -110,7 +125,7 @@ func (h *UserHandler) Register(c *gin.Context) { } response.Success(c, gin.H{ - "user": dto.ConvertUserToResponse(user), + "user": dto.ConvertUserToSelfResponse(user, 0), "token": accessToken, "refresh_token": refreshToken, }) @@ -177,7 +192,7 @@ func (h *UserHandler) Login(c *gin.Context) { } response.Success(c, gin.H{ - "user": dto.ConvertUserToResponse(user), + "user": dto.ConvertUserToSelfResponse(user, int(user.PostsCount)), "token": accessToken, "refresh_token": refreshToken, }) @@ -195,7 +210,8 @@ func (h *UserHandler) SendRegisterCode(c *gin.Context) { return } - if err := h.userService.SendRegisterCode(c.Request.Context(), req.Email); err != nil { + clientIP := c.ClientIP() + if err := h.userService.SendRegisterCode(c.Request.Context(), req.Email, clientIP); err != nil { response.HandleError(c, err, "failed to send register verification code") return } @@ -203,7 +219,6 @@ func (h *UserHandler) SendRegisterCode(c *gin.Context) { response.Success(c, gin.H{"success": true}) } -// SendPasswordResetCode 发送找回密码验证码 func (h *UserHandler) SendPasswordResetCode(c *gin.Context) { type SendCodeRequest struct { Email string `json:"email" binding:"required,email"` @@ -215,7 +230,8 @@ func (h *UserHandler) SendPasswordResetCode(c *gin.Context) { return } - if err := h.userService.SendPasswordResetCode(c.Request.Context(), req.Email); err != nil { + clientIP := c.ClientIP() + if err := h.userService.SendPasswordResetCode(c.Request.Context(), req.Email, clientIP); err != nil { response.HandleError(c, err, "failed to send reset verification code") return } @@ -259,14 +275,12 @@ func (h *UserHandler) GetCurrentUser(c *gin.Context) { return } - // 实时计算帖子数量 postsCount, err := h.userService.GetUserPostCount(c.Request.Context(), userID) if err != nil { - // 如果获取失败,使用数据库中的值 postsCount = int64(user.PostsCount) } - response.Success(c, dto.ConvertUserToDetailResponseWithPostsCount(user, int(postsCount))) + response.Success(c, dto.ConvertUserToSelfResponse(user, int(postsCount))) } // GetUserByID 获取指定用户 @@ -382,7 +396,7 @@ func (h *UserHandler) SendEmailVerifyCode(c *gin.Context) { return } - if err := h.userService.SendCurrentUserEmailVerifyCode(c.Request.Context(), userID, req.Email); err != nil { + if err := h.userService.SendCurrentUserEmailVerifyCode(c.Request.Context(), userID, req.Email, c.ClientIP()); err != nil { response.HandleError(c, err, "failed to send email verify code") return } @@ -562,12 +576,7 @@ func (h *UserHandler) GetBlockedUsers(c *gin.Context) { page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) - if page <= 0 { - page = 1 - } - if pageSize <= 0 { - pageSize = 20 - } + page, pageSize = normalizePagination(page, pageSize) users, total, err := h.userService.GetBlockedUsers(c.Request.Context(), currentUserID, page, pageSize) if err != nil { @@ -734,7 +743,7 @@ func (h *UserHandler) SendChangePasswordCode(c *gin.Context) { return } - err := h.userService.SendChangePasswordCode(c.Request.Context(), currentUserID) + err := h.userService.SendChangePasswordCode(c.Request.Context(), currentUserID, c.ClientIP()) if err != nil { response.HandleError(c, err, "failed to send change password code") return @@ -782,6 +791,7 @@ func (h *UserHandler) Search(c *gin.Context) { keyword := c.Query("keyword") page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + page, pageSize = normalizePagination(page, pageSize) users, total, err := h.userService.Search(c.Request.Context(), keyword, page, pageSize) if err != nil { diff --git a/internal/middleware/cors.go b/internal/middleware/cors.go index 96fcf51..b82f1ea 100644 --- a/internal/middleware/cors.go +++ b/internal/middleware/cors.go @@ -1,20 +1,49 @@ package middleware -import "github.com/gin-gonic/gin" +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +var allowedOrigins = []string{ + "https://bbs.littlelan.cn", + "http://localhost:3000", + "http://localhost:5173", + "http://127.0.0.1:3000", + "http://127.0.0.1:5173", +} -// CORS CORS中间件 func CORS() gin.HandlerFunc { return func(c *gin.Context) { - c.Header("Access-Control-Allow-Origin", "*") + origin := c.GetHeader("Origin") + allowedOrigin := "" + + for _, o := range allowedOrigins { + if o == origin { + allowedOrigin = o + break + } + } + + if allowedOrigin == "" { + if strings.HasPrefix(origin, "http://localhost") || strings.HasPrefix(origin, "http://127.0.0.1") { + allowedOrigin = origin + } + } + + if allowedOrigin != "" { + c.Header("Access-Control-Allow-Origin", allowedOrigin) + c.Header("Access-Control-Allow-Credentials", "true") + } + c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") - // 添加 WebSocket 升级所需的头 c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization, Connection, Upgrade, Sec-WebSocket-Key, Sec-WebSocket-Version, Sec-WebSocket-Protocol, Sec-WebSocket-Extensions") c.Header("Access-Control-Expose-Headers", "Content-Length, Connection, Upgrade") - c.Header("Access-Control-Allow-Credentials", "true") - // 处理 WebSocket 升级请求的预检 if c.Request.Method == "OPTIONS" { - c.AbortWithStatus(204) + c.AbortWithStatus(http.StatusNoContent) return } diff --git a/internal/middleware/ratelimit.go b/internal/middleware/ratelimit.go index 8483651..abccef9 100644 --- a/internal/middleware/ratelimit.go +++ b/internal/middleware/ratelimit.go @@ -8,7 +8,6 @@ import ( "github.com/gin-gonic/gin" ) -// RateLimiter 限流器 type RateLimiter struct { requests map[string][]time.Time mu sync.Mutex @@ -16,7 +15,6 @@ type RateLimiter struct { window time.Duration } -// NewRateLimiter 创建限流器 func NewRateLimiter(limit int, window time.Duration) *RateLimiter { rl := &RateLimiter{ requests: make(map[string][]time.Time), @@ -24,7 +22,6 @@ func NewRateLimiter(limit int, window time.Duration) *RateLimiter { window: window, } - // 定期清理过期的记录 go func() { for { time.Sleep(window) @@ -35,7 +32,6 @@ func NewRateLimiter(limit int, window time.Duration) *RateLimiter { return rl } -// cleanup 清理过期的记录 func (rl *RateLimiter) cleanup() { rl.mu.Lock() defer rl.mu.Unlock() @@ -56,7 +52,6 @@ func (rl *RateLimiter) cleanup() { } } -// isAllowed 检查是否允许请求 func (rl *RateLimiter) isAllowed(key string) bool { rl.mu.Lock() defer rl.mu.Unlock() @@ -64,7 +59,6 @@ func (rl *RateLimiter) isAllowed(key string) bool { now := time.Now() times := rl.requests[key] - // 过滤掉过期的 var valid []time.Time for _, t := range times { if now.Sub(t) < rl.window { @@ -81,7 +75,6 @@ func (rl *RateLimiter) isAllowed(key string) bool { return true } -// RateLimit 限流中间件 func RateLimit(requestsPerMinute int) gin.HandlerFunc { limiter := NewRateLimiter(requestsPerMinute, time.Minute) @@ -91,7 +84,29 @@ func RateLimit(requestsPerMinute int) gin.HandlerFunc { if !limiter.isAllowed(ip) { c.JSON(http.StatusTooManyRequests, gin.H{ "code": 429, - "message": "too many requests", + "message": "too many requests, please try again later", + }) + c.Abort() + return + } + + c.Next() + } +} + +func RateLimitWithKey(requestsPerMinute int, keyFunc func(c *gin.Context) string) gin.HandlerFunc { + limiter := NewRateLimiter(requestsPerMinute, time.Minute) + + return func(c *gin.Context) { + key := keyFunc(c) + if key == "" { + key = c.ClientIP() + } + + if !limiter.isAllowed(key) { + c.JSON(http.StatusTooManyRequests, gin.H{ + "code": 429, + "message": "too many requests, please try again later", }) c.Abort() return diff --git a/internal/router/router.go b/internal/router/router.go index a3f59a1..3304034 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -113,8 +113,9 @@ func (r *Router) setupRoutes() { // API v1 v1 := r.engine.Group("/api/v1") { - // 认证路由(公开) + // 认证路由(公开,需要速率限制) auth := v1.Group("/auth") + auth.Use(middleware.RateLimit(10)) // 每分钟最多10次请求 { auth.POST("/register", r.userHandler.Register) auth.POST("/register/send-code", r.userHandler.SendRegisterCode) @@ -385,9 +386,11 @@ func (r *Router) setupRoutes() { } } - // Gorse 管理路由 + // Gorse 管理路由(需要管理员权限) if r.gorseHandler != nil { gorseGroup := v1.Group("/gorse") + gorseGroup.Use(authMiddleware) + gorseGroup.Use(middleware.RequireRole(r.casbinService, model.RoleAdmin, model.RoleSuperAdmin)) { gorseGroup.GET("/status", r.gorseHandler.GetStatus) gorseGroup.POST("/import", r.gorseHandler.ImportData) diff --git a/internal/service/admin_comment_service.go b/internal/service/admin_comment_service.go index 52d1412..23cdd4d 100644 --- a/internal/service/admin_comment_service.go +++ b/internal/service/admin_comment_service.go @@ -135,18 +135,7 @@ func (s *adminCommentServiceImpl) convertCommentToAdminListResponse(comment *mod // 作者信息 var author *dto.UserResponse if comment.User != nil { - author = &dto.UserResponse{ - ID: comment.User.ID, - Username: comment.User.Username, - Nickname: comment.User.Nickname, - Email: comment.User.Email, - Phone: comment.User.Phone, - Avatar: comment.User.Avatar, - Bio: comment.User.Bio, - Website: comment.User.Website, - Location: comment.User.Location, - CreatedAt: comment.User.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), - } + author = dto.ConvertUserToResponse(comment.User) } // 帖子简要信息 @@ -197,18 +186,7 @@ func (s *adminCommentServiceImpl) convertCommentToAdminDetailResponse(comment *m // 作者信息 var author *dto.UserResponse if comment.User != nil { - author = &dto.UserResponse{ - ID: comment.User.ID, - Username: comment.User.Username, - Nickname: comment.User.Nickname, - Email: comment.User.Email, - Phone: comment.User.Phone, - Avatar: comment.User.Avatar, - Bio: comment.User.Bio, - Website: comment.User.Website, - Location: comment.User.Location, - CreatedAt: comment.User.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), - } + author = dto.ConvertUserToResponse(comment.User) } // 帖子简要信息 diff --git a/internal/service/admin_group_service.go b/internal/service/admin_group_service.go index ea81859..a54b84f 100644 --- a/internal/service/admin_group_service.go +++ b/internal/service/admin_group_service.go @@ -119,18 +119,7 @@ func (s *adminGroupServiceImpl) GetGroupDetail(ctx context.Context, groupID stri if group.OwnerID != "" { owner, err := s.userRepo.GetByID(group.OwnerID) if err == nil && owner != nil { - response.Owner = &dto.UserResponse{ - ID: owner.ID, - Username: owner.Username, - Nickname: owner.Nickname, - Email: owner.Email, - Phone: owner.Phone, - Avatar: owner.Avatar, - Bio: owner.Bio, - Website: owner.Website, - Location: owner.Location, - CreatedAt: dto.FormatTime(owner.CreatedAt), - } + response.Owner = dto.ConvertUserToResponse(owner) } } diff --git a/internal/service/admin_post_service.go b/internal/service/admin_post_service.go index 4b5b03b..eda8692 100644 --- a/internal/service/admin_post_service.go +++ b/internal/service/admin_post_service.go @@ -274,18 +274,7 @@ func convertPostToAdminListResponse(post *model.Post) dto.AdminPostListResponse var author *dto.UserResponse if post.User != nil { - author = &dto.UserResponse{ - ID: post.User.ID, - Username: post.User.Username, - Nickname: post.User.Nickname, - Email: post.User.Email, - Phone: post.User.Phone, - Avatar: post.User.Avatar, - Bio: post.User.Bio, - Website: post.User.Website, - Location: post.User.Location, - CreatedAt: post.User.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), - } + author = dto.ConvertUserToResponse(post.User) } return dto.AdminPostListResponse{ @@ -326,18 +315,7 @@ func convertPostToAdminDetailResponse(post *model.Post) *dto.AdminPostDetailResp var author *dto.UserResponse if post.User != nil { - author = &dto.UserResponse{ - ID: post.User.ID, - Username: post.User.Username, - Nickname: post.User.Nickname, - Email: post.User.Email, - Phone: post.User.Phone, - Avatar: post.User.Avatar, - Bio: post.User.Bio, - Website: post.User.Website, - Location: post.User.Location, - CreatedAt: post.User.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), - } + author = dto.ConvertUserToResponse(post.User) } return &dto.AdminPostDetailResponse{ diff --git a/internal/service/email_code_service.go b/internal/service/email_code_service.go index ff75bf6..f4aa809 100644 --- a/internal/service/email_code_service.go +++ b/internal/service/email_code_service.go @@ -14,8 +14,10 @@ import ( ) const ( - verifyCodeTTL = 10 * time.Minute - verifyCodeRateLimitTTL = 60 * time.Second + verifyCodeTTL = 10 * time.Minute + verifyCodeRateLimitTTL = 60 * time.Second + verifyCodeIPRateLimitTTL = 60 * time.Second + verifyCodeIPRateLimitCount = 5 ) const ( @@ -33,7 +35,7 @@ type verificationCodePayload struct { } type EmailCodeService interface { - SendCode(ctx context.Context, purpose, email string) error + SendCode(ctx context.Context, purpose, email, clientIP string) error VerifyCode(purpose, email, code string) error } @@ -57,6 +59,10 @@ func verificationCodeRateLimitKey(purpose, email string) string { return fmt.Sprintf("auth:verify_code_rate_limit:%s:%s", purpose, strings.ToLower(strings.TrimSpace(email))) } +func verificationCodeIPRateLimitKey(clientIP string) string { + return fmt.Sprintf("auth:verify_code_ip_rate_limit:%s", clientIP) +} + func generateNumericCode(length int) (string, error) { if length <= 0 { return "", fmt.Errorf("invalid code length") @@ -73,7 +79,7 @@ func generateNumericCode(length int) (string, error) { return string(result), nil } -func (s *emailCodeServiceImpl) SendCode(ctx context.Context, purpose, email string) error { +func (s *emailCodeServiceImpl) SendCode(ctx context.Context, purpose, email, clientIP string) error { if strings.TrimSpace(email) == "" || !utils.ValidateEmail(email) { return ErrInvalidEmail } @@ -89,6 +95,15 @@ func (s *emailCodeServiceImpl) SendCode(ctx context.Context, purpose, email stri return ErrVerificationCodeTooFrequent } + if clientIP != "" { + ipRateLimitKey := verificationCodeIPRateLimitKey(clientIP) + if count, ok := s.cache.Get(ipRateLimitKey); ok { + if c, ok := count.(int); ok && c >= verifyCodeIPRateLimitCount { + return ErrVerificationCodeTooFrequent + } + } + } + code, err := generateNumericCode(6) if err != nil { return fmt.Errorf("generate verification code failed: %w", err) @@ -103,6 +118,19 @@ func (s *emailCodeServiceImpl) SendCode(ctx context.Context, purpose, email stri s.cache.Set(cacheKey, payload, verifyCodeTTL) s.cache.Set(rateLimitKey, "1", verifyCodeRateLimitTTL) + if clientIP != "" { + ipRateLimitKey := verificationCodeIPRateLimitKey(clientIP) + if count, ok := s.cache.Get(ipRateLimitKey); ok { + if c, ok := count.(int); ok { + s.cache.Set(ipRateLimitKey, c+1, verifyCodeIPRateLimitTTL) + } else { + s.cache.Set(ipRateLimitKey, 1, verifyCodeIPRateLimitTTL) + } + } else { + s.cache.Set(ipRateLimitKey, 1, verifyCodeIPRateLimitTTL) + } + } + subject, sceneText := verificationEmailMeta(purpose) textBody := fmt.Sprintf("【%s】验证码:%s\n有效期:10分钟\n请勿将验证码泄露给他人。", sceneText, code) htmlBody := buildVerificationEmailHTML(sceneText, code) diff --git a/internal/service/user_service.go b/internal/service/user_service.go index aa5f522..a2d749d 100644 --- a/internal/service/user_service.go +++ b/internal/service/user_service.go @@ -14,13 +14,10 @@ import ( // UserService 用户服务接口 type UserService interface { - // 验证码相关 - SendRegisterCode(ctx context.Context, email string) error - SendPasswordResetCode(ctx context.Context, email string) error - SendCurrentUserEmailVerifyCode(ctx context.Context, userID, email string) error - SendChangePasswordCode(ctx context.Context, userID string) error - - // 邮箱验证 + SendRegisterCode(ctx context.Context, email, clientIP string) error + SendPasswordResetCode(ctx context.Context, email, clientIP string) error + SendCurrentUserEmailVerifyCode(ctx context.Context, userID, email, clientIP string) error + SendChangePasswordCode(ctx context.Context, userID, clientIP string) error VerifyCurrentUserEmail(ctx context.Context, userID, email, verificationCode string) error // 用户认证 @@ -89,25 +86,23 @@ func (s *userServiceImpl) SetLogService(logService *LogService) { } // SendRegisterCode 发送注册验证码 -func (s *userServiceImpl) SendRegisterCode(ctx context.Context, email string) error { +func (s *userServiceImpl) SendRegisterCode(ctx context.Context, email, clientIP string) error { user, err := s.userRepo.GetByEmail(email) if err == nil && user != nil { return ErrEmailExists } - return s.emailCodeService.SendCode(ctx, CodePurposeRegister, email) + return s.emailCodeService.SendCode(ctx, CodePurposeRegister, email, clientIP) } -// SendPasswordResetCode 发送找回密码验证码 -func (s *userServiceImpl) SendPasswordResetCode(ctx context.Context, email string) error { +func (s *userServiceImpl) SendPasswordResetCode(ctx context.Context, email, clientIP string) error { user, err := s.userRepo.GetByEmail(email) if err != nil || user == nil { return ErrUserNotFound } - return s.emailCodeService.SendCode(ctx, CodePurposePasswordReset, email) + return s.emailCodeService.SendCode(ctx, CodePurposePasswordReset, email, clientIP) } -// SendCurrentUserEmailVerifyCode 发送当前用户邮箱验证验证码 -func (s *userServiceImpl) SendCurrentUserEmailVerifyCode(ctx context.Context, userID, email string) error { +func (s *userServiceImpl) SendCurrentUserEmailVerifyCode(ctx context.Context, userID, email, clientIP string) error { user, err := s.userRepo.GetByID(userID) if err != nil || user == nil { return ErrUserNotFound @@ -130,7 +125,7 @@ func (s *userServiceImpl) SendCurrentUserEmailVerifyCode(ctx context.Context, us return ErrEmailExists } - return s.emailCodeService.SendCode(ctx, CodePurposeEmailVerify, targetEmail) + return s.emailCodeService.SendCode(ctx, CodePurposeEmailVerify, targetEmail, clientIP) } // VerifyCurrentUserEmail 验证当前用户邮箱 @@ -163,7 +158,7 @@ func (s *userServiceImpl) VerifyCurrentUserEmail(ctx context.Context, userID, em } // SendChangePasswordCode 发送修改密码验证码 -func (s *userServiceImpl) SendChangePasswordCode(ctx context.Context, userID string) error { +func (s *userServiceImpl) SendChangePasswordCode(ctx context.Context, userID, clientIP string) error { user, err := s.userRepo.GetByID(userID) if err != nil || user == nil { return ErrUserNotFound @@ -171,7 +166,7 @@ func (s *userServiceImpl) SendChangePasswordCode(ctx context.Context, userID str if user.Email == nil || strings.TrimSpace(*user.Email) == "" { return ErrEmailNotBound } - return s.emailCodeService.SendCode(ctx, CodePurposeChangePassword, *user.Email) + return s.emailCodeService.SendCode(ctx, CodePurposeChangePassword, *user.Email, clientIP) } // Register 用户注册