996 lines
36 KiB
Go
996 lines
36 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/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 {
|
|
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)
|
|
}
|
|
|
|
// 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 {
|
|
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, "重置失败")
|
|
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 "验证码错误或验证请求已失效"
|
|
}
|
|
}
|