fix(security): security audit fixes for API vulnerabilities
All checks were successful
Build Backend / build (push) Successful in 4m47s
Build Backend / build-docker (push) Successful in 2m22s

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
This commit is contained in:
lafay
2026-03-19 13:49:51 +08:00
parent d387cc493e
commit b0f209fdf8
13 changed files with 221 additions and 162 deletions

View File

@@ -7,8 +7,27 @@ import (
// ==================== User DTOs ==================== // ==================== User DTOs ====================
// UserResponse 用户信息响应 // UserResponse 用户公开信息响应(不包含敏感信息)
type UserResponse struct { 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"` ID string `json:"id"`
Username string `json:"username"` Username string `json:"username"`
Nickname string `json:"nickname"` Nickname string `json:"nickname"`
@@ -16,28 +35,7 @@ type UserResponse struct {
Phone *string `json:"phone,omitempty"` Phone *string `json:"phone,omitempty"`
EmailVerified bool `json:"email_verified"` EmailVerified bool `json:"email_verified"`
Avatar string `json:"avatar"` 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"`
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
Bio string `json:"bio"` Bio string `json:"bio"`
Website string `json:"website"` Website string `json:"website"`
Location string `json:"location"` Location string `json:"location"`
@@ -45,8 +43,28 @@ type UserDetailResponse struct {
FollowersCount int `json:"followers_count"` FollowersCount int `json:"followers_count"`
FollowingCount int `json:"following_count"` FollowingCount int `json:"following_count"`
IsVerified bool `json:"is_verified"` IsVerified bool `json:"is_verified"`
IsFollowing bool `json:"is_following"` // 当前用户是否关注了该用户 CreatedAt string `json:"created_at"`
IsFollowingMe bool `json:"is_following_me"` // 该用户是否关注了当前用户 }
// 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"` CreatedAt string `json:"created_at"`
} }

View File

@@ -12,7 +12,7 @@ func getAvatarOrDefault(user *model.User) string {
return utils.GetAvatarOrDefault(user.Username, user.Nickname, user.Avatar) return utils.GetAvatarOrDefault(user.Username, user.Nickname, user.Avatar)
} }
// ConvertUserToResponse 将User转换为UserResponse // ConvertUserToResponse 将User转换为UserResponse(公开信息,不包含敏感数据)
func ConvertUserToResponse(user *model.User) *UserResponse { func ConvertUserToResponse(user *model.User) *UserResponse {
if user == nil { if user == nil {
return nil return nil
@@ -21,8 +21,6 @@ func ConvertUserToResponse(user *model.User) *UserResponse {
ID: user.ID, ID: user.ID,
Username: user.Username, Username: user.Username,
Nickname: user.Nickname, Nickname: user.Nickname,
Email: user.Email,
Phone: user.Phone,
EmailVerified: user.EmailVerified, EmailVerified: user.EmailVerified,
Avatar: getAvatarOrDefault(user), Avatar: getAvatarOrDefault(user),
CoverURL: user.CoverURL, 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 { func ConvertUserToResponseWithFollowing(user *model.User, isFollowing bool) *UserResponse {
if user == nil { if user == nil {
return nil return nil
@@ -45,8 +68,6 @@ func ConvertUserToResponseWithFollowing(user *model.User, isFollowing bool) *Use
ID: user.ID, ID: user.ID,
Username: user.Username, Username: user.Username,
Nickname: user.Nickname, Nickname: user.Nickname,
Email: user.Email,
Phone: user.Phone,
EmailVerified: user.EmailVerified, EmailVerified: user.EmailVerified,
Avatar: getAvatarOrDefault(user), Avatar: getAvatarOrDefault(user),
CoverURL: user.CoverURL, CoverURL: user.CoverURL,
@@ -57,12 +78,12 @@ func ConvertUserToResponseWithFollowing(user *model.User, isFollowing bool) *Use
FollowersCount: user.FollowersCount, FollowersCount: user.FollowersCount,
FollowingCount: user.FollowingCount, FollowingCount: user.FollowingCount,
IsFollowing: isFollowing, IsFollowing: isFollowing,
IsFollowingMe: false, // 默认false需要单独计算 IsFollowingMe: false,
CreatedAt: FormatTime(user.CreatedAt), CreatedAt: FormatTime(user.CreatedAt),
} }
} }
// ConvertUserToResponseWithPostsCount 将User转换为UserResponse使用实时计算的帖子数量 // ConvertUserToResponseWithPostsCount 将User转换为UserResponse使用实时计算的帖子数量,公开信息
func ConvertUserToResponseWithPostsCount(user *model.User, postsCount int) *UserResponse { func ConvertUserToResponseWithPostsCount(user *model.User, postsCount int) *UserResponse {
if user == nil { if user == nil {
return nil return nil
@@ -71,8 +92,6 @@ func ConvertUserToResponseWithPostsCount(user *model.User, postsCount int) *User
ID: user.ID, ID: user.ID,
Username: user.Username, Username: user.Username,
Nickname: user.Nickname, Nickname: user.Nickname,
Email: user.Email,
Phone: user.Phone,
EmailVerified: user.EmailVerified, EmailVerified: user.EmailVerified,
Avatar: getAvatarOrDefault(user), Avatar: getAvatarOrDefault(user),
CoverURL: user.CoverURL, 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 { func ConvertUserToResponseWithMutualFollow(user *model.User, isFollowing, isFollowingMe bool) *UserResponse {
if user == nil { if user == nil {
return nil return nil
@@ -95,8 +114,6 @@ func ConvertUserToResponseWithMutualFollow(user *model.User, isFollowing, isFoll
ID: user.ID, ID: user.ID,
Username: user.Username, Username: user.Username,
Nickname: user.Nickname, Nickname: user.Nickname,
Email: user.Email,
Phone: user.Phone,
EmailVerified: user.EmailVerified, EmailVerified: user.EmailVerified,
Avatar: getAvatarOrDefault(user), Avatar: getAvatarOrDefault(user),
CoverURL: user.CoverURL, 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 { func ConvertUserToResponseWithMutualFollowAndPostsCount(user *model.User, isFollowing, isFollowingMe bool, postsCount int) *UserResponse {
if user == nil { if user == nil {
return nil return nil
@@ -121,8 +138,6 @@ func ConvertUserToResponseWithMutualFollowAndPostsCount(user *model.User, isFoll
ID: user.ID, ID: user.ID,
Username: user.Username, Username: user.Username,
Nickname: user.Nickname, Nickname: user.Nickname,
Email: user.Email,
Phone: user.Phone,
EmailVerified: user.EmailVerified, EmailVerified: user.EmailVerified,
Avatar: getAvatarOrDefault(user), Avatar: getAvatarOrDefault(user),
CoverURL: user.CoverURL, 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 { func ConvertUserToDetailResponse(user *model.User) *UserDetailResponse {
if user == nil { if user == nil {
return 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 { func ConvertUserToDetailResponseWithPostsCount(user *model.User, postsCount int) *UserDetailResponse {
if user == nil { if user == nil {
return nil return nil
@@ -173,7 +188,7 @@ func ConvertUserToDetailResponseWithPostsCount(user *model.User, postsCount int)
Nickname: user.Nickname, Nickname: user.Nickname,
Email: user.Email, Email: user.Email,
EmailVerified: user.EmailVerified, EmailVerified: user.EmailVerified,
Phone: user.Phone, // 仅当前用户自己可见 Phone: user.Phone,
Avatar: getAvatarOrDefault(user), Avatar: getAvatarOrDefault(user),
CoverURL: user.CoverURL, CoverURL: user.CoverURL,
Bio: user.Bio, Bio: user.Bio,

View File

@@ -77,13 +77,10 @@ func (h *GorseHandler) ImportData(c *gin.Context) {
// GetStatus 获取Gorse状态 // GetStatus 获取Gorse状态
// GET /api/v1/gorse/status // GET /api/v1/gorse/status
func (h *GorseHandler) GetStatus(c *gin.Context) { func (h *GorseHandler) GetStatus(c *gin.Context) {
// 返回Gorse连接状态和配置信息
hasPassword := h.importPassword != "" hasPassword := h.importPassword != ""
response.Success(c, gin.H{ response.Success(c, gin.H{
"enabled": h.gorseConfig.Enabled, "enabled": h.gorseConfig.Enabled,
"has_password": hasPassword, "has_password": hasPassword,
"address": h.gorseConfig.Address,
"api_key": strings.Repeat("*", 8), // 不返回实际APIKey
}) })
} }

View File

@@ -147,8 +147,9 @@ func (h *PostHandler) RecordView(c *gin.Context) {
func (h *PostHandler) List(c *gin.Context) { func (h *PostHandler) List(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
page, pageSize = normalizePagination(page, pageSize)
userID := c.Query("user_id") userID := c.Query("user_id")
tab := c.Query("tab") // recommend, follow, hot, latest tab := c.Query("tab")
// 获取当前用户ID // 获取当前用户ID
currentUserID := c.GetString("user_id") currentUserID := c.GetString("user_id")
@@ -416,6 +417,7 @@ func (h *PostHandler) GetUserPosts(c *gin.Context) {
userID := c.Param("id") userID := c.Param("id")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
page, pageSize = normalizePagination(page, pageSize)
currentUserID := c.GetString("user_id") currentUserID := c.GetString("user_id")
includePending := currentUserID != "" && currentUserID == userID includePending := currentUserID != "" && currentUserID == userID
@@ -443,6 +445,7 @@ func (h *PostHandler) GetFavorites(c *gin.Context) {
userID := c.Param("id") userID := c.Param("id")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) 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) posts, total, err := h.postService.GetFavorites(c.Request.Context(), userID, page, pageSize)
if err != nil { if err != nil {
@@ -469,6 +472,7 @@ func (h *PostHandler) Search(c *gin.Context) {
keyword := c.Query("keyword") keyword := c.Query("keyword")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) 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) posts, total, err := h.postService.Search(c.Request.Context(), keyword, page, pageSize)
if err != nil { if err != nil {

View File

@@ -15,6 +15,21 @@ import (
"carrot_bbs/internal/service" "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 用户处理器 // UserHandler 用户处理器
type UserHandler struct { type UserHandler struct {
userService service.UserService userService service.UserService
@@ -110,7 +125,7 @@ func (h *UserHandler) Register(c *gin.Context) {
} }
response.Success(c, gin.H{ response.Success(c, gin.H{
"user": dto.ConvertUserToResponse(user), "user": dto.ConvertUserToSelfResponse(user, 0),
"token": accessToken, "token": accessToken,
"refresh_token": refreshToken, "refresh_token": refreshToken,
}) })
@@ -177,7 +192,7 @@ func (h *UserHandler) Login(c *gin.Context) {
} }
response.Success(c, gin.H{ response.Success(c, gin.H{
"user": dto.ConvertUserToResponse(user), "user": dto.ConvertUserToSelfResponse(user, int(user.PostsCount)),
"token": accessToken, "token": accessToken,
"refresh_token": refreshToken, "refresh_token": refreshToken,
}) })
@@ -195,7 +210,8 @@ func (h *UserHandler) SendRegisterCode(c *gin.Context) {
return 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") response.HandleError(c, err, "failed to send register verification code")
return return
} }
@@ -203,7 +219,6 @@ func (h *UserHandler) SendRegisterCode(c *gin.Context) {
response.Success(c, gin.H{"success": true}) response.Success(c, gin.H{"success": true})
} }
// SendPasswordResetCode 发送找回密码验证码
func (h *UserHandler) SendPasswordResetCode(c *gin.Context) { func (h *UserHandler) SendPasswordResetCode(c *gin.Context) {
type SendCodeRequest struct { type SendCodeRequest struct {
Email string `json:"email" binding:"required,email"` Email string `json:"email" binding:"required,email"`
@@ -215,7 +230,8 @@ func (h *UserHandler) SendPasswordResetCode(c *gin.Context) {
return 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") response.HandleError(c, err, "failed to send reset verification code")
return return
} }
@@ -259,14 +275,12 @@ func (h *UserHandler) GetCurrentUser(c *gin.Context) {
return return
} }
// 实时计算帖子数量
postsCount, err := h.userService.GetUserPostCount(c.Request.Context(), userID) postsCount, err := h.userService.GetUserPostCount(c.Request.Context(), userID)
if err != nil { if err != nil {
// 如果获取失败,使用数据库中的值
postsCount = int64(user.PostsCount) postsCount = int64(user.PostsCount)
} }
response.Success(c, dto.ConvertUserToDetailResponseWithPostsCount(user, int(postsCount))) response.Success(c, dto.ConvertUserToSelfResponse(user, int(postsCount)))
} }
// GetUserByID 获取指定用户 // GetUserByID 获取指定用户
@@ -382,7 +396,7 @@ func (h *UserHandler) SendEmailVerifyCode(c *gin.Context) {
return 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") response.HandleError(c, err, "failed to send email verify code")
return return
} }
@@ -562,12 +576,7 @@ func (h *UserHandler) GetBlockedUsers(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
if page <= 0 { page, pageSize = normalizePagination(page, pageSize)
page = 1
}
if pageSize <= 0 {
pageSize = 20
}
users, total, err := h.userService.GetBlockedUsers(c.Request.Context(), currentUserID, page, pageSize) users, total, err := h.userService.GetBlockedUsers(c.Request.Context(), currentUserID, page, pageSize)
if err != nil { if err != nil {
@@ -734,7 +743,7 @@ func (h *UserHandler) SendChangePasswordCode(c *gin.Context) {
return return
} }
err := h.userService.SendChangePasswordCode(c.Request.Context(), currentUserID) err := h.userService.SendChangePasswordCode(c.Request.Context(), currentUserID, c.ClientIP())
if err != nil { if err != nil {
response.HandleError(c, err, "failed to send change password code") response.HandleError(c, err, "failed to send change password code")
return return
@@ -782,6 +791,7 @@ func (h *UserHandler) Search(c *gin.Context) {
keyword := c.Query("keyword") keyword := c.Query("keyword")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) 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) users, total, err := h.userService.Search(c.Request.Context(), keyword, page, pageSize)
if err != nil { if err != nil {

View File

@@ -1,20 +1,49 @@
package middleware 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 { func CORS() gin.HandlerFunc {
return func(c *gin.Context) { 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") 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-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-Expose-Headers", "Content-Length, Connection, Upgrade")
c.Header("Access-Control-Allow-Credentials", "true")
// 处理 WebSocket 升级请求的预检
if c.Request.Method == "OPTIONS" { if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204) c.AbortWithStatus(http.StatusNoContent)
return return
} }

View File

@@ -8,7 +8,6 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// RateLimiter 限流器
type RateLimiter struct { type RateLimiter struct {
requests map[string][]time.Time requests map[string][]time.Time
mu sync.Mutex mu sync.Mutex
@@ -16,7 +15,6 @@ type RateLimiter struct {
window time.Duration window time.Duration
} }
// NewRateLimiter 创建限流器
func NewRateLimiter(limit int, window time.Duration) *RateLimiter { func NewRateLimiter(limit int, window time.Duration) *RateLimiter {
rl := &RateLimiter{ rl := &RateLimiter{
requests: make(map[string][]time.Time), requests: make(map[string][]time.Time),
@@ -24,7 +22,6 @@ func NewRateLimiter(limit int, window time.Duration) *RateLimiter {
window: window, window: window,
} }
// 定期清理过期的记录
go func() { go func() {
for { for {
time.Sleep(window) time.Sleep(window)
@@ -35,7 +32,6 @@ func NewRateLimiter(limit int, window time.Duration) *RateLimiter {
return rl return rl
} }
// cleanup 清理过期的记录
func (rl *RateLimiter) cleanup() { func (rl *RateLimiter) cleanup() {
rl.mu.Lock() rl.mu.Lock()
defer rl.mu.Unlock() defer rl.mu.Unlock()
@@ -56,7 +52,6 @@ func (rl *RateLimiter) cleanup() {
} }
} }
// isAllowed 检查是否允许请求
func (rl *RateLimiter) isAllowed(key string) bool { func (rl *RateLimiter) isAllowed(key string) bool {
rl.mu.Lock() rl.mu.Lock()
defer rl.mu.Unlock() defer rl.mu.Unlock()
@@ -64,7 +59,6 @@ func (rl *RateLimiter) isAllowed(key string) bool {
now := time.Now() now := time.Now()
times := rl.requests[key] times := rl.requests[key]
// 过滤掉过期的
var valid []time.Time var valid []time.Time
for _, t := range times { for _, t := range times {
if now.Sub(t) < rl.window { if now.Sub(t) < rl.window {
@@ -81,7 +75,6 @@ func (rl *RateLimiter) isAllowed(key string) bool {
return true return true
} }
// RateLimit 限流中间件
func RateLimit(requestsPerMinute int) gin.HandlerFunc { func RateLimit(requestsPerMinute int) gin.HandlerFunc {
limiter := NewRateLimiter(requestsPerMinute, time.Minute) limiter := NewRateLimiter(requestsPerMinute, time.Minute)
@@ -91,7 +84,29 @@ func RateLimit(requestsPerMinute int) gin.HandlerFunc {
if !limiter.isAllowed(ip) { if !limiter.isAllowed(ip) {
c.JSON(http.StatusTooManyRequests, gin.H{ c.JSON(http.StatusTooManyRequests, gin.H{
"code": 429, "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() c.Abort()
return return

View File

@@ -113,8 +113,9 @@ func (r *Router) setupRoutes() {
// API v1 // API v1
v1 := r.engine.Group("/api/v1") v1 := r.engine.Group("/api/v1")
{ {
// 认证路由(公开) // 认证路由(公开,需要速率限制
auth := v1.Group("/auth") auth := v1.Group("/auth")
auth.Use(middleware.RateLimit(10)) // 每分钟最多10次请求
{ {
auth.POST("/register", r.userHandler.Register) auth.POST("/register", r.userHandler.Register)
auth.POST("/register/send-code", r.userHandler.SendRegisterCode) auth.POST("/register/send-code", r.userHandler.SendRegisterCode)
@@ -385,9 +386,11 @@ func (r *Router) setupRoutes() {
} }
} }
// Gorse 管理路由 // Gorse 管理路由(需要管理员权限)
if r.gorseHandler != nil { if r.gorseHandler != nil {
gorseGroup := v1.Group("/gorse") gorseGroup := v1.Group("/gorse")
gorseGroup.Use(authMiddleware)
gorseGroup.Use(middleware.RequireRole(r.casbinService, model.RoleAdmin, model.RoleSuperAdmin))
{ {
gorseGroup.GET("/status", r.gorseHandler.GetStatus) gorseGroup.GET("/status", r.gorseHandler.GetStatus)
gorseGroup.POST("/import", r.gorseHandler.ImportData) gorseGroup.POST("/import", r.gorseHandler.ImportData)

View File

@@ -135,18 +135,7 @@ func (s *adminCommentServiceImpl) convertCommentToAdminListResponse(comment *mod
// 作者信息 // 作者信息
var author *dto.UserResponse var author *dto.UserResponse
if comment.User != nil { if comment.User != nil {
author = &dto.UserResponse{ author = dto.ConvertUserToResponse(comment.User)
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"),
}
} }
// 帖子简要信息 // 帖子简要信息
@@ -197,18 +186,7 @@ func (s *adminCommentServiceImpl) convertCommentToAdminDetailResponse(comment *m
// 作者信息 // 作者信息
var author *dto.UserResponse var author *dto.UserResponse
if comment.User != nil { if comment.User != nil {
author = &dto.UserResponse{ author = dto.ConvertUserToResponse(comment.User)
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"),
}
} }
// 帖子简要信息 // 帖子简要信息

View File

@@ -119,18 +119,7 @@ func (s *adminGroupServiceImpl) GetGroupDetail(ctx context.Context, groupID stri
if group.OwnerID != "" { if group.OwnerID != "" {
owner, err := s.userRepo.GetByID(group.OwnerID) owner, err := s.userRepo.GetByID(group.OwnerID)
if err == nil && owner != nil { if err == nil && owner != nil {
response.Owner = &dto.UserResponse{ response.Owner = dto.ConvertUserToResponse(owner)
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),
}
} }
} }

View File

@@ -274,18 +274,7 @@ func convertPostToAdminListResponse(post *model.Post) dto.AdminPostListResponse
var author *dto.UserResponse var author *dto.UserResponse
if post.User != nil { if post.User != nil {
author = &dto.UserResponse{ author = dto.ConvertUserToResponse(post.User)
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"),
}
} }
return dto.AdminPostListResponse{ return dto.AdminPostListResponse{
@@ -326,18 +315,7 @@ func convertPostToAdminDetailResponse(post *model.Post) *dto.AdminPostDetailResp
var author *dto.UserResponse var author *dto.UserResponse
if post.User != nil { if post.User != nil {
author = &dto.UserResponse{ author = dto.ConvertUserToResponse(post.User)
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"),
}
} }
return &dto.AdminPostDetailResponse{ return &dto.AdminPostDetailResponse{

View File

@@ -14,8 +14,10 @@ import (
) )
const ( const (
verifyCodeTTL = 10 * time.Minute verifyCodeTTL = 10 * time.Minute
verifyCodeRateLimitTTL = 60 * time.Second verifyCodeRateLimitTTL = 60 * time.Second
verifyCodeIPRateLimitTTL = 60 * time.Second
verifyCodeIPRateLimitCount = 5
) )
const ( const (
@@ -33,7 +35,7 @@ type verificationCodePayload struct {
} }
type EmailCodeService interface { 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 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))) 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) { func generateNumericCode(length int) (string, error) {
if length <= 0 { if length <= 0 {
return "", fmt.Errorf("invalid code length") return "", fmt.Errorf("invalid code length")
@@ -73,7 +79,7 @@ func generateNumericCode(length int) (string, error) {
return string(result), nil 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) { if strings.TrimSpace(email) == "" || !utils.ValidateEmail(email) {
return ErrInvalidEmail return ErrInvalidEmail
} }
@@ -89,6 +95,15 @@ func (s *emailCodeServiceImpl) SendCode(ctx context.Context, purpose, email stri
return ErrVerificationCodeTooFrequent 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) code, err := generateNumericCode(6)
if err != nil { if err != nil {
return fmt.Errorf("generate verification code failed: %w", err) 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(cacheKey, payload, verifyCodeTTL)
s.cache.Set(rateLimitKey, "1", verifyCodeRateLimitTTL) 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) subject, sceneText := verificationEmailMeta(purpose)
textBody := fmt.Sprintf("【%s】验证码%s\n有效期10分钟\n请勿将验证码泄露给他人。", sceneText, code) textBody := fmt.Sprintf("【%s】验证码%s\n有效期10分钟\n请勿将验证码泄露给他人。", sceneText, code)
htmlBody := buildVerificationEmailHTML(sceneText, code) htmlBody := buildVerificationEmailHTML(sceneText, code)

View File

@@ -14,13 +14,10 @@ import (
// UserService 用户服务接口 // UserService 用户服务接口
type UserService interface { type UserService interface {
// 验证码相关 SendRegisterCode(ctx context.Context, email, clientIP string) error
SendRegisterCode(ctx context.Context, email string) error SendPasswordResetCode(ctx context.Context, email, clientIP string) error
SendPasswordResetCode(ctx context.Context, email string) error SendCurrentUserEmailVerifyCode(ctx context.Context, userID, email, clientIP string) error
SendCurrentUserEmailVerifyCode(ctx context.Context, userID, email string) error SendChangePasswordCode(ctx context.Context, userID, clientIP string) error
SendChangePasswordCode(ctx context.Context, userID string) error
// 邮箱验证
VerifyCurrentUserEmail(ctx context.Context, userID, email, verificationCode string) error VerifyCurrentUserEmail(ctx context.Context, userID, email, verificationCode string) error
// 用户认证 // 用户认证
@@ -89,25 +86,23 @@ func (s *userServiceImpl) SetLogService(logService *LogService) {
} }
// SendRegisterCode 发送注册验证码 // 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) user, err := s.userRepo.GetByEmail(email)
if err == nil && user != nil { if err == nil && user != nil {
return ErrEmailExists 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, clientIP string) error {
func (s *userServiceImpl) SendPasswordResetCode(ctx context.Context, email string) error {
user, err := s.userRepo.GetByEmail(email) user, err := s.userRepo.GetByEmail(email)
if err != nil || user == nil { if err != nil || user == nil {
return ErrUserNotFound 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, clientIP string) error {
func (s *userServiceImpl) SendCurrentUserEmailVerifyCode(ctx context.Context, userID, email string) error {
user, err := s.userRepo.GetByID(userID) user, err := s.userRepo.GetByID(userID)
if err != nil || user == nil { if err != nil || user == nil {
return ErrUserNotFound return ErrUserNotFound
@@ -130,7 +125,7 @@ func (s *userServiceImpl) SendCurrentUserEmailVerifyCode(ctx context.Context, us
return ErrEmailExists return ErrEmailExists
} }
return s.emailCodeService.SendCode(ctx, CodePurposeEmailVerify, targetEmail) return s.emailCodeService.SendCode(ctx, CodePurposeEmailVerify, targetEmail, clientIP)
} }
// VerifyCurrentUserEmail 验证当前用户邮箱 // VerifyCurrentUserEmail 验证当前用户邮箱
@@ -163,7 +158,7 @@ func (s *userServiceImpl) VerifyCurrentUserEmail(ctx context.Context, userID, em
} }
// SendChangePasswordCode 发送修改密码验证码 // 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) user, err := s.userRepo.GetByID(userID)
if err != nil || user == nil { if err != nil || user == nil {
return ErrUserNotFound return ErrUserNotFound
@@ -171,7 +166,7 @@ func (s *userServiceImpl) SendChangePasswordCode(ctx context.Context, userID str
if user.Email == nil || strings.TrimSpace(*user.Email) == "" { if user.Email == nil || strings.TrimSpace(*user.Email) == "" {
return ErrEmailNotBound return ErrEmailNotBound
} }
return s.emailCodeService.SendCode(ctx, CodePurposeChangePassword, *user.Email) return s.emailCodeService.SendCode(ctx, CodePurposeChangePassword, *user.Email, clientIP)
} }
// Register 用户注册 // Register 用户注册