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 ====================
// 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"`
}

View File

@@ -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,

View File

@@ -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
})
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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)
}
// 帖子简要信息

View File

@@ -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)
}
}

View File

@@ -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{

View File

@@ -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)

View File

@@ -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 用户注册