feat: 新增修改密码和注销账号功能

新增修改密码和注销账号的API接口,实现用户通过验证旧密码修改密码,以及通过短信验证流程注销账号的功能,同时补充相关常量和文档说明。
This commit is contained in:
Austin
2026-09-29 16:13:32 +08:00
parent cf9d559944
commit ab73b69870
5 changed files with 205 additions and 10 deletions
+154 -2
View File
@@ -1704,6 +1704,155 @@ func serveIMFeedback(wrt http.ResponseWriter, req *http.Request) {
writeIMSuccess(wrt, req, feedbackSubmissionPayload(feedback))
}
type imMePasswordChangeRequest struct {
OldPassword string `json:"old_password"`
NewPassword string `json:"new_password"`
}
// serveIMMePasswordChange lets an authenticated user change own password by
// presenting the current one. All sessions are evicted afterwards.
func serveIMMePasswordChange(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
var body imMePasswordChangeRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
if strings.TrimSpace(body.OldPassword) == "" || strings.TrimSpace(body.NewPassword) == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
basicAuth := store.Store.GetLogicalAuthHandler("basic")
if basicAuth == nil || !basicAuth.IsInitialized() {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
login, authLevel, _, _, err := store.Users.GetAuthRecord(principal.TenantID, principal.Uid)
if err != nil || login == "" {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
authCtx := auth.AuthContext{TenantID: principal.TenantID, RemoteAddr: req.RemoteAddr}
if _, _, err = basicAuth.Authenticate(authCtx, []byte(login+":"+body.OldPassword)); err != nil {
writeIMError(wrt, req, http.StatusUnauthorized, 30001, imReasonAuthRequired, "invalid credentials", nil)
return
}
if _, err = basicAuth.UpdateRecord(authCtx, &auth.Rec{
TenantID: principal.TenantID,
Uid: principal.Uid,
AuthLevel: authLevel,
}, []byte(":"+body.NewPassword)); err != nil {
if err == types.ErrPolicy {
writeIMError(wrt, req, http.StatusBadRequest, 30040, imReasonPasswordPolicy, "password policy violation", nil)
return
}
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
now := time.Now().UTC().Round(time.Millisecond)
if store.Security != nil {
if _, err = store.Security.BumpAuthVersion(principal.TenantID, principal.Uid, now); err != nil {
logs.Warn.Println("im me password change: bump auth version failed", err)
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
}
if globals.sessionStore != nil {
globals.sessionStore.EvictUser(principal.Uid, "")
}
publishRealtimeEvictUser(principal.TenantID, principal.Uid, "password_changed", "")
writeIMSuccess(wrt, req, map[string]any{"status": "ok"})
}
// serveIMMeDeletion deletes the authenticated user's account. Requires a
// confirmed "delete_account" sensitive-action token obtained through
// POST /v1/im/security/challenges + POST /v1/im/security/challenges/{id}.
func serveIMMeDeletion(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
if globals.permanentAccounts {
writeIMError(wrt, req, http.StatusForbidden, 30040, imReasonForbidden, "account deletion is disabled", nil)
return
}
if !consumeIMActionTokenHeader(wrt, req, principal, "delete_account") {
return
}
tenantID, uid := principal.TenantID, principal.Uid
if state, err := userGetState(tenantID, uid); err != nil || state == types.StateDeleted {
logs.Warn.Println("im me deletion: user is missing or already deleted", err)
writeIMError(wrt, req, http.StatusNotFound, 20004, imReasonResourceNotFound, "user not found", nil)
return
}
// Disable all authenticators.
for _, name := range store.Store.GetAuthNames() {
hdl := store.Store.GetLogicalAuthHandler(name)
if !hdl.IsInitialized() {
continue
}
if err := hdl.DelRecords(tenantID, uid); err != nil {
// This could be completely benign, i.e. authenticator exists but not used.
logs.Warn.Println("im me deletion: failed to delete auth record", uid.UserId(), name, err)
if storeErr, ok := err.(types.StoreError); ok && storeErr == types.ErrUnsupported {
writeIMError(wrt, req, http.StatusForbidden, 30040, imReasonForbidden, "account cannot be deleted", nil)
return
}
}
}
// Terminate all sessions.
if globals.sessionStore != nil {
globals.sessionStore.EvictUser(uid, "", "user_deleted")
}
publishRealtimeEvictUser(tenantID, uid, "user_deleted", "")
// Remove user from cache and announce to cluster that the user is deleted.
usersRemoveUser(tenantID, uid)
// Stop topics where the user is the owner and p2p topics.
done := make(chan bool)
globals.hub.unreg <- &topicUnreg{tenantID: tenantID, forUser: uid, del: false, done: done}
<-done
// Notify users of interest that the user is gone.
if uoi, err := store.Users.GetSubs(tenantID, uid); err == nil {
presUsersOfInterestOffline(tenantID, uid, uoi, "gone")
} else {
logs.Warn.Println("im me deletion: failed to send notifications to users", err)
}
// Notify subscribers of the group topics where the user was the owner.
if ownTopics, err := store.Users.GetOwnTopics(tenantID, uid); err == nil {
for _, topicName := range ownTopics {
if subs, err := store.Topics.GetSubs(tenantID, topicName, nil); err == nil {
presSubsOfflineOffline(tenantID, topicName, types.TopicCatGrp, subs, "gone", &presParams{}, "")
} else {
logs.Warn.Println("im me deletion: failed to notify topic subscribers", err, topicName)
}
}
} else {
logs.Warn.Println("im me deletion: failed to send notifications to owned topics", err)
}
// Delete user's records from the database (soft delete).
if err := store.Users.Delete(tenantID, uid, false); err != nil {
logs.Warn.Println("im me deletion: failed to delete user", err)
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
writeIMSuccess(wrt, req, map[string]any{"status": "ok"})
}
func serveIMSecurityChallenges(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
@@ -6842,6 +6991,8 @@ func defaultIMActionTarget(action, fallback string) string {
return "youth_mode"
case "group_owner_transfer", "group_dissolve":
return action
case "delete_account":
return "account"
default:
return fallback
}
@@ -8516,7 +8667,7 @@ func currentUserAccountPayload(tenantID types.TenantID, uid types.Uid) (map[stri
func validIMSecurityChallengeScene(scene string) bool {
switch strings.TrimSpace(scene) {
case "set_independent_password", "enable_youth_mode", "close_youth_mode", "reset_independent_password",
"group_owner_transfer", "group_dissolve":
"group_owner_transfer", "group_dissolve", "delete_account":
return true
default:
return false
@@ -10599,6 +10750,7 @@ func cleanStringList(values []string, limit int) []string {
func isAllowedPublicUploadPurpose(purpose string) bool {
switch strings.TrimSpace(purpose) {
case types.PublicUploadPurposeEnterpriseLicense, types.PublicUploadPurposeTenantFeedback,
types.PublicUploadPurposeFeedback,
types.PublicUploadPurposeUserAvatar,
types.PublicUploadPurposeGroupAvatar:
return true
@@ -10615,7 +10767,7 @@ func isAllowedPublicUploadMime(purpose, mimeType string) bool {
strings.HasPrefix(mimeType, "image/png") ||
strings.HasPrefix(mimeType, "image/webp") ||
mimeType == "application/pdf"
case types.PublicUploadPurposeTenantFeedback:
case types.PublicUploadPurposeTenantFeedback, types.PublicUploadPurposeFeedback:
return strings.HasPrefix(mimeType, "image/jpeg") ||
strings.HasPrefix(mimeType, "image/png") ||
strings.HasPrefix(mimeType, "image/webp")