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 通过)
458 lines
13 KiB
Go
458 lines
13 KiB
Go
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)
|
||
}
|
||
}
|