Files
go_blog/handlers/admin_user.go
T
dsh f39f0f4320 fix: SECURITY_TODO #30 最后管理员防线事务化,消除 TOCTOU 竞态
UserUpdate/UserDelete 的 adminCount<=1 计数与 Save/Delete 非原子,两个
并发的'降级/删除倒数第二位管理员'请求可同时通过检查,站点失去管理员
(MySQL 下真实窗口;SQLite 单写锁下窗口极小)。

- handlers/admin_user.go: 检查+写入包进 db.Transaction;
  ensureNotLastAdmin 在事务内锁定管理员集合后再计数:
  MySQL 用 SELECT ... FOR UPDATE(gorm clause.Locking)串行化
  并发事务;SQLite 无 FOR UPDATE,叠加进程内互斥锁 lastAdminMu
  (应用按设计单实例部署),计数在写锁串行化后重读
- 顺带修正:原实现中 f.Role 覆盖 user.Role 后检查恒为 true 的语义
  保留(基于原角色判定),避免降级检查被新值掩盖
- 测试: TestConcurrentLastAdminDowngrade(两位管理员并发降级:
  恰好 1 成功 1 拒绝、最终至少保留一位管理员;-race 通过)
2026-08-27 21:45:03 +08:00

458 lines
13 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 (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"go_blog/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// userForm 是后台用户创建/更新接口的 JSON 请求体,
// 同时供共享创建/编辑表单模板的渲染使用(IsEdit/Action/TitleText 非绑定字段)。
type userForm struct {
ID uint `json:"-"`
Username string `json:"username"`
Password string `json:"password"`
DisplayName string `json:"display_name"`
Email string `json:"email"`
Gender string `json:"gender"`
Birthday string `json:"birthday"` // 来自日期输入的 YYYY-MM-DD
Role string `json:"role"`
Status int `json:"status"`
IsEdit bool
Action string
TitleText string
}
// userListView 为 User 附加预渲染的标签/徽章,
// 使模板绝不调用包装为 interface{} 的结构体上的方法。
type userListView struct {
models.User
RoleLabel string
RoleBadge string
StatusLabel string
StatusBadge string
}
// parseUserFormJSON 绑定 JSON 请求体的用户字段并去除空白。
// 绑定失败时已写入 400 响应并返回 ok=false。ID 来自路由参数。
func parseUserFormJSON(c *gin.Context) (userForm, bool) {
var f userForm
if !bindJSON(c, &f) {
return f, false
}
f.ID = uintFormID(c.Param("id"))
f.Username = strings.TrimSpace(f.Username)
f.DisplayName = strings.TrimSpace(f.DisplayName)
f.Email = strings.TrimSpace(f.Email)
f.Gender = strings.TrimSpace(f.Gender)
f.Birthday = strings.TrimSpace(f.Birthday)
f.Role = strings.TrimSpace(f.Role)
return f, true
}
// uintFormID 将路由 :id 解析为 uint(不存在/非法时为 0)。
func uintFormID(s string) uint {
if s == "" {
return 0
}
n, err := strconv.ParseUint(s, 10, 64)
if err != nil {
return 0
}
return uint(n)
}
// errLastAdminRemoval 与 lastAdminMu 实现"最后管理员"防线
// SECURITY_TODO #30)。原实现先计数再写入,两者之间无原子性:
// 两个并发的"降级/删除倒数第二位管理员"请求可同时通过检查,
// 导致站点失去管理员。修复后检查与写入包进同一事务:
// - MySQL:事务内 SELECT ... FOR UPDATE 锁定管理员集合,
// 并发事务在锁上排队,先提交者生效,后到者重读计数后拒绝;
// - SQLite:不支持 FOR UPDATE,但其写锁串行化 + 本进程互斥锁
// (应用按设计单实例部署,见 LoginRateLimiter 注释)双保险。
var errLastAdminRemoval = errors.New("cannot remove the last admin")
var lastAdminMu sync.Mutex
// ensureNotLastAdmin 在事务内读取管理员数量(MySQL 下为锁定读取)。
// 仅剩 1 位(或 0 位)管理员时返回 errLastAdminRemoval。
func ensureNotLastAdmin(tx *gorm.DB) error {
q := tx.Model(&models.User{}).Where("role = ?", models.RoleAdmin)
if tx.Dialector.Name() != "sqlite" {
q = q.Clauses(clause.Locking{Strength: "UPDATE"})
}
var admins []models.User
if err := q.Find(&admins).Error; err != nil {
return err
}
if len(admins) <= 1 {
return errLastAdminRemoval
}
return nil
}
// applyUserFormToData 将表单值写入模板数据映射,
// 使渲染时表单被重新填充(初次加载或校验错误)。
func applyUserFormToData(data gin.H, f userForm) {
data["FormID"] = f.ID
data["FormUsername"] = f.Username
data["FormPassword"] = f.Password
data["FormDisplayName"] = f.DisplayName
data["FormEmail"] = f.Email
data["FormGender"] = f.Gender
data["FormBirthday"] = f.Birthday
data["FormRole"] = f.Role
data["FormStatus"] = f.Status
data["FormIsEdit"] = f.IsEdit
data["FormAction"] = f.Action
data["FormTitleText"] = f.TitleText
}
// renderUserForm 使用给定的表单值和可选的错误消息渲染共享的用户表单模板。
func renderUserForm(c *gin.Context, f userForm, errMsg string) {
tr := getTr(c)
data := DefaultData(c)
data["Title"] = f.TitleText
if errMsg != "" {
data["Error"] = errMsg
}
// <select> 元素的角色/状态选项。
data["RoleAdmin"] = models.RoleAdmin
data["RoleAuthor"] = models.RoleAuthor
data["StatusNormal"] = models.StatusNormal
data["StatusDisabled"] = models.StatusDisabled
data["StatusLocked"] = models.StatusLocked
data["StatusUnactivated"] = models.StatusUnactivated
data["TrGenderMale"] = tr["gender_male"]
data["TrGenderFemale"] = tr["gender_female"]
data["TrGenderOther"] = tr["gender_other"]
applyUserFormToData(data, f)
c.HTML(http.StatusOK, "user_form", data)
}
// UserListPage 渲染后台用户管理列表。
func UserListPage(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
tr := getTr(c)
var users []models.User
db.Order("created_at DESC").Find(&users)
views := make([]userListView, 0, len(users))
for _, u := range users {
v := userListView{User: u}
switch u.Role {
case models.RoleAdmin:
v.RoleLabel = tr["role_admin"]
v.RoleBadge = "bg-purple-100 text-purple-700"
default:
v.RoleLabel = tr["role_author"]
v.RoleBadge = "bg-blue-100 text-blue-700"
}
switch u.Status {
case models.StatusNormal:
v.StatusLabel = tr["status_normal"]
v.StatusBadge = "bg-green-100 text-green-700"
case models.StatusDisabled:
v.StatusLabel = tr["status_disabled"]
v.StatusBadge = "bg-gray-200 text-gray-600"
case models.StatusLocked:
v.StatusLabel = tr["status_locked"]
v.StatusBadge = "bg-yellow-100 text-yellow-700"
default:
v.StatusLabel = tr["status_unactivated"]
v.StatusBadge = "bg-red-100 text-red-700"
}
views = append(views, v)
}
data := DefaultData(c)
data["Title"] = tr["admin_users_title"]
data["Users"] = views
if msg := c.Query("saved"); msg == "1" {
switch c.Query("msg") {
case "created":
data["Success"] = tr["user_created"]
case "updated":
data["Success"] = tr["user_updated"]
case "deleted":
data["Success"] = tr["user_deleted"]
default:
data["Success"] = tr["settings_saved"]
}
}
if e := c.Query("error"); e != "" {
switch e {
case "self_disable":
data["Error"] = tr["user_cannot_disable_self"]
case "last_admin":
data["Error"] = tr["user_cannot_remove_last_admin"]
case "exists":
data["Error"] = tr["user_username_exists"]
}
}
c.HTML(http.StatusOK, "user_list", data)
}
}
// UserCreatePage 渲染空白用户创建表单。
func UserCreatePage(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
tr := getTr(c)
renderUserForm(c, userForm{
Action: "/admin/users/new",
TitleText: tr["user_create_title"],
Role: models.RoleAuthor,
Status: models.StatusNormal,
IsEdit: false,
}, "")
}
}
// UserCreate 处理 POST 创建新用户。
func UserCreate(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
f, ok := parseUserFormJSON(c)
if !ok {
return
}
if f.Username == "" {
APIError(c, http.StatusBadRequest, "user_username_required")
return
}
if f.Password == "" {
APIError(c, http.StatusBadRequest, "user_password_required")
return
}
// SECURITY (#23):执行平台最小密码长度。
if !validatePassword(f.Password) {
APIError(c, http.StatusBadRequest, "user_password_short")
return
}
// SECURITY (#24):拒绝格式非法的邮箱地址。
if !validateEmail(f.Email) {
APIError(c, http.StatusBadRequest, "user_email_invalid")
return
}
if f.Role == "" {
f.Role = models.RoleAuthor
}
// 用户名必须唯一。
var exists int64
db.Model(&models.User{}).Where("username = ?", f.Username).Count(&exists)
if exists > 0 {
APIError(c, http.StatusConflict, "user_username_exists")
return
}
user := models.User{
Username: f.Username,
DisplayName: f.DisplayName,
Email: f.Email,
Gender: f.Gender,
Role: f.Role,
Status: f.Status,
}
if t, err := time.Parse("2006-01-02", f.Birthday); err == nil {
user.Birthday = &t
}
if err := user.SetPassword(f.Password); err != nil {
APIError(c, http.StatusInternalServerError, "article_error")
return
}
if err := db.Create(&user).Error; err != nil {
APIError(c, http.StatusInternalServerError, "article_error")
return
}
APIOK(c, "/admin/users?saved=1&msg=created", gin.H{"user_id": user.ID})
}
}
// UserEditPage 渲染预填现有用户的编辑表单。
func UserEditPage(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
tr := getTr(c)
// SECURITY (#19):路由参数必须先解析为数值 id 再交给 GORM——
// 原始字符串作为单一条件传给 First() 时会按 SQL WHERE 子句拼接。
id := uintFormID(c.Param("id"))
if id == 0 {
c.Redirect(http.StatusFound, "/admin/users")
return
}
var user models.User
if err := db.First(&user, id).Error; err != nil {
c.Redirect(http.StatusFound, "/admin/users")
return
}
birthday := ""
if user.Birthday != nil {
birthday = user.Birthday.Format("2006-01-02")
}
renderUserForm(c, userForm{
ID: user.ID,
Username: user.Username,
DisplayName: user.DisplayName,
Email: user.Email,
Gender: user.Gender,
Birthday: birthday,
Role: user.Role,
Status: user.Status,
IsEdit: true,
Action: fmt.Sprintf("/admin/users/%d/edit", user.ID),
TitleText: tr["user_edit_title"],
}, "")
}
}
// UserUpdate 处理 POST 更新现有用户。
func UserUpdate(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
f, ok := parseUserFormJSON(c)
if !ok {
return
}
// SECURITY (#19):在触碰 GORM 前拒绝非数值 id(参见 UserEditPage)。
if f.ID == 0 {
APIError(c, http.StatusBadRequest, "api_invalid_request")
return
}
var user models.User
if err := db.First(&user, f.ID).Error; err != nil {
APIError(c, http.StatusNotFound, "user_not_found")
return
}
// 保留原用户名(它是登录键 + 文章外键的来源)。
f.Username = user.Username
// SECURITY (#23/#24):在进行任何其他修改前校验提交的密码/邮箱——
// 密码重置或个人资料编辑必须遵守与注册相同的规则。
if f.Password != "" && !validatePassword(f.Password) {
APIError(c, http.StatusBadRequest, "user_password_short")
return
}
if !validateEmail(f.Email) {
APIError(c, http.StatusBadRequest, "user_email_invalid")
return
}
currentID := userIDFromSession(c)
isSelf := user.ID == currentID
// 最后管理员检查必须在 user.Role 被新值覆盖之前基于原值判定。
wasAdmin := user.Role == models.RoleAdmin
// 自我保护:不能禁用/锁定自己的账户。
if isSelf && f.Status != models.StatusNormal {
APIError(c, http.StatusForbidden, "user_cannot_disable_self")
return
}
if f.Role == "" {
f.Role = user.Role
}
user.DisplayName = f.DisplayName
user.Email = f.Email
user.Gender = f.Gender
user.Role = f.Role
user.Status = f.Status
if t, err := time.Parse("2006-01-02", f.Birthday); err == nil {
user.Birthday = &t
} else {
user.Birthday = nil
}
// 可选密码重置(留空表示保持当前密码)。
if f.Password != "" {
if err := user.SetPassword(f.Password); err != nil {
APIError(c, http.StatusInternalServerError, "article_error")
return
}
}
// 自我保护:不能降级最后一位管理员(SECURITY_TODO #30
// 检查与写入在事务内原子执行,消除 TOCTOU 竞态)。
lastAdminCheck := wasAdmin && f.Role != models.RoleAdmin
lastAdminMu.Lock()
err := db.Transaction(func(tx *gorm.DB) error {
if lastAdminCheck {
if err := ensureNotLastAdmin(tx); err != nil {
return err
}
}
return tx.Save(&user).Error
})
lastAdminMu.Unlock()
if err != nil {
if errors.Is(err, errLastAdminRemoval) {
APIError(c, http.StatusForbidden, "user_cannot_remove_last_admin")
return
}
APIError(c, http.StatusInternalServerError, "article_error")
return
}
APIOK(c, "/admin/users?saved=1&msg=updated", nil)
}
}
// UserDelete 软删除用户,带自我保护和最后管理员防线。
func UserDelete(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
// SECURITY (#19):在触碰 GORM 前拒绝非数值 id(参见 UserEditPage)。
targetID := uintFormID(c.Param("id"))
if targetID == 0 {
APIError(c, http.StatusBadRequest, "api_invalid_request")
return
}
currentID := userIDFromSession(c)
var user models.User
if err := db.First(&user, targetID).Error; err != nil {
APIError(c, http.StatusNotFound, "user_not_found")
return
}
// 不能删除自己。
if targetID == currentID {
APIError(c, http.StatusForbidden, "user_cannot_disable_self")
return
}
// 不能删除最后一位管理员(SECURITY_TODO #30
// 检查与写入在事务内原子执行,消除 TOCTOU 竞态)。
lastAdminCheck := user.Role == models.RoleAdmin
lastAdminMu.Lock()
err := db.Transaction(func(tx *gorm.DB) error {
if lastAdminCheck {
if err := ensureNotLastAdmin(tx); err != nil {
return err
}
}
return tx.Delete(&user).Error
})
lastAdminMu.Unlock()
if err != nil {
if errors.Is(err, errLastAdminRemoval) {
APIError(c, http.StatusForbidden, "user_cannot_remove_last_admin")
return
}
APIError(c, http.StatusInternalServerError, "article_error")
return
}
APIOK(c, "/admin/users?saved=1&msg=deleted", nil)
}
}