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
This commit is contained in:
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// 帖子简要信息
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -16,6 +16,8 @@ import (
|
||||
const (
|
||||
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)
|
||||
|
||||
@@ -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 用户注册
|
||||
|
||||
Reference in New Issue
Block a user