Files
go_blog/handlers/auth.go
T
dsh 4c32267b47 fix: SECURITY_TODO #27 注册接口按 IP 限流(10 次/小时)防批量注册
register/api 未认证端点可被无限批量注册:bcrypt cost 12 与用户名唯一
检查不构成成本屏障。新增 WindowRateLimiter(固定窗口、有界 map、与
#10 相同的淘汰策略),Register 按 IP 限流 10 次/小时,超限 429 +
i18n register_locked(中英)。

- handlers/rate_limit.go: 通用固定窗口限流器(可注入时钟便于测试)
- handlers/auth.go: Register(db, limiter),键前缀与登录限流器区分
- main.go: 注册限流器接线;测试环境同步
- 测试: TestWindowLimiterFixedWindow / TestRegisterRateLimited
  (同 IP 10 次成功、第 11 次 429、其他 IP 不受影响)

注: #28 评论限流将复用同一限流器,在下一个 commit 实施。
2026-08-27 21:38:49 +08:00

262 lines
7.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handlers
import (
"net/http"
"strings"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"go_blog/models"
)
// LoginPage 渲染登录表单。
func LoginPage() gin.HandlerFunc {
return func(c *gin.Context) {
tr := getTr(c)
data := DefaultData(c)
data["Title"] = tr["page_login"]
if c.Query("error") == "1" {
data["Error"] = tr["login_error"]
}
if c.Query("error") == "locked" {
data["Error"] = tr["login_locked"]
}
// 根据站点设置检查是否允许注册
siteSetting, _ := c.Get("site_setting")
if s, ok := siteSetting.(*models.SiteSetting); ok && s != nil {
data["AllowRegistration"] = s.AllowRegistration
}
c.HTML(http.StatusOK, "login", data)
}
}
// loginRequest 是 POST /api/auth/login 的 JSON 请求体。
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
// Login 处理登录表单提交。它对每个 IP+用户名实施速率限制
// SECURITY_TODO #10),对于不存在的用户名会执行一次虚拟 bcrypt 比较,
// 使耗时不会暴露用户名是否存在(SECURITY_TODO #25)。
func Login(db *gorm.DB, limiter *LoginRateLimiter) gin.HandlerFunc {
return func(c *gin.Context) {
var req loginRequest
if !bindJSON(c, &req) {
return
}
username := req.Username
password := req.Password
key := GetClientIP(c) + "\x00" + username
if !limiter.Allow(key) {
APIError(c, http.StatusTooManyRequests, "login_locked")
return
}
var user models.User
if err := db.Where("username = ?", username).First(&user).Error; err != nil {
// 常量时间:失败前执行与真实密码校验等量的工作(bcrypt 比较),
// 使耗时不会暴露用户名是否存在。
limiter.Fail(key)
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(password))
APIError(c, http.StatusUnauthorized, "login_error")
return
}
if !user.CheckPassword(password) {
limiter.Fail(key)
APIError(c, http.StatusUnauthorized, "login_error")
return
}
// 拒绝非正常状态账户登录(已禁用 / 已锁定 / 未激活)。
if user.Status != models.StatusNormal {
APIError(c, http.StatusUnauthorized, "login_error")
return
}
// 成功:重置此键的失败计数。
limiter.Reset(key)
// 权限变更时轮换会话,防止会话固定攻击:
// 丢弃全部登录前状态,仅保留无害的 UI 偏好(语言和 CSRF 令牌,
// 使其他标签页中已渲染的表单仍然有效)。
session := sessions.Default(c)
lang, _ := session.Get("lang").(string)
csrfTok, _ := session.Get("csrf_token").(string)
session.Clear()
if lang != "" {
session.Set("lang", lang)
}
if csrfTok != "" {
session.Set("csrf_token", csrfTok)
}
session.Set("user_id", user.ID)
session.Set("username", user.Username)
if err := session.Save(); err != nil {
APIError(c, http.StatusInternalServerError, "api_error")
return
}
// 根据用户角色跳转:管理员到 /admin,其他用户到首页
redirect := "/"
if user.Role == models.RoleAdmin {
redirect = "/admin"
}
APIOK(c, redirect, nil)
}
}
// Logout 清除会话并返回跳转首页指令,由前端 fetch 发起跳转。
func Logout() gin.HandlerFunc {
return func(c *gin.Context) {
session := sessions.Default(c)
session.Clear()
session.Save()
APIOK(c, "/", nil)
}
}
// RegisterPage 渲染注册表单(仅在启用注册时可用)。
func RegisterPage(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
// 检查是否允许注册
var s models.SiteSetting
if err := db.First(&s, 1).Error; err != nil || !s.AllowRegistration {
c.Redirect(http.StatusFound, "/login")
return
}
tr := getTr(c)
data := DefaultData(c)
data["Title"] = tr["page_register"]
if errMsg := c.Query("error"); errMsg != "" {
data["Error"] = tr[errMsg]
}
c.HTML(http.StatusOK, "register", data)
}
}
// registerRequest 是 POST /api/auth/register 的 JSON 请求体。
type registerRequest struct {
Username string `json:"username"`
Password string `json:"password"`
ConfirmPassword string `json:"confirm_password"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
}
// Register 处理注册表单提交。它对每个 IP 实施速率限制
// SECURITY_TODO #27),防止批量注册垃圾账户。
func Register(db *gorm.DB, limiter *WindowRateLimiter) gin.HandlerFunc {
return func(c *gin.Context) {
// SECURITY_TODO #27:按 IP 限流注册(10 次/小时),
// 键加前缀与登录限流器区分。超限直接 429,不执行任何数据库工作。
if !limiter.Allow("register\x00" + GetClientIP(c)) {
APIError(c, http.StatusTooManyRequests, "register_locked")
return
}
// 检查是否允许注册
var s models.SiteSetting
if err := db.First(&s, 1).Error; err != nil || !s.AllowRegistration {
APIError(c, http.StatusForbidden, "registration_disabled")
return
}
var req registerRequest
if !bindJSON(c, &req) {
return
}
username := strings.TrimSpace(req.Username)
password := req.Password
confirmPassword := req.ConfirmPassword
email := strings.TrimSpace(req.Email)
displayName := strings.TrimSpace(req.DisplayName)
// 校验输入
if username == "" || password == "" {
APIError(c, http.StatusBadRequest, "register_required")
return
}
if len(username) < 3 || len(username) > 32 {
APIError(c, http.StatusBadRequest, "register_username_length")
return
}
if len(password) < 6 {
APIError(c, http.StatusBadRequest, "register_password_length")
return
}
if password != confirmPassword {
APIError(c, http.StatusBadRequest, "register_password_mismatch")
return
}
// SECURITY (#24):拒绝格式非法的邮箱地址(可选字段)。
if !validateEmail(email) {
APIError(c, http.StatusBadRequest, "register_email_invalid")
return
}
// 检查用户名是否已存在
var existingUser models.User
if err := db.Where("username = ?", username).First(&existingUser).Error; err == nil {
APIError(c, http.StatusConflict, "user_username_exists")
return
}
// 创建新用户
user := models.User{
Username: username,
Email: email,
DisplayName: displayName,
Role: models.RoleAuthor,
Status: models.StatusNormal,
}
if displayName == "" {
user.DisplayName = username
}
if err := user.SetPassword(password); err != nil {
APIError(c, http.StatusInternalServerError, "register_error")
return
}
if err := db.Create(&user).Error; err != nil {
APIError(c, http.StatusInternalServerError, "register_error")
return
}
// 注册成功后自动登录(与登录处理器一致的会话轮换)。
session := sessions.Default(c)
lang, _ := session.Get("lang").(string)
csrfTok, _ := session.Get("csrf_token").(string)
session.Clear()
if lang != "" {
session.Set("lang", lang)
}
if csrfTok != "" {
session.Set("csrf_token", csrfTok)
}
session.Set("user_id", user.ID)
session.Set("username", user.Username)
if err := session.Save(); err != nil {
APIError(c, http.StatusInternalServerError, "api_error")
return
}
// 首页
APIOK(c, "/", nil)
}
}