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, "参数错误", "").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) 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 _, err := db.Instance.Insert(&user); err != nil { log.WithContext(ctx).WithError(err).Error("create user failed") response.NewErrorResponse(response.ServerError, "创建用户失败", "").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, "无权访问", "").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() 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, "参数错误", "").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() 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 { 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, "无权执行该用户管理操作") }