Smail/backend/controllers/user.go
smail 6761ab2091
Some checks are pending
Security Checks / security (push) Waiting to run
补齐新增账号配置入口及分组事务保存
2026-09-26 12:05:47 +08:00

565 lines
20 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/accountgroup"
"git.pcux.cn/xuans/Smail/services/auth"
"git.pcux.cn/xuans/Smail/session"
"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 {
GroupIDs *[]int `json:"group_ids"`
Id int `json:"id"`
Account string `json:"account"`
Username string `json:"username"`
Password string `json:"password"`
Disabled int `json:"disabled"`
Role string `json:"role"`
}
type userEditRequest struct {
userCreateRequest
GroupIDs *[]int `json:"group_ids"`
MembershipVersion int `json:"membership_version"`
}
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, "参数错误", "").FPrint(w)
return
}
if reqData.Disabled != 0 && reqData.Disabled != 1 {
response.NewErrorResponse(response.ParamsError, "参数错误", "").FPrint(w)
return
}
account, err := validation.NormalizeAccount(reqData.Account)
if err != nil {
response.NewErrorResponse(response.ParamsError, "参数错误", "").FPrint(w)
return
}
username, err := validation.NormalizeDisplayName(reqData.Username)
if err != nil {
response.NewErrorResponse(response.ParamsError, "参数错误", "").FPrint(w)
return
}
user := models.User{Name: username, Account: account, Disabled: reqData.Disabled, SessionVersion: 1, SecurityVersion: 1}
user.ApplyRole(targetRole)
groupIDs := []int{0}
if reqData.GroupIDs != nil {
groupIDs = *reqData.GroupIDs
}
if len(groupIDs) > 0 && (len(groupIDs) != 1 || groupIDs[0] != 0) {
if !requireAccountGroupManagement(ctx, w) {
return
}
}
if err := auth.SetPassword(&user, reqData.Password); err != nil {
if writePasswordBusy(w, err) {
return
}
if errors.Is(err, password.ErrInvalidLength) {
response.NewErrorResponse(response.ParamsError, "密码长度必须为 8-12 字节", "").FPrint(w)
return
}
log.WithContext(ctx).WithError(err).Error("password hash failed")
response.NewErrorResponse(response.ServerError, "密码设置失败", "").FPrint(w)
return
}
if db.Instance == nil {
writeAccountGroupError(w, accountgroup.ErrDenied)
return
}
tx := db.Instance.NewSession()
defer tx.Close()
if err := tx.Begin(); err != nil {
writeAccountGroupError(w, err)
return
}
defer tx.Rollback()
actor := models.User{ID: ctx.UserID, SessionVersion: ctx.SessionVersion, SecurityVersion: ctx.SecurityVersion}
actor.ApplyRole(ctx.EffectiveRole())
if err := accountgroup.CheckWrite(tx, accountWriteGuard(ctx, actor)); err != nil {
writeAccountGroupError(w, err)
return
}
groupIDs, err = accountgroup.ValidateMembership(tx, user.EffectiveRole(), groupIDs)
if err != nil {
writeAccountGroupError(w, err)
return
}
if _, err := tx.Insert(&user); err != nil {
log.WithContext(ctx).WithError(err).Error("create user failed")
response.NewErrorResponse(response.ServerError, "创建用户失败", "").FPrint(w)
return
}
if err := accountgroup.ReplaceMembership(tx, user.ID, groupIDs); err != nil {
writeAccountGroupError(w, err)
return
}
if err := tx.Commit(); err != nil {
writeAccountGroupError(w, err)
return
}
response.NewSuccessResponse(struct {
models.User
GroupIDs []int
MembershipVersion int
}{user, groupIDs, user.SecurityVersion}).FPrint(w)
}
type userListRequest struct {
GroupID *int `json:"group_id"`
CurrentPage int `json:"current_page"`
PageSize int `json:"page_size"`
}
type userListItem struct {
GroupIDs []int `json:"GroupIDs,omitempty"`
MembershipVersion int `json:"MembershipVersion,omitempty"`
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, "无权访问", "").FPrint(w)
return
}
var reqData userListRequest
if err := decodeUserRequest(w, req, &reqData); err != nil {
response.NewErrorResponse(response.ParamsError, "参数错误", "").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()
accountgroup.ApplyScope(query, ctx.UserID, ctx.EffectiveRole())
if reqData.GroupID != nil {
groups, err := accountgroup.VisibleGroups(db.Instance, ctx.UserID, ctx.EffectiveRole())
if err != nil {
writeAccountGroupError(w, err)
return
}
allowed := false
for _, group := range groups {
if group.ID == *reqData.GroupID {
allowed = true
}
}
if !allowed {
writeAccountGroupError(w, accountgroup.ErrDenied)
return
}
accountgroup.ApplyGroup(query, *reqData.GroupID)
}
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))
memberships := map[int][]int{}
if ctx.EffectiveRole() == access.RoleSuperAdmin {
ids := make([]int, 0, len(users))
for _, user := range users {
ids = append(ids, user.ID)
}
memberships, err = accountgroup.Memberships(db.Instance, ids)
if err != nil {
writeAccountGroupError(w, err)
return
}
}
for _, user := range users {
items = append(items, userListItem{
GroupIDs: memberships[user.ID],
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],
})
if ctx.EffectiveRole() == access.RoleSuperAdmin {
items[len(items)-1].MembershipVersion = user.SecurityVersion
}
}
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,
"domains": domains,
}
for key, value := range loginSecurityFields(&models.User{ID: ctx.UserID, Role: string(ctx.EffectiveRole())}) {
result[key] = value
}
if result["securitySetupRequired"] != true && session.Instance != nil && session.Instance.GetBool(req.Context(), session.SecuritySetupDismissedKey) {
result["showSecuritySetupPrompt"] = false
result["showRecoverySetupPrompt"] = false
}
response.NewSuccessResponse(result).FPrint(w)
}
func EditUser(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
var reqData userEditRequest
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, "参数错误", "").FPrint(w)
return
}
if reqData.Disabled != 0 && reqData.Disabled != 1 {
response.NewErrorResponse(response.ParamsError, "参数错误", "").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, "参数错误", "").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, "查询用户失败", "").FPrint(w)
return
}
if !found || user.ID == 0 {
response.NewErrorResponse(response.ParamsError, "用户不存在", "").FPrint(w)
return
}
targetRole := user.EffectiveRole()
writeGuard := accountWriteGuard(ctx, user)
if reqData.GroupIDs != nil {
if !requireAccountGroupManagement(ctx, w) {
return
}
if reqData.MembershipVersion < 1 || reqData.MembershipVersion != user.SecurityVersion {
writeAccountGroupError(w, accountgroup.ErrConflict)
return
}
}
if !requireAccountScope(ctx, w, user.ID) {
return
}
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, "不允许禁用管理员账户", "").FPrint(w)
return
}
if user.ID == ctx.UserID && reqData.Password != "" {
response.NewErrorResponse(response.ParamsError, "请通过安全设置修改当前账户密码", "").FPrint(w)
return
}
if reqData.Username != "" {
username, validationErr := validation.NormalizeDisplayName(reqData.Username)
if validationErr != nil {
response.NewErrorResponse(response.ParamsError, "参数错误", "").FPrint(w)
return
}
user.Name = username
}
user.Disabled = reqData.Disabled
if reqData.Password != "" {
if err := auth.SetPassword(&user, reqData.Password); err != nil {
if writePasswordBusy(w, err) {
return
}
if errors.Is(err, password.ErrInvalidLength) {
response.NewErrorResponse(response.ParamsError, "密码长度必须为 8-12 字节", "").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 || reqData.GroupIDs != nil {
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 err := accountgroup.CheckWrite(tx, writeGuard); err != nil {
_ = tx.Rollback()
writeAccountGroupError(w, err)
return
}
if reqData.Password != "" {
updateColumns = append(updateColumns, "password", "must_change_password", "password_changed_at")
}
if reqData.Password != "" || user.Disabled != originalDisabled || user.EffectiveRole() != originalRole || reqData.GroupIDs != nil {
updateColumns = append(updateColumns, "session_version", "security_version")
}
update := tx.ID(user.ID).Where("session_version = ? AND security_version = ?", originalSessionVersion, originalSecurityVersion).Cols(updateColumns...)
accountgroup.ApplyScope(update, ctx.UserID, ctx.EffectiveRole())
if num, err := update.Update(&user); err != nil {
_ = tx.Rollback()
log.WithContext(ctx).WithError(err).Error("update user failed")
response.NewErrorResponse(response.ServerError, "更新用户失败", "").FPrint(w)
return
} else if num == 0 {
_ = tx.Rollback()
response.NewErrorResponse(response.ServerError, "没有可更新的数据", "").FPrint(w)
return
}
if reqData.Password != "" {
if err := credentialService.RevokeAllWithSession(tx, user.ID); err != nil {
_ = tx.Rollback()
log.WithContext(ctx).WithError(err).Error("revoke target credentials failed")
response.NewErrorResponse(response.ServerError, "撤销目标用户安全凭据失败", "").FPrint(w)
return
}
}
// 角色和分组在同一事务中校验与更新,任何失败均不能留下部分修改。
if reqData.GroupIDs != nil {
ids, err := accountgroup.ValidateMembership(tx, user.EffectiveRole(), *reqData.GroupIDs)
if err == nil {
err = accountgroup.ReplaceMembership(tx, user.ID, ids)
}
if err != nil {
_ = tx.Rollback()
writeAccountGroupError(w, err)
return
}
}
if user.EffectiveRole() != access.RoleAdmin {
count, err := tx.Where("user_id = ?", user.ID).Count(&models.AccountGroupMember{})
if err != nil {
_ = tx.Rollback()
writeAccountGroupError(w, err)
return
}
if count > 1 {
_ = tx.Rollback()
writeSecurityError(w, response.ParamsError, "请先将账号调整为一个分组,再更改为普通用户")
return
}
}
if err := tx.Commit(); err != nil {
_ = tx.Rollback()
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, "无权执行该用户管理操作")
}