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 ====================
|
// ==================== 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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 帖子简要信息
|
// 帖子简要信息
|
||||||
|
|||||||
@@ -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),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 用户注册
|
||||||
|
|||||||
Reference in New Issue
Block a user