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 实施。
262 lines
7.1 KiB
Go
262 lines
7.1 KiB
Go
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)
|
||
}
|
||
}
|