431 lines
16 KiB
Go
431 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, "参数错误", "").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, "无权执行该用户管理操作")
|
|
}
|