package controllers import ( "errors" "net/http" "strconv" "strings" "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/externalemail" "git.pcux.cn/xuans/Smail/services/verification" "git.pcux.cn/xuans/Smail/utils/context" log "github.com/sirupsen/logrus" ) type managedUserSecurityRequest struct { UserID int `json:"user_id"` Count int `json:"count"` Email string `json:"email"` Type string `json:"type"` ChallengeID string `json:"challenge_id"` BindingID int `json:"binding_id"` Code string `json:"code"` } func loadManagedSecurityUser(ctx *context.Context, w http.ResponseWriter, userID int) (*models.User, bool) { if ctx == nil || ctx.UserID <= 0 { writeSecurityError(w, response.NeedLogin, "请先登录") return nil, false } if db.Instance == nil || userID <= 0 { writeSecurityError(w, response.ParamsError, "目标用户无效") return nil, false } var target models.User found, err := db.Instance.ID(userID).Get(&target) if err != nil { log.WithContext(ctx).WithError(err).Error("load managed security user failed") writeSecurityError(w, response.ServerError, "读取目标用户失败") return nil, false } if !found || target.ID <= 0 { writeSecurityError(w, response.ParamsError, "目标用户不存在") return nil, false } if err := authorizeCapability(ctx, access.CapabilityUserSecurityManage, target.EffectiveRole(), false); err != nil { if access.IsDenied(err, access.CodeRecentAuthRequired) { writeSecurityErrorWithCode(w, response.NoAccessPrivileges, "此操作需要最近十分钟内完成登录,请确认当前密码", string(access.CodeRecentAuthRequired)) return nil, false } if access.IsDenied(err, access.CodeRecoveryCodeRequired) { writeSecurityErrorWithCode(w, response.NoAccessPrivileges, "请先为当前管理员配置有效安全码", string(access.CodeRecoveryCodeRequired)) return nil, false } writeSecurityError(w, response.NoAccessPrivileges, "无权管理该用户的安全信息") return nil, false } return &target, true } func managedUserIDFromQuery(req *http.Request) int { if req == nil || req.URL == nil { return 0 } userID, _ := strconv.Atoi(strings.TrimSpace(req.URL.Query().Get("id"))) return userID } // GetManagedRecoveryCodeStatus 返回目标用户安全码状态,不返回任何明文或哈希。 func GetManagedRecoveryCodeStatus(ctx *context.Context, w http.ResponseWriter, req *http.Request) { target, ok := loadManagedSecurityUser(ctx, w, managedUserIDFromQuery(req)) if !ok { return } service := credentialRuntime() if service == nil { writeSecurityError(w, response.ServerError, "安全凭据服务不可用") return } configured, err := service.HasValidRecoveryCode(target.ID) if err != nil { log.WithContext(ctx).WithError(err).Error("load managed recovery code status failed") writeSecurityError(w, response.ServerError, "读取安全码状态失败") return } response.NewSuccessResponse(map[string]any{ "user_id": target.ID, "account": target.Account, "configured": configured, }).FPrint(w) } // GenerateManagedRecoveryCodes 为目标用户生成只显示一次的安全码。 func GenerateManagedRecoveryCodes(ctx *context.Context, w http.ResponseWriter, req *http.Request) { var input managedUserSecurityRequest if err := decodeUserRequest(w, req, &input); err != nil { writeSecurityError(w, response.ParamsError, "请求无效") return } target, ok := loadManagedSecurityUser(ctx, w, input.UserID) if !ok { return } service := credentialRuntime() if service == nil { writeSecurityError(w, response.ServerError, "安全凭据服务不可用") return } codes, err := service.GenerateRecoveryCodesContext(recoveryRequestContext(req), target.ID, input.Count) if err != nil { if writePasswordBusy(w, err) { return } log.WithContext(ctx).WithError(err).Error("generate managed recovery codes failed") writeSecurityError(w, response.ParamsError, "安全码生成失败") return } InvalidateSecurityRuntimeUser(target.ID, target.Account) response.NewSuccessResponse(map[string]any{ "user_id": target.ID, "codes": codes, "shown_once": true, }).FPrint(w) } // RevokeManagedRecoveryCodes 撤销目标用户现有的全部安全码。 func RevokeManagedRecoveryCodes(ctx *context.Context, w http.ResponseWriter, req *http.Request) { var input managedUserSecurityRequest if err := decodeUserRequest(w, req, &input); err != nil { writeSecurityError(w, response.ParamsError, "请求无效") return } target, ok := loadManagedSecurityUser(ctx, w, input.UserID) if !ok { return } service := credentialRuntime() if service == nil { writeSecurityError(w, response.ServerError, "安全凭据服务不可用") return } if err := service.RevokeRecoveryCodes(target.ID); err != nil { log.WithContext(ctx).WithError(err).Error("revoke managed recovery codes failed") writeSecurityError(w, response.ServerError, "撤销安全码失败") return } InvalidateSecurityRuntimeUser(target.ID, target.Account) response.NewSuccessResponse(map[string]any{"user_id": target.ID, "revoked": true}).FPrint(w) } // ListManagedExternalEmails 返回目标用户已验证的外部邮箱掩码。 func ListManagedExternalEmails(ctx *context.Context, w http.ResponseWriter, req *http.Request) { target, ok := loadManagedSecurityUser(ctx, w, managedUserIDFromQuery(req)) if !ok { return } runtime := currentSecurityRuntime() if runtime.external == nil { writeSecurityError(w, response.ServerError, "外部邮箱服务不可用") return } items, err := runtime.external.List(target.ID) if err != nil { log.WithContext(ctx).WithError(err).Error("list managed external emails failed") writeSecurityError(w, response.ServerError, "读取外部邮箱失败") return } result := make([]map[string]any, 0, len(items)) for _, item := range items { if item.Status != models.ExternalEmailStatusVerified { continue } email, decryptErr := runtime.external.Decrypt(item) if decryptErr != nil { continue } result = append(result, map[string]any{ "id": item.ID, "type": item.EmailType, "status": item.Status, "email": maskEmail(email), }) } response.NewSuccessResponse(map[string]any{ "user_id": target.ID, "account": target.Account, "items": result, }).FPrint(w) } // ChangeManagedExternalEmail 发送目标用户外部邮箱变更验证码。 func ChangeManagedExternalEmail(ctx *context.Context, w http.ResponseWriter, req *http.Request) { var input managedUserSecurityRequest if err := decodeUserRequest(w, req, &input); err != nil { writeSecurityError(w, response.ParamsError, "请求无效") return } target, ok := loadManagedSecurityUser(ctx, w, input.UserID) if !ok { return } runtime := currentSecurityRuntime() if runtime.external == nil || runtime.verification == nil { writeSecurityError(w, response.ServerError, "外部邮箱验证服务不可用") return } normalizedEmail, err := externalemail.NormalizeEmail(input.Email) if err != nil { writeSecurityError(w, response.ParamsError, "请输入有效的外部邮箱地址") return } owned, err := currentUserPrimaryEmail(target.ID, normalizedEmail) if err != nil { log.WithContext(ctx).WithError(err).Error("check managed user primary email failed") writeSecurityError(w, response.ServerError, "外部邮箱变更失败") return } if owned { writeSecurityError(w, response.ParamsError, "不能将该用户自己的邮箱绑定为外部安全邮箱") return } emailType := strings.TrimSpace(input.Type) if emailType == "" { emailType = models.ExternalEmailTypePrimary } item, err := runtime.external.BeginReplacement(externalemail.BindRequest{ UserID: target.ID, Email: normalizedEmail, Type: emailType, }) if err != nil { if errors.Is(err, externalemail.ErrBindingExists) { writeSecurityError(w, response.ParamsError, "该外部邮箱已经绑定或正在被其他账号使用") return } log.WithContext(ctx).WithError(err).Error("begin managed external email replacement failed") writeSecurityError(w, response.ParamsError, "外部邮箱变更失败") return } if runtime.verification.Store != nil { runtime.verification.Store.InvalidateResource(item.ID, verification.PurposeExternalEmailChange) runtime.verification.Store.InvalidateResource(item.ID, verification.PurposeExternalEmailBind) } challenge, err := runtime.verification.Issue(verification.IssueRequest{ Purpose: verification.PurposeExternalEmailChange, UserID: target.ID, Account: target.Account, Email: normalizedEmail, ResourceID: item.ID, }, remoteIP(req)) if err != nil { log.WithContext(ctx).WithError(err).Warn("issue managed external email verification failed") writeSecurityError(w, response.ParamsError, externalEmailVerificationIssueMessage(err)) return } response.NewSuccessResponse(map[string]any{ "user_id": target.ID, "binding_id": item.ID, "challenge_id": challenge.ID, "email": maskEmail(normalizedEmail), "expires_at": challenge.ExpiresAt.UTC(), }).FPrint(w) } // VerifyManagedExternalEmail 验证成功后原子替换目标用户同用途的旧邮箱。 func VerifyManagedExternalEmail(ctx *context.Context, w http.ResponseWriter, req *http.Request) { var input managedUserSecurityRequest if err := decodeUserRequest(w, req, &input); err != nil || input.BindingID <= 0 { writeSecurityError(w, response.ParamsError, "验证请求无效") return } target, ok := loadManagedSecurityUser(ctx, w, input.UserID) if !ok { return } runtime := currentSecurityRuntime() if runtime.external == nil || runtime.verification == nil { writeSecurityError(w, response.ServerError, "外部邮箱验证服务不可用") return } normalizedEmail, err := externalemail.NormalizeEmail(input.Email) if err != nil { writeSecurityError(w, response.ParamsError, "验证失败") return } if err := runtime.verification.VerifyBound( input.ChallengeID, target.Account, normalizedEmail, input.Code, verification.PurposeExternalEmailChange, input.BindingID, remoteIP(req), ); err != nil { writeSecurityError(w, response.ParamsError, externalEmailVerificationCheckMessage(err)) return } if err := runtime.external.VerifyReplacement(target.ID, input.BindingID); err != nil { log.WithContext(ctx).WithError(err).Error("verify managed external email replacement failed") writeSecurityError(w, response.ParamsError, "外部邮箱替换失败") return } InvalidateSecurityRuntimeUser(target.ID, target.Account) response.NewSuccessResponse(map[string]any{"user_id": target.ID, "verified": true}).FPrint(w) }