Smail/backend/controllers/security.go
smail 6f79441585 修复审查发现的认证配置邮件协议及前端问题
补齐安全运行时清理、配置候选恢复、泛域名证书事务提交、邮件副本与协议边界、插件隐私和安装回滚回归,并更新修复状态及待验收清单。
2026-09-06 13:02:02 +08:00

808 lines
28 KiB
Go

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/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 {
return service
}
if db.Instance == nil {
return nil
}
service, _ := credentials.NewService(db.Instance)
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"`
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)
}
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, "\u8bf7\u6c42\u5df2\u53d7\u7406")
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, "\u8bf7\u6c42\u5df2\u53d7\u7406")
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, "\u9a8c\u8bc1\u5931\u8d25")
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, "\u9a8c\u8bc1\u5931\u8d25")
return
}
if methodOffline {
ticket, err = runtime.recovery.VerifyOfflineWithContext(input.Account, input.Question, input.Answer, input.RecoveryCode, remoteIP(req), browserContext)
} else {
ticket, err = runtime.recovery.VerifyWithContext(input.ID, input.Account, input.Email, input.Code, input.RecoveryCode, remoteIP(req), browserContext)
}
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, "\u91cd\u7f6e\u5931\u8d25")
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, "\u9a8c\u8bc1\u5931\u8d25")
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 {
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 {
writeSecurityError(w, response.NoAccessPrivileges, "此操作需要最近十分钟内完成登录,请重新登录后再试")
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})
if err != nil {
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 {
_ = runtime.external.AbandonPending(ctx.UserID, item.ID)
writeSecurityError(w, response.ParamsError, "外部邮箱绑定失败")
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)
}
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, "验证失败")
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)
}
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)
}