Files
go_blog/handlers/auth.go
T
kevin c9f858b626 fix: 完成 P2 安全修复 #9-13 #22-25
- #9 CDN 本地化:marked/DOMPurify/highlight.js/cropperjs/easymde 入 static/vendor(go:embed),Tailwind 改静态构建(scripts/build_tailwind.sh),CSP 收紧为 default-src 'self'
- #10 登录限速:IP+用户名维度 5 次失败锁 15 分钟,内存实现有界(handlers/login_ratelimit.go)
- #25 计时侧信道:用户不存在时执行 dummy bcrypt 抹平时间差(随 #10 实施)
- #11 配置文件权限 0640
- #12 首启随机一次性密码(弃用 admin/admin)
- #13 unix socket 660 + 代理用户加组提示
- #22 storage_dir 路径穿越校验(单安全路径段)
- #23 密码最小长度统一(改密/建号/重置),#24 邮箱格式统一校验
- 新增 13 个单元测试;go test ./... 含 -race 全绿
2026-08-27 18:10:22 +08:00

235 lines
6.4 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 renders the login form.
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"]
}
// Check if registration is allowed from site settings
siteSetting, _ := c.Get("site_setting")
if s, ok := siteSetting.(*models.SiteSetting); ok && s != nil {
data["AllowRegistration"] = s.AllowRegistration
}
c.HTML(http.StatusOK, "login", data)
}
}
// Login processes the login form submission. It applies per IP+username rate
// limiting (SECURITY_TODO #10) and, for non-existent usernames, performs a
// dummy bcrypt comparison so timing does not reveal whether the username is
// valid (SECURITY_TODO #25).
func Login(db *gorm.DB, limiter *loginRateLimiter) gin.HandlerFunc {
return func(c *gin.Context) {
username := c.PostForm("username")
password := c.PostForm("password")
key := GetClientIP(c) + "\x00" + username
if !limiter.Allow(key) {
c.Redirect(http.StatusFound, "/login?error=locked")
return
}
var user models.User
if err := db.Where("username = ?", username).First(&user).Error; err != nil {
// Constant-time: burn the same amount of work a real password
// check would (bcrypt compare) before failing, so timing does
// not reveal whether the username exists.
limiter.Fail(key)
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(password))
c.Redirect(http.StatusFound, "/login?error=1")
return
}
if !user.CheckPassword(password) {
limiter.Fail(key)
c.Redirect(http.StatusFound, "/login?error=1")
return
}
// Refuse login for non-normal accounts (disabled / locked / unactivated).
if user.Status != models.StatusNormal {
c.Redirect(http.StatusFound, "/login?error=1")
return
}
// Success: reset the failure counter for this key.
limiter.Reset(key)
// Rotate the session on privilege change to prevent session
// fixation: drop all pre-authentication state, keep only the
// harmless UI preferences (language and CSRF token so forms
// already rendered in other tabs stay valid).
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 {
c.String(http.StatusInternalServerError, "Failed to save session")
return
}
// Redirect based on user role: admins to /admin, others to home
if user.Role == models.RoleAdmin {
c.Redirect(http.StatusFound, "/admin")
} else {
c.Redirect(http.StatusFound, "/")
}
}
}
// Logout clears the session and redirects home.
func Logout() gin.HandlerFunc {
return func(c *gin.Context) {
session := sessions.Default(c)
session.Clear()
session.Save()
c.Redirect(http.StatusFound, "/")
}
}
// RegisterPage renders the registration form (only when registration is enabled).
func RegisterPage(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
// Check if registration is allowed
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)
}
}
// Register processes the registration form submission.
func Register(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
// Check if registration is allowed
var s models.SiteSetting
if err := db.First(&s, 1).Error; err != nil || !s.AllowRegistration {
c.Redirect(http.StatusFound, "/login")
return
}
username := strings.TrimSpace(c.PostForm("username"))
password := c.PostForm("password")
confirmPassword := c.PostForm("confirm_password")
email := strings.TrimSpace(c.PostForm("email"))
displayName := strings.TrimSpace(c.PostForm("display_name"))
// Validate inputs
if username == "" || password == "" {
c.Redirect(http.StatusFound, "/register?error=register_required")
return
}
if len(username) < 3 || len(username) > 32 {
c.Redirect(http.StatusFound, "/register?error=register_username_length")
return
}
if len(password) < 6 {
c.Redirect(http.StatusFound, "/register?error=register_password_length")
return
}
if password != confirmPassword {
c.Redirect(http.StatusFound, "/register?error=register_password_mismatch")
return
}
// SECURITY (#24): reject malformed email addresses (optional field).
if !validateEmail(email) {
c.Redirect(http.StatusFound, "/register?error=register_email_invalid")
return
}
// Check if username already exists
var existingUser models.User
if err := db.Where("username = ?", username).First(&existingUser).Error; err == nil {
c.Redirect(http.StatusFound, "/register?error=user_username_exists")
return
}
// Create new user
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 {
c.Redirect(http.StatusFound, "/register?error=register_error")
return
}
if err := db.Create(&user).Error; err != nil {
c.Redirect(http.StatusFound, "/register?error=register_error")
return
}
// Auto-login after successful registration (with session
// rotation, mirroring the login handler).
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 {
c.Redirect(http.StatusFound, "/login")
return
}
// Redirect to home page
c.Redirect(http.StatusFound, "/")
}
}