Smail/backend/controllers/user.go
smail fd475cd88d
Some checks are pending
Security Checks / security (push) Waiting to run
保存角色权限分层改造续审现场
2026-09-09 20:27:50 +08:00

425 lines
16 KiB
Go

package controllers
import (
"encoding/json"
"errors"
"git.pcux.cn/xuans/Smail/config"
"git.pcux.cn/xuans/Smail/db"
"git.pcux.cn/xuans/Smail/dto/response"
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/services/access"
"git.pcux.cn/xuans/Smail/services/auth"
"git.pcux.cn/xuans/Smail/utils/array"
"git.pcux.cn/xuans/Smail/utils/context"
"git.pcux.cn/xuans/Smail/utils/csrf"
"git.pcux.cn/xuans/Smail/utils/password"
"git.pcux.cn/xuans/Smail/utils/validation"
log "github.com/sirupsen/logrus"
"github.com/spf13/cast"
"io"
"math"
"net/http"
"time"
)
const maxUserRequestBytes int64 = 64 << 10
func decodeUserRequest(w http.ResponseWriter, req *http.Request, dst any) error {
req.Body = http.MaxBytesReader(w, req.Body, maxUserRequestBytes)
decoder := json.NewDecoder(req.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(dst); err != nil {
return err
}
var trailing any
if err := decoder.Decode(&trailing); err != io.EOF {
if err == nil {
return errors.New("multiple json values")
}
return err
}
return nil
}
type userCreateRequest struct {
Id int `json:"id"`
Account string `json:"account"`
Username string `json:"username"`
Password string `json:"password"`
Disabled int `json:"disabled"`
Role string `json:"role"`
}
func CreateUser(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
targetRole := access.RoleUser
var reqData userCreateRequest
if err := decodeUserRequest(w, req, &reqData); err != nil {
response.NewErrorResponse(response.ParamsError, "参数错误", "").FPrint(w)
return
}
if reqData.Role != "" {
targetRole = access.Role(reqData.Role)
}
if err := authorizeCapability(ctx, access.CapabilityUserCreate, targetRole, false); err != nil {
response.NewErrorResponse(response.NoAccessPrivileges, "无权访问", "").FPrint(w)
return
}
if reqData.Username == "" || reqData.Password == "" || reqData.Account == "" {
response.NewErrorResponse(response.ParamsError, "\u53c2\u6570\u9519\u8bef", "").FPrint(w)
return
}
if reqData.Disabled != 0 && reqData.Disabled != 1 {
response.NewErrorResponse(response.ParamsError, "\u53c2\u6570\u9519\u8bef", "").FPrint(w)
return
}
account, err := validation.NormalizeAccount(reqData.Account)
if err != nil {
response.NewErrorResponse(response.ParamsError, "\u53c2\u6570\u9519\u8bef", "").FPrint(w)
return
}
username, err := validation.NormalizeDisplayName(reqData.Username)
if err != nil {
response.NewErrorResponse(response.ParamsError, "\u53c2\u6570\u9519\u8bef", "").FPrint(w)
return
}
user := models.User{Name: username, Account: account, Disabled: reqData.Disabled, SessionVersion: 1, SecurityVersion: 1}
user.ApplyRole(targetRole)
if err := auth.SetPassword(&user, reqData.Password); err != nil {
if errors.Is(err, password.ErrInvalidLength) {
response.NewErrorResponse(response.ParamsError, "\u5bc6\u7801\u957f\u5ea6\u5fc5\u987b\u4e3a 8-12 \u5b57\u8282", "").FPrint(w)
return
}
log.WithContext(ctx).WithError(err).Error("password hash failed")
response.NewErrorResponse(response.ServerError, "密码设置失败", "").FPrint(w)
return
}
if _, err := db.Instance.Insert(&user); err != nil {
log.WithContext(ctx).WithError(err).Error("create user failed")
response.NewErrorResponse(response.ServerError, "\u521b\u5efa\u7528\u6237\u5931\u8d25", "").FPrint(w)
return
}
response.NewSuccessResponse(user).FPrint(w)
}
type userListRequest struct {
CurrentPage int `json:"current_page"`
PageSize int `json:"page_size"`
}
type userListItem struct {
ID int `json:"ID"`
Account string `json:"Account"`
Name string `json:"Name"`
Disabled int `json:"Disabled"`
Role access.Role `json:"Role"`
IsAdmin int `json:"IsAdmin"`
MustChangePassword int `json:"must_change_password"`
RecoveryCodeConfigured bool `json:"RecoveryCodeConfigured"`
ExternalEmailEnabled bool `json:"ExternalEmailEnabled"`
ExternalEmailConfigured bool `json:"ExternalEmailConfigured"`
}
func loadUserSecurityStatus(users []models.User) (map[int]bool, map[int]bool, error) {
recoveryConfigured := make(map[int]bool, len(users))
externalEmailConfigured := make(map[int]bool, len(users))
if len(users) == 0 {
return recoveryConfigured, externalEmailConfigured, nil
}
userIDs := make([]int, 0, len(users))
for _, user := range users {
userIDs = append(userIDs, user.ID)
}
var recoveryCodes []models.RecoveryCode
if err := db.Instance.Cols("user_id").In("user_id", userIDs).Where("used_at IS NULL").Find(&recoveryCodes); err != nil {
return nil, nil, err
}
for _, recoveryCode := range recoveryCodes {
recoveryConfigured[recoveryCode.UserID] = true
}
var externalEmails []models.ExternalEmail
if err := db.Instance.Cols("user_id").In("user_id", userIDs).Where("status = ?", models.ExternalEmailStatusVerified).Find(&externalEmails); err != nil {
return nil, nil, err
}
for _, externalEmail := range externalEmails {
externalEmailConfigured[externalEmail.UserID] = true
}
return recoveryConfigured, externalEmailConfigured, nil
}
func UserList(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
if err := authorizeCapability(ctx, access.CapabilityUserRead, "", false); err != nil {
response.NewErrorResponse(response.NoAccessPrivileges, "\u65e0\u6743\u8bbf\u95ee", "").FPrint(w)
return
}
var reqData userListRequest
if err := decodeUserRequest(w, req, &reqData); err != nil {
response.NewErrorResponse(response.ParamsError, "\u53c2\u6570\u9519\u8bef", "").FPrint(w)
return
}
if reqData.PageSize <= 0 || reqData.PageSize > 100 {
reqData.PageSize = 15
}
offset := 0
if reqData.CurrentPage >= 1 {
offset = (reqData.CurrentPage - 1) * reqData.PageSize
}
var users []models.User
query := db.Instance.Table(&models.User{})
defer query.Close()
if ctx.EffectiveRole() == access.RoleAdmin {
query.Where(models.OrdinaryUserPredicate)
}
totalNum, err := query.Asc("id").Limit(reqData.PageSize, offset).FindAndCount(&users)
if err != nil {
log.WithContext(ctx).Errorf("SQL Error: %+v", err)
response.NewErrorResponse(response.ServerError, "查询失败", "").FPrint(w)
return
}
recoveryConfigured, externalEmailConfigured, err := loadUserSecurityStatus(users)
if err != nil {
log.WithContext(ctx).WithError(err).Error("load user security status failed")
response.NewErrorResponse(response.ServerError, "读取用户安全状态失败", "").FPrint(w)
return
}
items := make([]userListItem, 0, len(users))
for _, user := range users {
items = append(items, userListItem{
ID: user.ID,
Account: user.Account,
Name: user.Name,
Disabled: user.Disabled,
Role: user.EffectiveRole(),
IsAdmin: user.IsAdmin,
MustChangePassword: user.MustChangePassword,
RecoveryCodeConfigured: recoveryConfigured[user.ID],
ExternalEmailEnabled: user.ExternalEmailEnabled == 1,
ExternalEmailConfigured: externalEmailConfigured[user.ID],
})
}
response.NewSuccessResponse(map[string]any{
"current_page": reqData.CurrentPage,
"total_page": cast.ToInt(math.Ceil(cast.ToFloat64(totalNum) / cast.ToFloat64(reqData.PageSize))),
"list": items,
}).FPrint(w)
}
func Info(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
if _, err := csrf.Ensure(w, req, !config.DevelopmentMode); err != nil {
log.WithContext(ctx).WithError(err).Error("restore csrf token failed")
response.NewErrorResponse(response.ServerError, "登录状态恢复失败", "").FPrint(w)
return
}
mailDomains := config.CurrentMailDomains()
if mailDomains.Primary == "" {
response.NewErrorResponse(response.ServerError, "服务配置不可用", "").FPrint(w)
return
}
domains := array.Difference(mailDomains.Accepted, []string{mailDomains.Primary})
domains = append([]string{mailDomains.Primary}, domains...)
result := map[string]any{
"id": ctx.UserID,
"account": ctx.UserAccount,
"name": ctx.UserName,
"role": ctx.EffectiveRole(),
"capabilities": access.Capabilities(ctx.EffectiveRole()),
"is_admin": ctx.IsAdmin,
"must_change_password": ctx.MustChangePassword,
"showSecuritySetupPrompt": ctx.SecuritySetupAcknowledged == 0,
"webAuthnEnabled": webAuthnRuntime() != nil,
"domains": domains,
}
if ctx.EffectiveRole() != access.RoleSuperAdmin {
result["recoveryCodesConfigured"] = ctx.RecoveryCodeConfigured
result["showRecoverySetupPrompt"] = !ctx.RecoveryCodeConfigured
} else {
result["recoveryCodesConfigured"] = false
result["showRecoverySetupPrompt"] = false
}
response.NewSuccessResponse(result).FPrint(w)
}
func EditUser(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
var reqData userCreateRequest
if err := decodeUserRequest(w, req, &reqData); err != nil {
response.NewErrorResponse(response.ParamsError, "参数错误", "").FPrint(w)
return
}
if reqData.Id == 0 && reqData.Account == "" {
response.NewErrorResponse(response.ParamsError, "\u53c2\u6570\u9519\u8bef", "").FPrint(w)
return
}
if reqData.Disabled != 0 && reqData.Disabled != 1 {
response.NewErrorResponse(response.ParamsError, "\u53c2\u6570\u9519\u8bef", "").FPrint(w)
return
}
var user models.User
var found bool
var err error
if reqData.Id != 0 {
found, err = db.Instance.Where("id=?", reqData.Id).Get(&user)
} else {
account, validationErr := validation.NormalizeAccount(reqData.Account)
if validationErr != nil {
response.NewErrorResponse(response.ParamsError, "\u53c2\u6570\u9519\u8bef", "").FPrint(w)
return
}
found, err = db.Instance.Where("account=?", account).Get(&user)
}
if err != nil {
log.WithContext(ctx).Errorf("SQL Error: %+v", err)
response.NewErrorResponse(response.ServerError, "\u67e5\u8be2\u7528\u6237\u5931\u8d25", "").FPrint(w)
return
}
if !found || user.ID == 0 {
response.NewErrorResponse(response.ParamsError, "\u7528\u6237\u4e0d\u5b58\u5728", "").FPrint(w)
return
}
targetRole := user.EffectiveRole()
if err := authorizeCapability(ctx, access.CapabilityUserManage, targetRole, false); err != nil {
response.NewErrorResponse(response.NoAccessPrivileges, "无权管理该用户", "").FPrint(w)
return
}
if reqData.Password != "" {
if err := authorizeCapability(ctx, access.CapabilityPasswordReset, targetRole, false); err != nil {
writeUserAuthorizationError(w, err, "密码重置需要为当前管理员配置有效安全码")
return
}
}
originalDisabled := user.Disabled
originalRole := user.EffectiveRole()
originalSessionVersion := user.SessionVersion
originalSecurityVersion := user.SecurityVersion
if reqData.Role != "" && access.Role(reqData.Role) != targetRole {
newRole := access.Role(reqData.Role)
if !newRole.Valid() {
response.NewErrorResponse(response.ParamsError, "角色参数错误", "").FPrint(w)
return
}
ctx.TargetRecoveryConfigured = false
if credentialService := credentialRuntime(); credentialService != nil {
configured, checkErr := credentialService.HasValidRecoveryCode(user.ID)
if checkErr != nil {
log.WithContext(ctx).WithError(checkErr).Error("load target recovery code status failed")
response.NewErrorResponse(response.ServerError, "读取目标用户恢复码状态失败", "").FPrint(w)
return
}
ctx.TargetRecoveryConfigured = configured
}
if err := authorizeCapabilityWithNewRole(ctx, access.CapabilityRoleManage, targetRole, newRole, false); err != nil {
writeUserAuthorizationError(w, err, "更改权限级别前,请先为该用户配置有效安全码")
return
}
user.ApplyRole(newRole)
}
if user.IsAdmin == 1 && reqData.Disabled != 0 {
response.NewErrorResponse(response.ParamsError, "\u4e0d\u5141\u8bb8\u7981\u7528\u7ba1\u7406\u5458\u8d26\u6237", "").FPrint(w)
return
}
if user.ID == ctx.UserID && reqData.Password != "" {
response.NewErrorResponse(response.ParamsError, "\u8bf7\u901a\u8fc7\u5b89\u5168\u8bbe\u7f6e\u4fee\u6539\u5f53\u524d\u8d26\u6237\u5bc6\u7801", "").FPrint(w)
return
}
if reqData.Username != "" {
username, validationErr := validation.NormalizeDisplayName(reqData.Username)
if validationErr != nil {
response.NewErrorResponse(response.ParamsError, "\u53c2\u6570\u9519\u8bef", "").FPrint(w)
return
}
user.Name = username
}
user.Disabled = reqData.Disabled
if reqData.Password != "" {
if err := auth.SetPassword(&user, reqData.Password); err != nil {
if errors.Is(err, password.ErrInvalidLength) {
response.NewErrorResponse(response.ParamsError, "\u5bc6\u7801\u957f\u5ea6\u5fc5\u987b\u4e3a 8-12 \u5b57\u8282", "").FPrint(w)
return
}
log.WithContext(ctx).WithError(err).Error("password hash failed")
response.NewErrorResponse(response.ServerError, "密码设置失败", "").FPrint(w)
return
}
user.MustChangePassword = 1
now := time.Now().UTC()
user.PasswordChangedAt = &now
}
if reqData.Password != "" || user.Disabled != originalDisabled || user.EffectiveRole() != originalRole {
user.SessionVersion = originalSessionVersion + 1
user.SecurityVersion = originalSecurityVersion + 1
}
credentialService := credentialRuntime()
if reqData.Password != "" && credentialService == nil {
log.WithContext(ctx).Error("credential service unavailable during user password reset")
response.NewErrorResponse(response.ServerError, "安全凭据服务不可用", "").FPrint(w)
return
}
tx := db.Instance.NewSession()
if tx == nil {
response.NewErrorResponse(response.ServerError, "更新用户失败", "").FPrint(w)
return
}
defer tx.Close()
if err := tx.Begin(); err != nil {
log.WithContext(ctx).WithError(err).Error("begin user update transaction failed")
response.NewErrorResponse(response.ServerError, "更新用户失败", "").FPrint(w)
return
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
}
}()
updateColumns := []string{"name", "disabled", "role", "is_admin"}
if reqData.Password != "" {
updateColumns = append(updateColumns, "password", "must_change_password", "password_changed_at")
}
if reqData.Password != "" || user.Disabled != originalDisabled || user.EffectiveRole() != originalRole {
updateColumns = append(updateColumns, "session_version", "security_version")
}
if num, err := tx.ID(user.ID).Where("session_version = ? AND security_version = ?", originalSessionVersion, originalSecurityVersion).Cols(updateColumns...).Update(&user); err != nil {
log.WithContext(ctx).WithError(err).Error("update user failed")
response.NewErrorResponse(response.ServerError, "更新用户失败", "").FPrint(w)
return
} else if num == 0 {
response.NewErrorResponse(response.ServerError, "没有可更新的数据", "").FPrint(w)
return
}
if reqData.Password != "" {
if err := credentialService.RevokeAllWithSession(tx, user.ID); err != nil {
log.WithContext(ctx).WithError(err).Error("revoke target credentials failed")
response.NewErrorResponse(response.ServerError, "撤销目标用户安全凭据失败", "").FPrint(w)
return
}
}
if err := tx.Commit(); err != nil {
log.WithContext(ctx).WithError(err).Error("commit user update failed")
response.NewErrorResponse(response.ServerError, "更新用户失败", "").FPrint(w)
return
}
committed = true
if user.SessionVersion != originalSessionVersion {
InvalidateSecurityRuntimeUser(user.ID, user.Account)
}
response.NewSuccessResponse(user).FPrint(w)
}
// writeUserAuthorizationError 保留授权限制,并让界面按错误类型继续身份确认。
func writeUserAuthorizationError(w http.ResponseWriter, err error, recoveryMessage string) {
if access.IsDenied(err, access.CodeRecentAuthRequired) {
writeSecurityErrorWithCode(w, response.NoAccessPrivileges, "请确认当前密码后继续操作", string(access.CodeRecentAuthRequired))
return
}
if access.IsDenied(err, access.CodeRecoveryCodeRequired) {
writeSecurityErrorWithCode(w, response.NoAccessPrivileges, recoveryMessage, string(access.CodeRecoveryCodeRequired))
return
}
writeSecurityError(w, response.NoAccessPrivileges, "无权执行该用户管理操作")
}