Files
go_blog/handlers/auth.go
T
kevin 7062c755a3 feat: 认证接口 JSON 化——/api/auth/login|register|logout
- 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 ./... 全绿
2026-08-27 19:31:29 +08:00

254 lines
6.7 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 处理注册表单提交。
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)
}
}