package controllers import ( "crypto/rand" "encoding/hex" "errors" "net" "net/http" "net/url" "strconv" "strings" "sync" "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/services/credentials" "git.pcux.cn/xuans/Smail/services/externalemail" "git.pcux.cn/xuans/Smail/services/recovery" "git.pcux.cn/xuans/Smail/services/verification" webauthnservice "git.pcux.cn/xuans/Smail/services/webauthn" "git.pcux.cn/xuans/Smail/utils/context" log "github.com/sirupsen/logrus" ) // PublicRecoveryEnabled 判断公开自助恢复入口是否已经完整配置。 // 分区配置可用时,以分区配置作为唯一依据。 func PublicRecoveryEnabled() bool { if manager := config.CurrentManager(); manager != nil { runtime := manager.Snapshot() hosts := append([]string{runtime.Root.Domains.Web, runtime.Root.Domains.Primary}, runtime.Root.Domains.AccessHosts...) return runtime.SetupState == config.SetupStateReady && runtime.Email.Policy.ExternalRecoveryEnabled && hasConfiguredRecoveryHost(hosts...) } current := config.CurrentConfig() if current == nil || !current.IsSetupReady() || !current.ExternalRecoveryEnabled { return false } hosts := append([]string{current.WebDomain, current.Domain}, current.AccessHosts...) return hasConfiguredRecoveryHost(hosts...) } func hasConfiguredRecoveryHost(values ...string) bool { for _, value := range values { value = strings.TrimSpace(value) if value == "" || strings.ContainsAny(value, " \t\r\n/@\\?#") { continue } parsed, err := url.Parse("//" + value) port := parsed.Port() portOK := port == "" if port != "" { portNumber, portErr := strconv.Atoi(port) portOK = portErr == nil && portNumber > 0 && portNumber <= 65535 } if err == nil && portOK && parsed.Hostname() != "" && parsed.User == nil && parsed.Path == "" && parsed.RawQuery == "" && parsed.Fragment == "" { return true } } return false } func rejectUnavailableRecovery(w http.ResponseWriter) bool { if PublicRecoveryEnabled() { return false } writeSecurityError(w, response.ParamsError, "密码重置功能未启用") return true } type securityRuntimeState struct { verification *verification.Service external *externalemail.Repository recovery *recovery.Service credentials *credentials.Service webauthn *webauthnservice.Service } var securityRuntime securityRuntimeState var securityRuntimeMu sync.RWMutex // 每个请求持有同一份服务快照,发布锁不跨越数据库、投递和失效回调。 func currentSecurityRuntime() securityRuntimeState { securityRuntimeMu.RLock() defer securityRuntimeMu.RUnlock() return securityRuntime } // ConfigureSecurityRuntime 由主程序在数据库和内存安全服务就绪后调用。 func ConfigureSecurityRuntime(verify *verification.Service, external *externalemail.Repository, recover *recovery.Service, credential *credentials.Service) { if recover != nil { recover.SetSecurityStateInvalidator(InvalidateSecurityRuntimeUser) } securityRuntimeMu.Lock() defer securityRuntimeMu.Unlock() securityRuntime.verification = verify securityRuntime.external = external securityRuntime.recovery = recover securityRuntime.credentials = credential } func ConfigureWebAuthnRuntime(service *webauthnservice.Service) { securityRuntimeMu.Lock() defer securityRuntimeMu.Unlock() securityRuntime.webauthn = service } // ClearSecurityRuntime 在停止入口后一次撤下全部安全服务,避免保留已停止的旧库依赖。 func ClearSecurityRuntime() { securityRuntimeMu.Lock() securityRuntime = securityRuntimeState{} securityRuntimeMu.Unlock() loginVerificationState.Lock() clear(loginVerificationState.items) loginVerificationState.Unlock() } // InvalidateSecurityRuntimeUser 清除密码重置后仍驻留在内存中的安全流程状态。 func InvalidateSecurityRuntimeUser(userID int, account string) { if userID <= 0 { return } runtime := currentSecurityRuntime() if runtime.recovery != nil { runtime.recovery.InvalidateUser(userID) } if runtime.verification != nil && runtime.verification.Store != nil { runtime.verification.Store.InvalidateAccount(account) } if runtime.webauthn != nil { runtime.webauthn.InvalidateUser(userID) } loginVerificationState.Lock() for id, item := range loginVerificationState.items { if item.UserID == userID || strings.EqualFold(item.Account, account) { delete(loginVerificationState.items, id) } } loginVerificationState.Unlock() } func LoadSecurityContext(ctx *context.Context) { if ctx == nil || ctx.UserID <= 0 { return } if ctx.EffectiveRole() == access.RoleSuperAdmin { ctx.RecoveryCodeConfigured = false return } service := currentSecurityRuntime().credentials if service == nil && db.Instance != nil { service, _ = credentials.NewService(db.Instance) } if service == nil { return } configured, err := service.HasValidRecoveryCode(ctx.UserID) if err == nil { ctx.RecoveryCodeConfigured = configured } } func credentialRuntime() *credentials.Service { if service := currentSecurityRuntime().credentials; service != nil { if service.VaultRoot() == "" && config.ROOT_PATH != "" { service.SetVaultRoot(config.ROOT_PATH) } return service } if db.Instance == nil { return nil } service, _ := credentials.NewService(db.Instance) if service != nil && config.ROOT_PATH != "" { service.SetVaultRoot(config.ROOT_PATH) } return service } type verificationRequest struct { Purpose verification.Purpose `json:"purpose"` Identifier string `json:"identifier"` Account string `json:"account"` Email string `json:"email"` } type verificationCheckRequest struct { ID string `json:"id"` Method string `json:"method"` Purpose verification.Purpose `json:"purpose"` Identifier string `json:"identifier"` Account string `json:"account"` Email string `json:"email"` Code string `json:"code"` Question string `json:"question"` Answer string `json:"answer"` RecoveryCode string `json:"recovery_code"` Continuation string `json:"continuation"` BindingID int `json:"binding_id"` } type recoveryCompleteRequest struct { Ticket string `json:"ticket"` NewPassword string `json:"new_password"` } const recoveryContextCookieName = "smail_recovery_context" type externalEmailRequest struct { Email string `json:"email"` Type string `json:"type"` ID int `json:"id"` } type securityQuestionRequest struct { Question string `json:"question"` Answer string `json:"answer"` } type recoveryCodeRequest struct { Code string `json:"code"` Count int `json:"count"` } func rejectSuperAdminRecoveryCodes(ctx *context.Context, w http.ResponseWriter) bool { if ctx != nil && ctx.EffectiveRole() == access.RoleSuperAdmin { writeSecurityError(w, response.NoAccessPrivileges, "超级管理员恢复码只能通过部署端专用通道管理") return true } return false } // GetSecurityQuestion 返回当前用户的密保问题,不返回答案哈希。 func GetSecurityQuestion(ctx *context.Context, w http.ResponseWriter, req *http.Request) { if ctx == nil || ctx.UserID <= 0 { writeSecurityError(w, response.NeedLogin, "请先登录") return } service := credentialRuntime() if service == nil { writeSecurityError(w, response.ServerError, "安全凭据服务不可用") return } question, configured, err := service.GetSecurityQuestion(ctx.UserID) if err != nil { log.WithError(err).Error("load security question failed") writeSecurityError(w, response.ServerError, "读取密保问题失败") return } response.NewSuccessResponse(map[string]any{"configured": configured, "question": question}).FPrint(w) } // SetSecurityQuestion 设置或替换当前用户的密保问题。 func SetSecurityQuestion(ctx *context.Context, w http.ResponseWriter, req *http.Request) { if ctx == nil || ctx.UserID <= 0 { writeSecurityError(w, response.NeedLogin, "请先登录") return } var input securityQuestionRequest if err := decodeJSONRequest(w, req, &input); err != nil { writeSecurityError(w, response.ParamsError, "请求无效") return } service := credentialRuntime() if service == nil { writeSecurityError(w, response.ServerError, "安全凭据服务不可用") return } if err := service.SetSecurityQuestion(ctx.UserID, input.Question, input.Answer); err != nil { writeSecurityError(w, response.ParamsError, "密保问题设置失败") return } response.NewSuccessResponse(map[string]any{"configured": true}).FPrint(w) } // GenerateRecoveryCodes 生成一组只显示一次的离线恢复码。 func GenerateRecoveryCodes(ctx *context.Context, w http.ResponseWriter, req *http.Request) { if ctx == nil || ctx.UserID <= 0 { writeSecurityError(w, response.NeedLogin, "请先登录") return } if rejectSuperAdminRecoveryCodes(ctx, w) { return } var input recoveryCodeRequest if req != nil && req.Body != nil { if err := decodeJSONRequest(w, req, &input); err != nil { writeSecurityError(w, response.ParamsError, "请求无效") return } } service := credentialRuntime() if service == nil { writeSecurityError(w, response.ServerError, "安全凭据服务不可用") return } codes, err := service.GenerateRecoveryCodes(ctx.UserID, input.Count) if err != nil { writeSecurityError(w, response.ParamsError, "离线恢复码生成失败") return } ctx.RecoveryCodeConfigured = len(codes) > 0 response.NewSuccessResponse(map[string]any{"codes": codes, "shown_once": true}).FPrint(w) } // GetRecoveryCodeStatus 返回当前用户是否配置了有效离线恢复码。 func GetRecoveryCodeStatus(ctx *context.Context, w http.ResponseWriter, req *http.Request) { if ctx == nil || ctx.UserID <= 0 { writeSecurityError(w, response.NeedLogin, "请先登录") return } if rejectSuperAdminRecoveryCodes(ctx, w) { return } service := credentialRuntime() if service == nil { writeSecurityError(w, response.ServerError, "安全凭据服务不可用") return } configured, err := service.HasValidRecoveryCode(ctx.UserID) if err != nil { writeSecurityError(w, response.ServerError, "读取恢复码状态失败") return } response.NewSuccessResponse(map[string]any{"configured": configured}).FPrint(w) } // RevokeRecoveryCodes 撤销当前用户的全部有效离线恢复码。 func RevokeRecoveryCodes(ctx *context.Context, w http.ResponseWriter, req *http.Request) { if ctx == nil || ctx.UserID <= 0 { writeSecurityError(w, response.NeedLogin, "请先登录") return } if rejectSuperAdminRecoveryCodes(ctx, w) { return } service := credentialRuntime() if service == nil { writeSecurityError(w, response.ServerError, "安全凭据服务不可用") return } if err := service.RevokeRecoveryCodes(ctx.UserID); err != nil { writeSecurityError(w, response.ServerError, "撤销恢复码失败") return } ctx.RecoveryCodeConfigured = false response.NewSuccessResponse(map[string]any{"revoked": true}).FPrint(w) } // RequestVerification 统一处理登录、密码恢复和外部邮箱验证申请。 type protocolCredentialRequest struct { Protocol string `json:"protocol"` Enabled *bool `json:"enabled,omitempty"` } func requireProtocolCredentialContext(ctx *context.Context, w http.ResponseWriter) (*credentials.Service, bool) { if ctx == nil || ctx.UserID <= 0 { writeSecurityError(w, response.NeedLogin, "请先登录") return nil, false } service := credentialRuntime() if service == nil { writeSecurityError(w, response.ParamsError, "安全设置暂时不可用") return nil, false } return service, true } // ListProtocolCredentials 返回当前用户的协议开关和授权码状态。 func ListProtocolCredentials(ctx *context.Context, w http.ResponseWriter, req *http.Request) { service, ok := requireProtocolCredentialContext(ctx, w) if !ok { return } items, err := service.ListProtocolCredentials(ctx.UserID) if err != nil { log.WithError(err).Error("load protocol credentials failed") writeSecurityError(w, response.ParamsError, "读取邮件客户端协议设置失败") return } response.NewSuccessResponse(items).FPrint(w) } // SetProtocolCredentialEnabled 设置当前用户的邮件客户端协议开关,不改变服务监听状态。 func SetProtocolCredentialEnabled(ctx *context.Context, w http.ResponseWriter, req *http.Request) { service, ok := requireProtocolCredentialContext(ctx, w) if !ok { return } var input protocolCredentialRequest if err := decodeJSONRequest(w, req, &input); err != nil || input.Enabled == nil { writeSecurityError(w, response.ParamsError, "协议设置请求无效") return } if err := service.SetProtocolEnabled(ctx.UserID, input.Protocol, *input.Enabled); err != nil { writeSecurityError(w, response.ParamsError, "协议设置失败") return } response.NewSuccessResponse(map[string]any{"protocol": strings.ToLower(strings.TrimSpace(input.Protocol)), "enabled": *input.Enabled}).FPrint(w) } // GenerateProtocolCredential 为当前用户生成指定协议授权码,明文只返回一次。 func GenerateProtocolCredential(ctx *context.Context, w http.ResponseWriter, req *http.Request) { service, ok := requireProtocolCredentialContext(ctx, w) if !ok { return } var input protocolCredentialRequest if err := decodeJSONRequest(w, req, &input); err != nil || strings.TrimSpace(input.Protocol) == "" { writeSecurityError(w, response.ParamsError, "协议请求无效") return } code, err := service.GenerateProtocolCode(ctx.UserID, input.Protocol) if err != nil { writeSecurityError(w, response.ParamsError, "请先开启协议后再生成授权码") return } protocol, _ := credentials.NormalizeProtocol(input.Protocol) response.NewSuccessResponse(map[string]any{"protocol": protocol, "code": code, "shown_once": true}).FPrint(w) } // RevokeProtocolCredential 撤销当前用户指定协议的授权码。 func RevokeProtocolCredential(ctx *context.Context, w http.ResponseWriter, req *http.Request) { service, ok := requireProtocolCredentialContext(ctx, w) if !ok { return } var input protocolCredentialRequest if err := decodeJSONRequest(w, req, &input); err != nil || strings.TrimSpace(input.Protocol) == "" { writeSecurityError(w, response.ParamsError, "协议请求无效") return } if err := service.RevokeProtocolCode(ctx.UserID, input.Protocol); err != nil { writeSecurityError(w, response.ParamsError, "撤销授权码失败") return } protocol, _ := credentials.NormalizeProtocol(input.Protocol) response.NewSuccessResponse(map[string]any{"protocol": protocol, "revoked": true}).FPrint(w) } // RevealProtocolCredentials 在安全身份验证成功后解密并返回当前用户所有有效协议授权码。 func RevealProtocolCredentials(ctx *context.Context, w http.ResponseWriter, req *http.Request) { service, ok := requireProtocolCredentialContext(ctx, w) if !ok { return } var input struct { Password string `json:"password"` } if err := decodeJSONRequest(w, req, &input); err != nil || strings.TrimSpace(input.Password) == "" { writeSecurityError(w, response.ParamsError, "请输入当前账号密码进行安全验证") return } if _, err := auth.AuthenticateUserByID(ctx.UserID, input.Password); err != nil { writeSecurityError(w, response.ParamsError, "密码验证失败") return } codes, err := service.RevealProtocolCodes(ctx.UserID) if err != nil { writeSecurityError(w, response.ServerError, "解密授权码失败") return } response.NewSuccessResponse(codes).FPrint(w) } func RequestVerification(ctx *context.Context, w http.ResponseWriter, req *http.Request) { runtime := currentSecurityRuntime() if ctx == nil || ctx.UserID <= 0 { writeSecurityError(w, response.NeedLogin, "请先登录") return } var input verificationRequest if err := decodeJSONRequest(w, req, &input); err != nil { writeSecurityError(w, response.ParamsError, "请求无效") return } if input.Account == "" { input.Account = input.Identifier } if !strings.EqualFold(strings.TrimSpace(input.Account), strings.TrimSpace(ctx.UserAccount)) { writeSecurityError(w, response.ParamsError, "请求无效") return } if input.Email == "" || strings.TrimSpace(input.Account) == "" || !verification.IsSupportedPurpose(input.Purpose) || runtime.verification == nil { writeSecurityError(w, response.ParamsError, "请求已受理") return } challenge, err := runtime.verification.Issue(verification.IssueRequest{Purpose: input.Purpose, UserID: ctx.UserID, Account: input.Account, Email: input.Email}, remoteIP(req)) if err != nil { writeSecurityError(w, response.ParamsError, "请求已受理") return } response.NewSuccessResponse(map[string]any{"id": challenge.ID, "expires_at": challenge.ExpiresAt.UTC()}).FPrint(w) } // VerifyVerification 消费一次性验证码,只返回后续流程所需的短时票据。 func VerifyVerification(ctx *context.Context, w http.ResponseWriter, req *http.Request) { runtime := currentSecurityRuntime() if ctx == nil || ctx.UserID <= 0 { writeSecurityError(w, response.NeedLogin, "请先登录") return } var input verificationCheckRequest if err := decodeJSONRequest(w, req, &input); err != nil || runtime.verification == nil { writeSecurityError(w, response.ParamsError, "验证失败") return } if input.Account == "" { input.Account = input.Identifier } if !strings.EqualFold(strings.TrimSpace(input.Account), strings.TrimSpace(ctx.UserAccount)) { writeSecurityError(w, response.ParamsError, "验证失败") return } if err := runtime.verification.Verify(input.ID, input.Account, input.Email, input.Code, input.Purpose, remoteIP(req)); err != nil { writeSecurityError(w, response.ParamsError, "验证失败") return } response.NewSuccessResponse(map[string]any{"verified": true}).FPrint(w) } func BeginPasswordRecovery(ctx *context.Context, w http.ResponseWriter, req *http.Request) { runtime := currentSecurityRuntime() if rejectUnavailableRecovery(w) { return } var input verificationRequest if err := decodeJSONRequest(w, req, &input); err != nil || runtime.recovery == nil { writeSecurityError(w, response.ParamsError, "请求已受理") return } beginKey := emailRecoveryBeginIPAttempts.ipKey(req) if emailRecoveryBeginIPAttempts.blocked(beginKey) { id, err := newOpaqueRecoveryChallengeID() if err != nil { writeSecurityError(w, response.ParamsError, "请求已受理") return } response.NewSuccessResponse(map[string]any{"id": id}).FPrint(w) return } if input.Account == "" { input.Account = input.Identifier } browserContext, ok := recoveryBrowserContext(req, w, true) if !ok { writeSecurityError(w, response.ParamsError, "请求已受理") return } id, err := runtime.recovery.Begin(recovery.BeginRequest{Identifier: input.Account, Email: input.Email, BrowserContext: browserContext}, remoteIP(req)) if err != nil && id == "" { emailRecoveryFailureIPAttempts.recordFailure(emailRecoveryFailureIPAttempts.ipKey(req)) writeSecurityError(w, response.ParamsError, "请求已受理") return } emailRecoveryBeginIPAttempts.recordFailure(beginKey) response.NewSuccessResponse(map[string]any{"id": id}).FPrint(w) } func VerifyPasswordRecovery(ctx *context.Context, w http.ResponseWriter, req *http.Request) { runtime := currentSecurityRuntime() if rejectUnavailableRecovery(w) { return } failureKey := emailRecoveryFailureIPAttempts.ipKey(req) if emailRecoveryFailureIPAttempts.blocked(failureKey) { writeSecurityError(w, response.ParamsError, "验证失败") return } var input verificationCheckRequest if err := decodeJSONRequest(w, req, &input); err != nil || runtime.recovery == nil { emailRecoveryFailureIPAttempts.recordFailure(failureKey) writeSecurityError(w, response.ParamsError, "验证失败") return } if input.Account == "" { input.Account = input.Identifier } var ticket string var err error methodOffline := strings.EqualFold(strings.TrimSpace(input.Method), "offline") browserContext, ok := recoveryBrowserContext(req, w, methodOffline) if !ok { emailRecoveryFailureIPAttempts.recordFailure(failureKey) writeSecurityError(w, response.ParamsError, "验证失败") return } if methodOffline { ticket, err = runtime.recovery.VerifyOfflineWithContext(input.Account, input.Question, input.Answer, input.RecoveryCode, remoteIP(req), browserContext) } else if strings.EqualFold(strings.TrimSpace(input.Method), "email_recovery_code") { ticket, err = runtime.recovery.ContinueEmailWithContext(input.Continuation, input.Account, input.Email, input.RecoveryCode, browserContext) } else { var result recovery.EmailVerificationResult result, err = runtime.recovery.VerifyEmailStepWithContext(input.ID, input.Account, input.Email, input.Code, remoteIP(req), browserContext) if err == nil { // 附加验证尚未完成,操作日志不能提前标记为身份验证成功。 if observer, ok := w.(interface{ ObservePending(bool) }); ok { observer.ObservePending(result.RequiresRecoveryCode) } response.NewSuccessResponse(result).FPrint(w) return } } if err != nil { emailRecoveryFailureIPAttempts.recordFailure(failureKey) writeSecurityError(w, response.ParamsError, "验证失败") return } response.NewSuccessResponse(map[string]any{"ticket": ticket}).FPrint(w) } func CompletePasswordRecovery(ctx *context.Context, w http.ResponseWriter, req *http.Request) { runtime := currentSecurityRuntime() if rejectUnavailableRecovery(w) { return } failureKey := emailRecoveryFailureIPAttempts.ipKey(req) if emailRecoveryFailureIPAttempts.blocked(failureKey) { writeSecurityError(w, response.ParamsError, "重置失败") return } var input recoveryCompleteRequest if err := decodeJSONRequest(w, req, &input); err != nil || runtime.recovery == nil || strings.TrimSpace(input.Ticket) == "" { emailRecoveryFailureIPAttempts.recordFailure(failureKey) writeSecurityError(w, response.ParamsError, "重置失败") return } browserContext, ok := recoveryBrowserContext(req, w, false) if !ok { emailRecoveryFailureIPAttempts.recordFailure(failureKey) writeSecurityError(w, response.ParamsError, "验证失败") return } if err := runtime.recovery.CompleteWithContext(input.Ticket, input.NewPassword, browserContext); err != nil { emailRecoveryFailureIPAttempts.recordFailure(failureKey) writeSecurityError(w, response.ParamsError, "重置失败") return } clearRecoveryBrowserContext(w, req) response.NewSuccessResponse(map[string]any{"reset": true}).FPrint(w) } func recoveryBrowserContext(req *http.Request, w http.ResponseWriter, create bool) (string, bool) { if req == nil || w == nil { return "", false } if cookie, err := req.Cookie(recoveryContextCookieName); err == nil && strings.TrimSpace(cookie.Value) != "" { return cookie.Value, true } if !create { return "", false } var raw [32]byte if _, err := rand.Read(raw[:]); err != nil { return "", false } value := hex.EncodeToString(raw[:]) secure := req.TLS != nil || !config.DevelopmentMode http.SetCookie(w, &http.Cookie{ Name: recoveryContextCookieName, Value: value, Path: "/api/", HttpOnly: true, Secure: secure, SameSite: http.SameSiteStrictMode, MaxAge: 15 * 60, }) return value, true } func clearRecoveryBrowserContext(w http.ResponseWriter, req *http.Request) { if w == nil { return } secure := req != nil && (req.TLS != nil || !config.DevelopmentMode) http.SetCookie(w, &http.Cookie{ Name: recoveryContextCookieName, Value: "", Path: "/api/", HttpOnly: true, Secure: secure, SameSite: http.SameSiteStrictMode, MaxAge: -1, }) } func newOpaqueRecoveryChallengeID() (string, error) { var raw [16]byte if _, err := rand.Read(raw[:]); err != nil { return "", err } return hex.EncodeToString(raw[:]), nil } func ListExternalEmails(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 } items, err := runtime.external.List(ctx.UserID) if err != nil { log.WithError(err).Error("list external email failed") writeSecurityError(w, response.ServerError, "读取外部邮箱失败") return } result := make([]map[string]any, 0, len(items)) for _, item := range items { if item.Status == models.ExternalEmailStatusPending { continue } email, err := runtime.external.Decrypt(item) if err != nil { continue } result = append(result, map[string]any{"id": item.ID, "type": item.EmailType, "status": item.Status, "email": maskEmail(email), "created_at": item.CreatedAt, "updated_at": item.UpdatedAt}) } response.NewSuccessResponse(result).FPrint(w) } func BindExternalEmail(ctx *context.Context, w http.ResponseWriter, req *http.Request) { runtime := currentSecurityRuntime() if ctx == nil || ctx.UserID <= 0 || runtime.external == nil || runtime.verification == nil { writeSecurityError(w, response.NeedLogin, "请先登录") return } if !ctx.RecentAuth { writeSecurityErrorWithCode(w, response.NoAccessPrivileges, "此操作需要最近十分钟内完成登录,请确认当前密码", string(access.CodeRecentAuthRequired)) return } var input externalEmailRequest if err := decodeJSONRequest(w, req, &input); err != nil { writeSecurityError(w, response.ParamsError, "请求无效") return } normalizedEmail, err := externalemail.NormalizeEmail(input.Email) if err != nil { writeSecurityError(w, response.ParamsError, "请输入有效的外部邮箱地址") return } owned, err := currentUserPrimaryEmail(ctx.UserID, normalizedEmail) if err != nil { log.WithError(err).Error("check current user primary email failed") writeSecurityError(w, response.ServerError, "外部邮箱绑定失败") return } if owned { writeSecurityError(w, response.ParamsError, "不能将当前邮箱绑定为外部安全邮箱") return } input.Email = normalizedEmail item, err := runtime.external.Bind(externalemail.BindRequest{UserID: ctx.UserID, Email: input.Email, Type: input.Type}) created := err == nil if errors.Is(err, externalemail.ErrBindingExists) { item, err = runtime.external.FindPending(ctx.UserID, input.Email, input.Type) if errors.Is(err, externalemail.ErrBindingNotFound) { writeSecurityError(w, response.ParamsError, "该外部邮箱已经绑定") return } } if err != nil { switch { case errors.Is(err, externalemail.ErrPrimaryLimit): writeSecurityError(w, response.ParamsError, "主要外部邮箱已存在,请先停用后再绑定") case errors.Is(err, externalemail.ErrBackupLimit): writeSecurityError(w, response.ParamsError, "备用外部邮箱已存在,请先停用后再绑定") default: writeSecurityError(w, response.ParamsError, "外部邮箱绑定失败") } return } challenge, err := runtime.verification.Issue(verification.IssueRequest{Purpose: verification.PurposeExternalEmailBind, UserID: ctx.UserID, Account: ctx.UserAccount, Email: input.Email, ResourceID: item.ID}, remoteIP(req)) if err != nil { log.WithError(err).Warn("issue external email verification challenge failed") if created { _ = runtime.external.AbandonPending(ctx.UserID, item.ID) } writeSecurityError(w, response.ParamsError, externalEmailVerificationIssueMessage(err)) return } response.NewSuccessResponse(map[string]any{"id": item.ID, "challenge_id": challenge.ID, "status": item.Status, "email": maskEmail(input.Email), "expires_at": challenge.ExpiresAt.UTC()}).FPrint(w) } // ChangeOwnExternalEmail 发送当前用户外部邮箱变更验证码,旧邮箱会保留到验证成功。 func ChangeOwnExternalEmail(ctx *context.Context, w http.ResponseWriter, req *http.Request) { runtime := currentSecurityRuntime() if ctx == nil || ctx.UserID <= 0 || runtime.external == nil || runtime.verification == nil { writeSecurityError(w, response.NeedLogin, "请先登录") return } if !ctx.RecentAuth { writeSecurityErrorWithCode(w, response.NoAccessPrivileges, "此操作需要最近十分钟内完成登录,请确认当前密码", string(access.CodeRecentAuthRequired)) return } var input externalEmailRequest if err := decodeJSONRequest(w, req, &input); err != nil { writeSecurityError(w, response.ParamsError, "请求无效") return } normalizedEmail, err := externalemail.NormalizeEmail(input.Email) if err != nil { writeSecurityError(w, response.ParamsError, "请输入有效的外部邮箱地址") return } owned, err := currentUserPrimaryEmail(ctx.UserID, normalizedEmail) if err != nil { log.WithContext(ctx).WithError(err).Error("check current 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: ctx.UserID, 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 own 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: ctx.UserID, Account: ctx.UserAccount, Email: normalizedEmail, ResourceID: item.ID, }, remoteIP(req)) if err != nil { log.WithContext(ctx).WithError(err).Warn("issue own external email verification failed") writeSecurityError(w, response.ParamsError, externalEmailVerificationIssueMessage(err)) return } response.NewSuccessResponse(map[string]any{ "binding_id": item.ID, "challenge_id": challenge.ID, "email": maskEmail(normalizedEmail), "expires_at": challenge.ExpiresAt.UTC(), }).FPrint(w) } func currentUserPrimaryEmail(userID int, email string) (bool, error) { if db.Instance == nil || userID <= 0 { return false, errors.New("database is unavailable") } var binding models.UserEmail return db.Instance.Where("user_id = ? AND verified_at IS NOT NULL AND status != ? AND is_primary = 1 AND (address_canonical = ? OR LOWER(address) = ?)", userID, 3, email, email).Limit(1).Get(&binding) } func VerifyExternalEmail(ctx *context.Context, w http.ResponseWriter, req *http.Request) { runtime := currentSecurityRuntime() if ctx == nil || ctx.UserID <= 0 || runtime.external == nil || runtime.verification == nil { writeSecurityError(w, response.NeedLogin, "请先登录") return } var input verificationCheckRequest if err := decodeJSONRequest(w, req, &input); err != nil || input.BindingID <= 0 { writeSecurityError(w, response.ParamsError, "验证失败") return } if err := runtime.verification.VerifyBound(input.ID, ctx.UserAccount, input.Email, input.Code, verification.PurposeExternalEmailBind, input.BindingID, remoteIP(req)); err != nil { writeSecurityError(w, response.ParamsError, externalEmailVerificationCheckMessage(err)) return } if err := runtime.external.Verify(ctx.UserID, input.BindingID); err != nil { writeSecurityError(w, response.ParamsError, "验证失败") return } response.NewSuccessResponse(map[string]any{"verified": true}).FPrint(w) } // VerifyOwnExternalEmail 验证成功后原子替换当前用户同用途的旧邮箱。 func VerifyOwnExternalEmail(ctx *context.Context, w http.ResponseWriter, req *http.Request) { runtime := currentSecurityRuntime() if ctx == nil || ctx.UserID <= 0 || runtime.external == nil || runtime.verification == nil { writeSecurityError(w, response.NeedLogin, "请先登录") return } var input verificationCheckRequest if err := decodeJSONRequest(w, req, &input); err != nil || input.BindingID <= 0 { writeSecurityError(w, response.ParamsError, "验证请求无效") return } normalizedEmail, err := externalemail.NormalizeEmail(input.Email) if err != nil { writeSecurityError(w, response.ParamsError, "验证失败") return } if err := runtime.verification.VerifyBound( input.ID, ctx.UserAccount, normalizedEmail, input.Code, verification.PurposeExternalEmailChange, input.BindingID, remoteIP(req), ); err != nil { writeSecurityError(w, response.ParamsError, externalEmailVerificationCheckMessage(err)) return } if err := runtime.external.VerifyReplacement(ctx.UserID, input.BindingID); err != nil { log.WithContext(ctx).WithError(err).Error("verify own external email replacement failed") writeSecurityError(w, response.ParamsError, "外部邮箱替换失败") return } InvalidateSecurityRuntimeUser(ctx.UserID, ctx.UserAccount) response.NewSuccessResponse(map[string]any{"verified": 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 { 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.Disable(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.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{"disabled": true}).FPrint(w) } func remoteIP(req *http.Request) string { if req == nil { return "" } return normalizeRemoteHost(req.RemoteAddr) } func normalizeRemoteHost(value string) string { value = strings.TrimSpace(value) if value == "" { return "" } if host, _, err := net.SplitHostPort(value); err == nil { value = host } value = strings.Trim(value, "[]") if parsed := net.ParseIP(value); parsed != nil { return parsed.String() } return value } func maskEmail(value string) string { parts := strings.SplitN(strings.ToLower(strings.TrimSpace(value)), "@", 2) if len(parts) != 2 || parts[0] == "" { return "***" } if len(parts[0]) == 1 { return "*@" + parts[1] } return parts[0][:1] + "***@" + parts[1] } func writeSecurityError(w http.ResponseWriter, code int, message string) { response.NewErrorResponse(code, message, "").FPrint(w) } func writeSecurityErrorWithCode(w http.ResponseWriter, errorNo int, message, code string) { response.NewErrorResponse(errorNo, message, response.ConfigErrorData{Code: code}).FPrint(w) } func externalEmailVerificationIssueMessage(err error) string { switch { case errors.Is(err, verification.ErrRateLimited): return "验证码发送过于频繁,请稍后再试" case errors.Is(err, verification.ErrSMTPRelayUnavailable), errors.Is(err, verification.ErrInvalidSMTPConfig), errors.Is(err, verification.ErrUnsupportedSMTP): return "SMTP 中继配置不完整,请完善配置或清空中继后使用项目自身投递" case errors.Is(err, verification.ErrProjectMailUnavailable): return "项目邮件投递尚未就绪,请检查主域名和 DKIM 配置" case errors.Is(err, verification.ErrMailDeliveryFailed): return "验证码邮件投递失败,请确认收件地址和邮件服务状态" default: return "验证码发送失败,请检查发信服务后重试" } } func externalEmailVerificationCheckMessage(err error) string { switch { case errors.Is(err, verification.ErrExpiredChallenge): return "验证码已过期,请重新发送" case errors.Is(err, verification.ErrTooManyAttempts): return "验证码错误次数过多,请重新发送" default: return "验证码错误或验证请求已失效" } }