- auth.go:Login/Register 改 JSON 绑定(loginRequest/registerRequest),
错误码复用 i18n 键:登录失败 login_error(401)、限流锁定 login_locked(429)、
注册校验 400、用户名冲突 user_username_exists(409)、注册禁用
registration_disabled(403);成功返回 {ok,redirect}(角色定向 /admin 或 /)
- main.go:旧 POST /login /register /logout 移除,迁入 /api/auth 分组
- i18n:新增 registration_disabled(中英)
- security_test.go:env 路由改 /api/auth/*,e.login 改 JSON 登录,
新增 postJSON/respCode/respRedirect/respOK 测试辅助
- p2_validation_test.go:TestLoginRateLimited/TestLoginTimingDoesNotRevealUser/
TestRegisterRejectsInvalidEmail 迁移 JSON 断言(429/401/409/400)
- base.html:logout-form 全局委托 fetch + login/register 模板 id/JS
- main_test.go:冒烟断言补 /api/auth 三端点
- go build/vet/test ./... 全绿
254 lines
6.7 KiB
Go
254 lines
6.7 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 处理注册表单提交。
|
||
func Register(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 {
|
||
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)
|
||
}
|
||
}
|