feat(backend): 支持外部邮箱启用/删除接口及用户账号级联删除接口
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-10-17 22:28:58 +08:00
parent 00f3283a4a
commit 583b9e88b7
7 changed files with 190 additions and 1 deletions

View File

@ -1168,6 +1168,39 @@ func VerifyOwnExternalEmail(ctx *context.Context, w http.ResponseWriter, req *ht
response.NewSuccessResponse(map[string]any{"verified": true}).FPrint(w)
}
func DeleteExternalEmail(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
runtime := currentSecurityRuntime()
if ctx == nil || ctx.UserID <= 0 || runtime.external == nil {
writeSecurityError(w, response.NeedLogin, "请先登录")
return
}
var input externalEmailRequest
if err := decodeJSONRequest(w, req, &input); err != nil || input.ID <= 0 {
writeSecurityError(w, response.ParamsError, "请求无效")
return
}
if err := runtime.external.Delete(ctx.UserID, input.ID); err != nil {
writeSecurityError(w, response.ParamsError, "外部邮箱删除失败")
return
}
if runtime.verification != nil && runtime.verification.Store != nil {
runtime.verification.Store.InvalidateResource(input.ID, verification.PurposeExternalEmailBind)
runtime.verification.Store.InvalidateResource(input.ID, verification.PurposeExternalEmailChange)
runtime.verification.Store.InvalidateAccount(ctx.UserAccount)
}
if runtime.recovery != nil {
runtime.recovery.InvalidateUser(ctx.UserID)
}
loginVerificationState.Lock()
for id, item := range loginVerificationState.items {
if item.UserID == ctx.UserID {
delete(loginVerificationState.items, id)
}
}
loginVerificationState.Unlock()
response.NewSuccessResponse(map[string]any{"deleted": true}).FPrint(w)
}
func DisableExternalEmail(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
runtime := currentSecurityRuntime()
if ctx == nil || ctx.UserID <= 0 || runtime.external == nil {

View File

@ -603,3 +603,139 @@ func writeUserAuthorizationError(w http.ResponseWriter, err error, recoveryMessa
}
writeSecurityError(w, response.NoAccessPrivileges, "无权执行该用户管理操作")
}
type userDeleteRequest struct {
Id int `json:"id"`
Account string `json:"account"`
}
func DeleteUser(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
var reqData userDeleteRequest
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
}
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
}
if ctx != nil && ctx.UserID == user.ID {
response.NewErrorResponse(response.ParamsError, "不能删除当前登录的账号", "").FPrint(w)
return
}
targetRole := user.EffectiveRole()
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 targetRole == access.RoleSuperAdmin {
var superAdminCount int64
superAdminCount, err = db.Instance.Where("role = ? OR is_admin = 1", string(access.RoleSuperAdmin)).Count(&models.User{})
if err != nil {
log.WithContext(ctx).WithError(err).Error("count super admin failed")
response.NewErrorResponse(response.ServerError, "检查超级管理员数量失败", "").FPrint(w)
return
}
if superAdminCount <= 1 {
response.NewErrorResponse(response.ParamsError, "系统必须保留至少一个超级管理员,无法删除", "").FPrint(w)
return
}
}
writeGuard := accountWriteGuard(ctx, user)
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 delete transaction failed")
response.NewErrorResponse(response.ServerError, "删除用户失败", "").FPrint(w)
return
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
}
}()
if err := accountgroup.CheckWrite(tx, writeGuard); err != nil {
_ = tx.Rollback()
writeAccountGroupError(w, err)
return
}
credentialService := credentialRuntime()
if credentialService != nil {
_ = credentialService.RevokeAllWithSession(tx, user.ID)
}
_, _ = tx.Where("user_id = ?", user.ID).Delete(&models.AccountGroupMember{})
_, _ = tx.Where("user_id = ?", user.ID).Delete(&models.ExternalEmail{})
_, _ = tx.Where("user_id = ?", user.ID).Delete(&models.RecoveryCode{})
_, _ = tx.Where("user_id = ?", user.ID).Delete(&models.WebAuthnCredential{})
_, _ = tx.Where("user_id = ?", user.ID).Delete(&models.ClientCredential{})
_, _ = tx.Where("user_id = ?", user.ID).Delete(&models.ProtocolCredential{})
_, _ = tx.Where("user_id = ?", user.ID).Delete(&models.NotificationGrant{})
_, _ = tx.Where("user_id = ?", user.ID).Delete(&models.UserEmail{})
_, _ = tx.Where("user_id = ?", user.ID).Delete(&models.UserContact{})
_, _ = tx.Where("user_id = ?", user.ID).Delete(&models.Rule{})
_, _ = tx.Where("user_id = ?", user.ID).Delete(&models.Group{})
deleteCond := tx.ID(user.ID)
accountgroup.ApplyScope(deleteCond, ctx.UserID, ctx.EffectiveRole())
num, err := deleteCond.Delete(&models.User{})
if err != nil {
_ = tx.Rollback()
log.WithContext(ctx).WithError(err).Error("delete user failed")
response.NewErrorResponse(response.ServerError, "删除用户失败", "").FPrint(w)
return
}
if num == 0 {
_ = tx.Rollback()
response.NewErrorResponse(response.ServerError, "用户删除未生效", "").FPrint(w)
return
}
if err := tx.Commit(); err != nil {
_ = tx.Rollback()
log.WithContext(ctx).WithError(err).Error("commit user delete failed")
response.NewErrorResponse(response.ServerError, "删除用户失败", "").FPrint(w)
return
}
committed = true
InvalidateSecurityRuntimeUser(user.ID, user.Account)
response.NewSuccessResponse(map[string]any{"deleted": true, "id": user.ID, "account": user.Account}).FPrint(w)
}

View File

@ -54,6 +54,7 @@ func registerV1Routes(mux *http.ServeMux) {
registerV1MethodRoute(mux, "/api/v1/security/external-email/change", "/api/security/external-email/change", http.MethodPost, 16<<10, controllers.ChangeOwnExternalEmail)
registerV1MethodRoute(mux, "/api/v1/security/external-email/change/verify", "/api/security/external-email/change/verify", http.MethodPost, 16<<10, controllers.VerifyOwnExternalEmail)
registerV1MethodRoute(mux, "/api/v1/security/external-email/disable", "/api/security/external-email/disable", http.MethodPost, 16<<10, controllers.DisableExternalEmail)
registerV1MethodRoute(mux, "/api/v1/security/external-email/delete", "/api/security/external-email/delete", http.MethodPost, 16<<10, controllers.DeleteExternalEmail)
registerV1MethodRoute(mux, "/api/v1/logout", "/api/logout", http.MethodPost, 4<<10, controllers.Logout)
registerV1MethodRoute(mux, "/api/v1/system/restart", "/api/v1/system/restart", http.MethodPost, 4<<10, controllers.RestartSmail)
registerV1MethodRoute(mux, "/api/v1/system/restart/preview", "/api/v1/system/restart/preview", http.MethodPost, 4<<10, controllers.PreviewRestartSmail)
@ -83,6 +84,7 @@ func registerV1Routes(mux *http.ServeMux) {
registerV1MethodRoute(mux, "/api/v1/rule/del", "/api/rule/del", http.MethodPost, 64<<10, controllers.DelRule)
registerV1MethodRoute(mux, "/api/v1/user/create", "/api/user/create", http.MethodPost, 64<<10, controllers.CreateUser)
registerV1MethodRoute(mux, "/api/v1/user/edit", "/api/user/edit", http.MethodPost, 64<<10, controllers.EditUser)
registerV1MethodRoute(mux, "/api/v1/user/delete", "/api/user/delete", http.MethodPost, 64<<10, controllers.DeleteUser)
registerV1MethodRoute(mux, "/api/v1/user/profile", "/api/user/profile", http.MethodPost, 64<<10, controllers.UpdateProfile)
registerV1MethodRoute(mux, "/api/v1/user/info", "/api/user/info", http.MethodPost, 64<<10, controllers.Info)
registerV1MethodRoute(mux, "/api/v1/user/list", "/api/user/list", http.MethodPost, 64<<10, controllers.UserList)

View File

@ -96,6 +96,7 @@ func router(mux *http.ServeMux) error {
mux.HandleFunc("/api/security/external-email/change", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.ChangeOwnExternalEmail))))
mux.HandleFunc("/api/security/external-email/change/verify", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.VerifyOwnExternalEmail))))
mux.HandleFunc("/api/security/external-email/disable", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.DisableExternalEmail))))
mux.HandleFunc("/api/security/external-email/delete", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.DeleteExternalEmail))))
mux.HandleFunc("/api/logout", onlyMethod(http.MethodPost, limitBody(4<<10, contextIterceptor(controllers.Logout))))
mux.HandleFunc("/api/group", onlyMethod(http.MethodGet, contextIterceptor(controllers.GetUserGroup)))
mux.HandleFunc("/api/group/list", onlyMethod(http.MethodGet, contextIterceptor(controllers.GetUserGroupList)))
@ -136,6 +137,7 @@ func router(mux *http.ServeMux) error {
mux.HandleFunc("/attachments/download/", onlyMethod(http.MethodGet, contextIterceptor(controllers.Download)))
mux.HandleFunc("/api/user/create", onlyMethod(http.MethodPost, limitBody(64<<10, contextIterceptor(controllers.CreateUser))))
mux.HandleFunc("/api/user/edit", onlyMethod(http.MethodPost, limitBody(64<<10, contextIterceptor(controllers.EditUser))))
mux.HandleFunc("/api/user/delete", onlyMethod(http.MethodPost, limitBody(64<<10, contextIterceptor(controllers.DeleteUser))))
mux.HandleFunc("/api/user/profile", onlyMethod(http.MethodPost, limitBody(64<<10, contextIterceptor(controllers.UpdateProfile))))
mux.HandleFunc("/api/user/info", onlyMethod(http.MethodPost, limitBody(64<<10, contextIterceptor(controllers.Info))))
mux.HandleFunc("/api/user/list", onlyMethod(http.MethodPost, limitBody(64<<10, contextIterceptor(controllers.UserList))))

View File

@ -23,6 +23,7 @@ var managementEndpoints = []Endpoint{
{Method: "POST", Path: "/api/user/create", Capability: CapabilityUserCreate},
{Method: "POST", Path: "/api/user/profile", Capability: CapabilityProfileWriteOwn},
{Method: "POST", Path: "/api/user/edit", Capability: CapabilityUserManage},
{Method: "POST", Path: "/api/user/delete", Capability: CapabilityUserManage},
{Method: "*", Path: "/api/user/security/", Capability: CapabilityUserSecurityManage},
{Method: "POST", Path: "/api/user/info", Capability: CapabilityUserRead},
{Method: "POST", Path: "/api/user/list", Capability: CapabilityUserRead},
@ -51,6 +52,7 @@ var managementEndpoints = []Endpoint{
{Method: "POST", Path: "/api/v1/user/create", Capability: CapabilityUserCreate},
{Method: "POST", Path: "/api/v1/user/profile", Capability: CapabilityProfileWriteOwn},
{Method: "POST", Path: "/api/v1/user/edit", Capability: CapabilityUserManage},
{Method: "POST", Path: "/api/v1/user/delete", Capability: CapabilityUserManage},
{Method: "*", Path: "/api/v1/user/security/", Capability: CapabilityUserSecurityManage},
{Method: "POST", Path: "/api/v1/user/info", Capability: CapabilityUserRead},
{Method: "POST", Path: "/api/v1/user/list", Capability: CapabilityUserRead},

View File

@ -411,6 +411,20 @@ func (r *Repository) AbandonPending(userID, id int) error {
return nil
}
func (r *Repository) Delete(userID, id int) error {
if r == nil || r.engine == nil || userID <= 0 || id <= 0 {
return ErrInvalidBinding
}
affected, err := r.engine.Where("id = ? AND user_id = ?", id, userID).Delete(&models.ExternalEmail{})
if err != nil {
return fmt.Errorf("delete external email binding: %w", err)
}
if affected == 0 {
return ErrBindingOwnership
}
return nil
}
func (r *Repository) Disable(userID, id int) error {
if r == nil || r.engine == nil || userID <= 0 || id <= 0 {
return ErrInvalidBinding

View File

@ -57,7 +57,7 @@
<el-icon><Setting /></el-icon>
<span class="settings-label">{{ lang.settings }}</span>
</div>
<span class="app-version-badge">V2.11.1</span>
<span class="app-version-badge">V2.12.0</span>
</button>
</div>
</div>