登录支持用户选择邮箱或手机动态码并消除同步发信等待
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-18 20:57:46 +08:00
parent 04cc5774e0
commit 41593ee762
25 changed files with 1223 additions and 207 deletions

View File

@ -219,7 +219,7 @@ func Login(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
log.WithContext(ctx).WithError(err).Info("登录二次验证未就绪")
response.NewErrorResponse(response.ParamsError, "请先配置可用的外部安全邮箱", "").FPrint(w)
response.NewErrorResponse(response.ParamsError, "登录验证服务暂不可用,请稍后重试", "").FPrint(w)
return

View File

@ -0,0 +1,295 @@
package controllers
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base32"
"encoding/binary"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"git.pcux.cn/xuans/Smail/config"
"git.pcux.cn/xuans/Smail/db"
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/services/access"
"git.pcux.cn/xuans/Smail/services/externalemail"
"git.pcux.cn/xuans/Smail/services/totp"
"git.pcux.cn/xuans/Smail/services/verification"
"git.pcux.cn/xuans/Smail/session"
appcontext "git.pcux.cn/xuans/Smail/utils/context"
"github.com/alexedwards/scs/v2"
)
func setupLoginMethods(t *testing.T, delivery verification.Delivery) (*models.User, *totp.Service) {
t.Helper()
oldConfig, oldDB, oldSession, oldManager := config.Instance, db.Instance, session.Instance, config.CurrentManager()
oldRuntime, oldAttempts := currentSecurityRuntime(), loginSecondFactorAttempts
root := t.TempDir()
config.SetRuntimeManager(nil)
config.Instance = &config.Config{DbType: config.DBTypeSQLite, DbDSN: filepath.Join(root, "login.db"), Domain: "example.com", VerificationEnabled: true, LoginPolicy: "always"}
if err := db.Init(""); err != nil {
t.Fatal(err)
}
session.Instance = scs.New()
loginSecondFactorAttempts = newLoginLimiter(20)
t.Cleanup(func() {
ClearSecurityRuntime()
_ = db.Instance.Close()
config.Instance, db.Instance, session.Instance = oldConfig, oldDB, oldSession
config.SetRuntimeManager(oldManager)
securityRuntimeMu.Lock()
securityRuntime = oldRuntime
securityRuntimeMu.Unlock()
loginSecondFactorAttempts = oldAttempts
})
user := &models.User{Account: "login-method-user", Role: string(access.RoleUser), SessionVersion: 1}
if _, err := db.Instance.Insert(user); err != nil {
t.Fatal(err)
}
vault := externalemail.NewVaultWithKeys([]byte("01234567890123456789012345678901"), []byte("abcdefabcdefabcdefabcdefabcdefab"))
repo, err := externalemail.NewRepository(db.Instance, vault)
if err != nil {
t.Fatal(err)
}
binding, err := repo.Bind(externalemail.BindRequest{UserID: user.ID, Email: "owner@example.net", Type: models.ExternalEmailTypePrimary})
if err != nil {
t.Fatal(err)
}
if err := repo.Verify(user.ID, binding.ID); err != nil {
t.Fatal(err)
}
store, err := verification.NewStore(100)
if err != nil {
t.Fatal(err)
}
service, err := verification.NewService(store, delivery, nil)
if err != nil {
t.Fatal(err)
}
authenticator, err := totp.NewService(db.Instance, root)
if err != nil {
t.Fatal(err)
}
ConfigureSecurityRuntime(service, repo, nil, nil)
ConfigureTOTPRuntime(authenticator)
return user, authenticator
}
func callLoginHandler(t *testing.T, handler func(*appcontext.Context, http.ResponseWriter, *http.Request), input any) (int, map[string]any) {
t.Helper()
body, err := json.Marshal(input)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "https://example.com/api/v1/security/login/verify", strings.NewReader(string(body)))
loaded, err := session.Instance.Load(req.Context(), "")
if err != nil {
t.Fatal(err)
}
req = req.WithContext(loaded)
recorder := httptest.NewRecorder()
handler(&appcontext.Context{Context: loaded}, recorder, req)
var result struct {
ErrorNo int `json:"errorNo"`
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
t.Fatalf("decode response: %v", err)
}
var data map[string]any
if result.ErrorNo == 0 {
if err := json.Unmarshal(result.Data, &data); err != nil {
t.Fatal(err)
}
}
return result.ErrorNo, data
}
func TestLoginEmailDeliveryDoesNotBlockHTTP(t *testing.T) {
started, release := make(chan struct{}), make(chan struct{})
var once sync.Once
var calls atomic.Int32
user, _ := setupLoginMethods(t, func(verification.Challenge, string) error {
calls.Add(1)
once.Do(func() { close(started) })
<-release
return nil
})
t.Cleanup(func() {
close(release)
deadline := time.After(2 * time.Second)
for len(loginDeliverySlots) != 0 {
select {
case <-deadline:
t.Error("mail worker did not stop")
return
case <-time.After(time.Millisecond):
}
}
})
begin := time.Now()
pending, err := beginLoginVerification(user, "127.0.0.1")
if err != nil || calls.Load() != 0 {
t.Fatalf("begin invoked mail delivery: %v", err)
}
input := resendLoginVerificationRequest{ID: pending["challenge_id"].(string), Account: user.Account}
code, data := callLoginHandler(t, ResendLoginVerification, input)
if code != 0 || data["delivery_status"] != "sending" {
t.Fatalf("send response: %d %+v", code, data)
}
select {
case <-started:
case <-time.After(time.Second):
t.Fatal("mail worker did not start")
}
for i := 0; i < 3; i++ {
code, _ = callLoginHandler(t, ResendLoginVerification, input)
if code != 0 {
t.Fatal("duplicate request failed")
}
code, data = callLoginHandler(t, LoginVerificationStatus, input)
if code != 0 || data["delivery_status"] != "sending" {
t.Fatal("status failed while mail blocked")
}
}
if elapsed := time.Since(begin); elapsed > time.Second {
t.Fatalf("HTTP operations blocked for %s", elapsed)
} else {
t.Logf("begin/send/status with blocked sender: %s", elapsed)
}
if calls.Load() != 1 {
t.Fatalf("duplicate mail jobs: %d", calls.Load())
}
if code, _ := callLoginHandler(t, LoginVerificationStatus, resendLoginVerificationRequest{ID: input.ID, Account: "another-user"}); code == 0 {
t.Fatal("status disclosed across accounts")
}
InvalidateSecurityRuntimeUser(user.ID, user.Account)
}
func testTOTPCode(secret string, step int64) string {
decoded, _ := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(secret)
var counter [8]byte
binary.BigEndian.PutUint64(counter[:], uint64(step))
mac := hmac.New(sha256.New, decoded)
_, _ = mac.Write(counter[:])
digest := mac.Sum(nil)
offset := digest[len(digest)-1] & 15
return fmt.Sprintf("%06d", (binary.BigEndian.Uint32(digest[offset:offset+4])&0x7fffffff)%1000000)
}
func TestLoginMethodsRolesAndSingleConsumption(t *testing.T) {
for _, role := range []access.Role{access.RoleSuperAdmin, access.RoleAdmin, access.RoleUser} {
t.Run(string(role), func(t *testing.T) {
user, service := setupLoginMethods(t, func(verification.Challenge, string) error { return nil })
user.Role = string(role)
if _, err := db.Instance.ID(user.ID).Cols("role").Update(user); err != nil {
t.Fatal(err)
}
ctx := &appcontext.Context{UserID: user.ID, UserAccount: user.Account, Role: role, RecentAuth: true}
recorder := httptest.NewRecorder()
TOTPSettings(ctx, recorder, httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"action":"begin"}`)))
var result struct {
ErrorNo int `json:"errorNo"`
Data totp.Enrollment `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil || result.ErrorNo != 0 || result.Data.Secret == "" {
t.Fatalf("role cannot enroll: %v", err)
}
step := time.Now().Unix() / 30
if err := service.ConfirmEnrollment(user.ID, result.Data.ID, testTOTPCode(result.Data.Secret, step-1)); err != nil {
t.Fatal(err)
}
pending, err := beginLoginVerification(user, "127.0.0.1")
if err != nil || len(pending["methods"].([]string)) != 2 {
t.Fatalf("expected both methods: %v", err)
}
id := pending["challenge_id"].(string)
var emailCode string
challenge, err := currentSecurityRuntime().verification.Store.Issue(verification.IssueRequest{Purpose: verification.PurposeLogin, Account: user.Account, Email: "owner@example.net"}, func(_ verification.Challenge, code string) error { emailCode = code; return nil })
if err != nil {
t.Fatal(err)
}
loginVerificationState.Lock()
item := loginVerificationState.items[id]
item.EmailChallenge, item.DeliveryStatus = challenge.ID, "sent"
loginVerificationState.items[id] = item
loginVerificationState.Unlock()
var successes atomic.Int32
var wg sync.WaitGroup
for _, method := range []string{"email", "totp"} {
wg.Add(1)
go func(method string) {
defer wg.Done()
code := emailCode
if method == "totp" {
code = testTOTPCode(result.Data.Secret, step)
}
errorNo, _ := callLoginHandler(t, CompleteLoginVerification, loginVerificationRequest{ID: id, Account: user.Account, Method: method, Code: code})
if errorNo == 0 {
successes.Add(1)
}
}(method)
}
wg.Wait()
if successes.Load() != 1 {
t.Fatalf("expected one login, got %d", successes.Load())
}
if _, ok := pendingLoginVerification(id, user.Account); ok {
t.Fatal("successful challenge retained")
}
// 新的登录流程必须能独立使用 TOTP,且旧动态码不能重放。
pending, err = beginLoginVerification(user, "127.0.0.1")
if err != nil {
t.Fatal(err)
}
input := loginVerificationRequest{ID: pending["challenge_id"].(string), Account: user.Account, Method: "totp", Code: testTOTPCode(result.Data.Secret, step+1)}
if code, _ := callLoginHandler(t, CompleteLoginVerification, input); code != 0 {
t.Fatal("TOTP login failed")
}
pending, _ = beginLoginVerification(user, "127.0.0.1")
input.ID = pending["challenge_id"].(string)
if code, _ := callLoginHandler(t, CompleteLoginVerification, input); code == 0 {
t.Fatal("TOTP replay accepted")
}
})
}
}
func TestLoginMethodsRejectInvalidState(t *testing.T) {
user, _ := setupLoginMethods(t, func(verification.Challenge, string) error { return nil })
pending, _ := beginLoginVerification(user, "127.0.0.1")
id := pending["challenge_id"].(string)
for i := 0; i < 5; i++ {
if code, _ := callLoginHandler(t, CompleteLoginVerification, loginVerificationRequest{ID: id, Account: user.Account, Method: "totp", Code: "123456"}); code == 0 {
t.Fatal("disabled TOTP accepted")
}
}
if _, ok := pendingLoginVerification(id, user.Account); ok {
t.Fatal("five failed attempts did not expire login")
}
pending, _ = beginLoginVerification(user, "127.0.0.1")
id = pending["challenge_id"].(string)
user.SessionVersion++
if _, err := db.Instance.ID(user.ID).Cols("session_version").Update(user); err != nil {
t.Fatal(err)
}
if code, _ := callLoginHandler(t, ResendLoginVerification, resendLoginVerificationRequest{ID: id, Account: user.Account}); code == 0 {
t.Fatal("stale session version sent mail")
}
loginVerificationState.Lock()
item := loginVerificationState.items[id]
item.ExpiresAt = time.Now().Add(-time.Second)
loginVerificationState.items[id] = item
loginVerificationState.Unlock()
if consumePendingLoginVerification(id, item) {
t.Fatal("expired login consumed")
}
}

View File

@ -1,6 +1,8 @@
package controllers
import (
"crypto/rand"
"encoding/hex"
"errors"
"net/http"
"strings"
@ -40,8 +42,18 @@ type loginVerificationItem struct {
Email string
SessionVersion int
ExpiresAt time.Time
TOTPEnabled bool
EmailChallenge string
DeliveryStatus string
DeliveryError string
RetryAt time.Time
Attempts int
Verifying bool
}
var loginDeliverySlots = make(chan struct{}, 16)
var loginSecondFactorAttempts = newLoginLimiter(20)
type resendLoginVerificationRequest struct {
ID string `json:"id"`
Account string `json:"account"`
@ -52,6 +64,7 @@ type loginVerificationRequest struct {
Identifier string `json:"identifier"`
Email string `json:"email"`
Code string `json:"code"`
Method string `json:"method"`
}
func loginVerificationEnabled() bool {
@ -84,54 +97,70 @@ func loginVerificationPolicyEnabled(enabled bool, policy string) bool {
func beginLoginVerification(user *models.User, remoteIP string) (map[string]any, error) {
runtime := currentSecurityRuntime()
if !loginVerificationEnabled() {
if user == nil || user.ID <= 0 {
return nil, errLoginVerificationUnavailable
}
item := loginVerificationItem{
UserID: user.ID, Account: user.Account, SessionVersion: user.SessionVersion,
ExpiresAt: time.Now().Add(loginVerificationTTL), DeliveryStatus: "idle",
}
if runtime.totp != nil {
var err error
item.TOTPEnabled, err = runtime.totp.IsEnabled(user.ID)
if err != nil {
return nil, errLoginVerificationUnavailable
}
}
if loginVerificationEnabled() || item.TOTPEnabled {
if runtime.external == nil || runtime.verification == nil {
return nil, errLoginVerificationUnavailable
}
binding, err := runtime.external.FindVerifiedPrimaryByUser(user.ID)
if err != nil && !errors.Is(err, externalemail.ErrBindingNotFound) {
return nil, errLoginVerificationUnavailable
}
if err == nil {
item.Email, err = runtime.external.Decrypt(*binding)
if err != nil {
return nil, errLoginVerificationUnavailable
}
}
}
if item.Email == "" && !item.TOTPEnabled {
return nil, nil
}
if user == nil || user.ID <= 0 || runtime.verification == nil || runtime.external == nil {
var token [32]byte
if _, err := rand.Read(token[:]); err != nil {
return nil, errLoginVerificationUnavailable
}
binding, err := runtime.external.FindVerifiedPrimaryByUser(user.ID)
if err != nil {
if errors.Is(err, externalemail.ErrBindingNotFound) {
return nil, nil
}
return nil, errLoginVerificationUnavailable
}
email, err := runtime.external.Decrypt(*binding)
if err != nil {
return nil, errLoginVerificationUnavailable
}
challenge, err := runtime.verification.Issue(verification.IssueRequest{
Purpose: verification.PurposeLogin,
Account: user.Account,
Email: email,
TTL: loginVerificationTTL,
}, remoteIP)
if err != nil {
return nil, err
}
id := hex.EncodeToString(token[:])
loginVerificationState.Lock()
defer loginVerificationState.Unlock()
cleanupLoginVerificationLocked(time.Now())
if len(loginVerificationState.items) >= loginVerificationMaxEntries {
loginVerificationState.Unlock()
runtime.verification.Store.InvalidateChallenge(challenge.ID)
return nil, errLoginVerificationUnavailable
}
loginVerificationState.items[challenge.ID] = loginVerificationItem{
UserID: user.ID, Account: user.Account, Email: email,
SessionVersion: user.SessionVersion, ExpiresAt: challenge.ExpiresAt,
}
loginVerificationState.Unlock()
return map[string]any{
"pending": true,
"challenge_id": challenge.ID,
"expires_at": challenge.ExpiresAt.UTC(),
"email": maskEmail(email),
"account": user.Account,
}, nil
loginVerificationState.items[id] = item
return loginVerificationResponse(id, item), nil
}
// ResendLoginVerification 使旧登录验证码失效后重新发送新验证码。
func loginVerificationResponse(id string, item loginVerificationItem) map[string]any {
methods := []string{}
if item.Email != "" {
methods = append(methods, "email")
}
if item.TOTPEnabled {
methods = append(methods, "totp")
}
return map[string]any{
"pending": true, "challenge_id": id, "account": item.Account,
"expires_at": item.ExpiresAt.UTC(), "email": maskEmail(item.Email),
"methods": methods, "delivery_status": item.DeliveryStatus,
"delivery_error": item.DeliveryError, "retry_at": item.RetryAt.UTC(),
}
}
// ResendLoginVerification 只启动有并发上限的后台投递,HTTP 请求不等待邮件服务器。
func ResendLoginVerification(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
runtime := currentSecurityRuntime()
var input resendLoginVerificationRequest
@ -140,70 +169,153 @@ func ResendLoginVerification(ctx *context.Context, w http.ResponseWriter, req *h
return
}
loginVerificationState.Lock()
cleanupLoginVerificationLocked(time.Now())
item, ok := loginVerificationState.items[input.ID]
if ok {
delete(loginVerificationState.items, input.ID)
}
loginVerificationState.Unlock()
if !ok || !strings.EqualFold(strings.TrimSpace(input.Account), item.Account) {
item, ok := pendingLoginVerification(input.ID, input.Account)
if !ok || !loginVerificationUserValid(item) {
writeSecurityError(w, response.ParamsError, "登录验证已失效,请重新登录")
return
}
if runtime.verification == nil || runtime.verification.Store == nil {
if runtime.verification == nil || runtime.verification.Store == nil || item.Email == "" {
writeSecurityError(w, response.ParamsError, "验证码服务暂不可用")
return
}
runtime.verification.Store.InvalidateChallenge(input.ID)
challenge, err := runtime.verification.Issue(verification.IssueRequest{
Purpose: verification.PurposeLogin,
UserID: item.UserID,
Account: item.Account,
Email: item.Email,
TTL: loginVerificationTTL,
}, remoteIP(req))
if err != nil {
writeSecurityError(w, response.ParamsError, "验证码发送失败,请稍后重试")
loginVerificationState.Lock()
item, ok = loginVerificationState.items[input.ID]
if !ok || !time.Now().Before(item.ExpiresAt) {
loginVerificationState.Unlock()
writeSecurityError(w, response.ParamsError, "登录验证已失效,请重新登录")
return
}
loginVerificationState.Lock()
cleanupLoginVerificationLocked(time.Now())
loginVerificationState.items[challenge.ID] = loginVerificationItem{
UserID: item.UserID, Account: item.Account, Email: item.Email,
SessionVersion: item.SessionVersion, ExpiresAt: challenge.ExpiresAt,
if item.DeliveryStatus == "sending" || time.Now().Before(item.RetryAt) {
loginVerificationState.Unlock()
response.NewSuccessResponse(loginVerificationResponse(input.ID, item)).FPrint(w)
return
}
select {
case loginDeliverySlots <- struct{}{}:
default:
loginVerificationState.Unlock()
writeSecurityError(w, response.ParamsError, "邮件发送繁忙,请稍后重试或使用动态码")
return
}
previousChallenge := item.EmailChallenge
item.EmailChallenge = ""
item.DeliveryStatus, item.DeliveryError = "sending", ""
item.RetryAt = time.Now().Add(time.Minute)
loginVerificationState.items[input.ID] = item
loginVerificationState.Unlock()
response.NewSuccessResponse(map[string]any{
"pending": true,
"challenge_id": challenge.ID,
"expires_at": challenge.ExpiresAt.UTC(),
"email": maskEmail(item.Email),
"account": item.Account,
}).FPrint(w)
ip := remoteIP(req)
go func() {
defer func() { <-loginDeliverySlots }()
if previousChallenge != "" {
runtime.verification.Store.InvalidateChallenge(previousChallenge)
}
challenge, err := runtime.verification.Issue(verification.IssueRequest{
Purpose: verification.PurposeLogin, UserID: item.UserID, Account: item.Account,
Email: item.Email, TTL: time.Until(item.ExpiresAt),
}, ip)
loginVerificationState.Lock()
current, exists := loginVerificationState.items[input.ID]
if exists && time.Now().Before(current.ExpiresAt) {
if err != nil {
current.DeliveryStatus = "failed"
current.DeliveryError = externalEmailVerificationIssueMessage(err)
} else {
current.DeliveryStatus, current.EmailChallenge = "sent", challenge.ID
}
loginVerificationState.items[input.ID] = current
}
loginVerificationState.Unlock()
if !exists || !time.Now().Before(current.ExpiresAt) {
runtime.verification.Store.InvalidateChallenge(challenge.ID)
}
}()
response.NewSuccessResponse(loginVerificationResponse(input.ID, item)).FPrint(w)
}
func LoginVerificationStatus(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
var input resendLoginVerificationRequest
if err := decodeJSONRequest(w, req, &input); err != nil {
writeSecurityError(w, response.ParamsError, "登录验证请求无效")
return
}
item, ok := pendingLoginVerification(input.ID, input.Account)
if !ok || !loginVerificationUserValid(item) {
writeSecurityError(w, response.ParamsError, "登录验证已失效,请重新登录")
return
}
response.NewSuccessResponse(loginVerificationResponse(input.ID, item)).FPrint(w)
}
func loginVerificationUserValid(item loginVerificationItem) bool {
if db.Instance == nil {
return false
}
var user models.User
has, err := db.Instance.ID(item.UserID).Get(&user)
return err == nil && has && user.Disabled == 0 && user.Account == item.Account && user.SessionVersion == item.SessionVersion
}
// CompleteLoginVerification 只有验证码成功后才创建完整 Session。
func CompleteLoginVerification(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
runtime := currentSecurityRuntime()
var input loginVerificationRequest
if err := decodeJSONRequest(w, req, &input); err != nil || runtime.verification == nil {
if err := decodeJSONRequest(w, req, &input); err != nil {
writeSecurityError(w, response.ParamsError, "验证失败")
return
}
if input.Account == "" {
input.Account = input.Identifier
}
loginVerificationState.Lock()
cleanupLoginVerificationLocked(time.Now())
item, ok := loginVerificationState.items[input.ID]
loginVerificationState.Unlock()
if !ok || !strings.EqualFold(strings.TrimSpace(input.Account), item.Account) {
item, ok := pendingLoginVerification(input.ID, input.Account)
if !ok || !loginVerificationUserValid(item) {
writeSecurityError(w, response.ParamsError, "验证失败")
return
}
if err := runtime.verification.Verify(input.ID, item.Account, item.Email, input.Code, verification.PurposeLogin, remoteIP(req)); err != nil {
limitKey := loginSecondFactorAttempts.accountKey(item.Account)
if loginSecondFactorAttempts.blocked(limitKey) {
writeSecurityError(w, response.ParamsError, "验证失败次数过多,请十五分钟后重试")
return
}
loginVerificationState.Lock()
item, ok = loginVerificationState.items[input.ID]
if !ok || item.Verifying || item.Attempts >= 5 || !time.Now().Before(item.ExpiresAt) {
loginVerificationState.Unlock()
writeSecurityError(w, response.ParamsError, "登录验证已失效或正在验证,请重新登录")
return
}
item.Attempts++
item.Verifying = true
loginVerificationState.items[input.ID] = item
loginVerificationState.Unlock()
var verifyErr error
switch input.Method {
case "totp":
if !item.TOTPEnabled || runtime.totp == nil {
verifyErr = errLoginVerificationUnavailable
} else {
verifyErr = runtime.totp.Verify(item.UserID, input.Code)
}
case "", "email":
if item.EmailChallenge == "" || runtime.verification == nil {
verifyErr = errLoginVerificationUnavailable
} else {
verifyErr = runtime.verification.Verify(item.EmailChallenge, item.Account, item.Email, input.Code, verification.PurposeLogin, remoteIP(req))
}
default:
verifyErr = errLoginVerificationUnavailable
}
if verifyErr != nil {
loginSecondFactorAttempts.recordFailure(limitKey)
loginVerificationState.Lock()
if current, exists := loginVerificationState.items[input.ID]; exists {
current.Verifying = false
if current.Attempts >= 5 {
delete(loginVerificationState.items, input.ID)
} else {
loginVerificationState.items[input.ID] = current
}
}
loginVerificationState.Unlock()
writeSecurityError(w, response.ParamsError, "验证失败")
return
}
@ -216,9 +328,14 @@ func CompleteLoginVerification(ctx *context.Context, w http.ResponseWriter, req
writeSecurityError(w, response.ParamsError, "登录验证已失效,请重新登录")
return
}
loginVerificationState.Lock()
delete(loginVerificationState.items, input.ID)
loginVerificationState.Unlock()
if !consumePendingLoginVerification(input.ID, item) {
writeSecurityError(w, response.ParamsError, "登录验证已失效,请重新登录")
return
}
loginSecondFactorAttempts.clear(limitKey)
if runtime.verification != nil && item.EmailChallenge != "" {
runtime.verification.Store.InvalidateChallenge(item.EmailChallenge)
}
completeLoginSession(ctx, w, req, &user)
}
@ -306,7 +423,7 @@ func consumePendingLoginVerification(challengeID string, expected loginVerificat
loginVerificationState.Lock()
defer loginVerificationState.Unlock()
item, ok := loginVerificationState.items[strings.TrimSpace(challengeID)]
if !ok || item.UserID != expected.UserID || item.SessionVersion != expected.SessionVersion || !strings.EqualFold(item.Account, expected.Account) {
if !ok || !time.Now().Before(item.ExpiresAt) || item.UserID != expected.UserID || item.SessionVersion != expected.SessionVersion || !strings.EqualFold(item.Account, expected.Account) {
return false
}
delete(loginVerificationState.items, strings.TrimSpace(challengeID))

View File

@ -20,6 +20,7 @@ import (
"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/totp"
"git.pcux.cn/xuans/Smail/services/verification"
webauthnservice "git.pcux.cn/xuans/Smail/services/webauthn"
"git.pcux.cn/xuans/Smail/utils/clientip"
@ -87,6 +88,7 @@ type securityRuntimeState struct {
recovery *recovery.Service
credentials *credentials.Service
webauthn *webauthnservice.Service
totp *totp.Service
}
var securityRuntime securityRuntimeState
@ -118,6 +120,12 @@ func ConfigureWebAuthnRuntime(service *webauthnservice.Service) {
securityRuntime.webauthn = service
}
func ConfigureTOTPRuntime(service *totp.Service) {
securityRuntimeMu.Lock()
defer securityRuntimeMu.Unlock()
securityRuntime.totp = service
}
// ClearSecurityRuntime 在停止入口后一次撤下全部安全服务,避免保留已停止的旧库依赖。
func ClearSecurityRuntime() {
securityRuntimeMu.Lock()
@ -134,6 +142,9 @@ func InvalidateSecurityRuntimeUser(userID int, account string) {
return
}
runtime := currentSecurityRuntime()
if runtime.totp != nil {
runtime.totp.InvalidateUser(userID)
}
if runtime.recovery != nil {
runtime.recovery.InvalidateUser(userID)
}

View File

@ -0,0 +1,77 @@
package controllers
import (
"net/http"
"strings"
"git.pcux.cn/xuans/Smail/dto/response"
"git.pcux.cn/xuans/Smail/services/access"
"git.pcux.cn/xuans/Smail/utils/context"
)
// TOTPSettings 所有登录角色均可管理自己的验证器,不接受客户端指定用户。
func TOTPSettings(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
w.Header().Set("Cache-Control", "no-store")
service := currentSecurityRuntime().totp
if ctx == nil || ctx.UserID <= 0 || service == nil {
writeSecurityError(w, response.ParamsError, "动态码服务暂不可用")
return
}
if req.Method == http.MethodGet {
enabled, err := service.IsEnabled(ctx.UserID)
if err != nil {
writeSecurityError(w, response.ParamsError, "无法读取动态码状态")
return
}
response.NewSuccessResponse(map[string]any{"enabled": enabled}).FPrint(w)
return
}
if !ctx.RecentAuth {
writeSecurityErrorWithCode(w, response.NoAccessPrivileges, "请确认当前密码后继续操作", string(access.CodeRecentAuthRequired))
return
}
var input struct {
Action string `json:"action"`
ID string `json:"id"`
Code string `json:"code"`
}
if err := decodeJSONRequest(w, req, &input); err != nil {
writeSecurityError(w, response.ParamsError, "动态码请求无效")
return
}
var err error
limitKey := loginSecondFactorAttempts.accountKey(ctx.UserAccount)
if loginSecondFactorAttempts.blocked(limitKey) {
writeSecurityError(w, response.ParamsError, "验证失败次数过多,请十五分钟后重试")
return
}
switch input.Action {
case "begin":
enrollment, beginErr := service.BeginEnrollment(ctx.UserID, ctx.UserAccount)
if beginErr != nil {
writeSecurityError(w, response.ParamsError, "无法开始绑定,请刷新状态后重试")
return
}
response.NewSuccessResponse(enrollment).FPrint(w)
return
case "confirm":
err = service.ConfirmEnrollment(ctx.UserID, input.ID, strings.TrimSpace(input.Code))
case "disable":
err = service.Disable(ctx.UserID, strings.TrimSpace(input.Code))
case "cancel":
service.InvalidateUser(ctx.UserID)
default:
writeSecurityError(w, response.ParamsError, "动态码操作无效")
return
}
if err != nil {
loginSecondFactorAttempts.recordFailure(limitKey)
writeSecurityError(w, response.ParamsError, "动态码无效、已使用或绑定已过期,请使用新的动态码重试")
return
}
if input.Action == "confirm" || input.Action == "disable" {
loginSecondFactorAttempts.clear(limitKey)
InvalidateSecurityRuntimeUser(ctx.UserID, ctx.UserAccount)
}
response.NewSuccessResponse(map[string]any{"success": true}).FPrint(w)
}

View File

@ -55,6 +55,14 @@ func router(mux *http.ServeMux) error {
mux.HandleFunc("/api/security/verification/verify", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.VerifyVerification))))
mux.HandleFunc("/api/security/login/verify", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.CompleteLoginVerification))))
mux.HandleFunc("/api/security/login/resend", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.ResendLoginVerification))))
mux.HandleFunc("/api/security/login/status", onlyMethod(http.MethodPost, limitBody(4<<10, contextIterceptor(controllers.LoginVerificationStatus))))
mux.HandleFunc("/api/security/totp", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
limitBody(4<<10, contextIterceptor(controllers.TOTPSettings))(w, r)
})
mux.HandleFunc("/api/security/webauthn/registration/begin", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.BeginWebAuthnRegistration))))
mux.HandleFunc("/api/security/webauthn/registration/finish", onlyMethod(http.MethodPost, limitBody(64<<10, contextIterceptor(controllers.FinishWebAuthnRegistration))))
mux.HandleFunc("/api/security/webauthn/credentials", onlyMethod(http.MethodGet, contextIterceptor(controllers.ListWebAuthnCredentials)))

View File

@ -227,6 +227,8 @@ func mustChangePasswordAllowedPath(path string) bool {
func publicSecurityPath(path string) bool {
switch normalizedAPIPath(path) {
case "/api/security/login/status":
return true
case "/api/ping", "/api/login", "/api/security/login/verify", "/api/security/login/resend", "/api/security/recovery/begin", "/api/security/recovery/verify", "/api/security/recovery/complete", "/api/security/recovery/webauthn/begin", "/api/security/recovery/webauthn/verify", "/api/security/webauthn/login/begin", "/api/security/webauthn/login/finish":
return true
default:

View File

@ -30,6 +30,7 @@ import (
"git.pcux.cn/xuans/Smail/services/recovery"
"git.pcux.cn/xuans/Smail/services/restart"
"git.pcux.cn/xuans/Smail/services/setup/ssl"
"git.pcux.cn/xuans/Smail/services/totp"
"git.pcux.cn/xuans/Smail/services/verification"
webauthnservice "git.pcux.cn/xuans/Smail/services/webauthn"
"git.pcux.cn/xuans/Smail/session"
@ -366,6 +367,12 @@ func startVerificationRuntime() error {
}
recover.SetWebAuthnVerifier(webAuthnRuntimeService)
recover.SetPublicLinkDelivery(newPublicRecoveryLinkDelivery())
totpService, err := totp.NewService(db.Instance, config.ROOT_PATH)
if err != nil {
runtime.Stop()
return fmt.Errorf("start TOTP service: %w", err)
}
controllers.ConfigureTOTPRuntime(totpService)
controllers.ConfigureSecurityRuntime(service, external, recover, credential)
credentialService = credential
verificationRuntime = runtime

View File

@ -14,14 +14,23 @@ import (
"git.pcux.cn/xuans/Smail/services/verification"
appcontext "git.pcux.cn/xuans/Smail/utils/context"
"git.pcux.cn/xuans/Smail/utils/send"
smailsmtp "git.pcux.cn/xuans/Smail/utils/smtp"
)
func newVerificationDelivery() verification.Delivery {
return newVerificationDeliveryWithDependencies(
config.CurrentSMTPRuntimeSettings,
deliverVerificationWithSMTPRelay,
deliverVerificationWithProjectMail,
)
return func(challenge verification.Challenge, code string) error {
ctx, cancel := context.WithTimeout(context.Background(), verification.MailDeliveryTimeout)
defer cancel()
return newVerificationDeliveryWithDependencies(
config.CurrentSMTPRuntimeSettings,
func(settings config.SMTPRuntimeSettings, challenge verification.Challenge, code string) error {
return deliverVerificationWithSMTPRelayContext(ctx, settings, challenge, code)
},
func(challenge verification.Challenge, code string) error {
return deliverVerificationWithProjectMailContext(ctx, challenge, code)
},
)(challenge, code)
}
}
func newVerificationDeliveryWithDependencies(
@ -46,6 +55,9 @@ func newVerificationDeliveryWithDependencies(
if relayErr == nil {
return nil
}
if smailsmtp.IsDeliveryUncertain(relayErr) {
return relayErr
}
if !smtpRelayComplete(runtimeSettings) {
return relayErr
}
@ -93,21 +105,36 @@ func verificationSMTPSettings(settings config.SMTPRuntimeSettings) (verification
}
func deliverVerificationWithSMTPRelay(runtimeSettings config.SMTPRuntimeSettings, challenge verification.Challenge, code string) error {
ctx, cancel := context.WithTimeout(context.Background(), verification.MailDeliveryTimeout)
defer cancel()
return deliverVerificationWithSMTPRelayContext(ctx, runtimeSettings, challenge, code)
}
func deliverVerificationWithSMTPRelayContext(ctx context.Context, runtimeSettings config.SMTPRuntimeSettings, challenge verification.Challenge, code string) error {
settings, err := verificationSMTPSettings(runtimeSettings)
if err != nil {
return err
}
delivery, err := verification.NewSMTPDelivery(settings)
delivery, err := verification.NewSMTPDeliveryContext(ctx, settings)
if err != nil {
return fmt.Errorf("SMTP relay is unavailable: %w", err)
}
if err = delivery.Delivery()(challenge, code); err != nil {
return fmt.Errorf("%w: SMTP relay rejected delivery", verification.ErrMailDeliveryFailed)
return fmt.Errorf("%w: %w", verification.ErrMailDeliveryFailed, err)
}
return nil
}
func deliverVerificationWithProjectMail(challenge verification.Challenge, code string) error {
ctx, cancel := context.WithTimeout(context.Background(), verification.MailDeliveryTimeout)
defer cancel()
return deliverVerificationWithProjectMailContext(ctx, challenge, code)
}
func deliverVerificationWithProjectMailContext(deliveryContext context.Context, challenge verification.Challenge, code string) error {
if err := deliveryContext.Err(); err != nil {
return err
}
domain := strings.TrimSpace(config.CurrentMailDomains().Primary)
if domain == "" {
return fmt.Errorf("%w: primary mail domain is not configured", verification.ErrProjectMailUnavailable)
@ -132,7 +159,7 @@ func deliverVerificationWithProjectMail(challenge verification.Challenge, code s
MsgID: messageID,
}
ctx := &appcontext.Context{
Context: context.Background(),
Context: deliveryContext,
Values: make(map[string]any),
}
if deliveryErr, _ := send.SendTriggered(ctx, message, "系统安全验证邮件"); deliveryErr != nil {

View File

@ -112,6 +112,7 @@ func (s *Service) BeginEnrollment(userID int, account string) (*Enrollment, erro
}
id := base64.RawURLEncoding.EncodeToString(idBytes)
expiresAt := s.now().UTC().Add(EnrollmentTTL)
encodedSecret := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(secret)
s.mu.Lock()
s.cleanupEnrollmentsLocked(s.now().UTC())
@ -129,7 +130,6 @@ func (s *Service) BeginEnrollment(userID int, account string) (*Enrollment, erro
s.enrollments[id] = enrollmentItem{userID: userID, secret: secret, expiresAt: expiresAt}
s.mu.Unlock()
encodedSecret := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(secret)
return &Enrollment{
ID: id,
Secret: encodedSecret,

View File

@ -19,7 +19,7 @@ import (
type SecurityMode string
const (
MailDeliveryTimeout = 45 * time.Second
MailDeliveryTimeout = 45 * time.Second
SecurityStartTLS SecurityMode = "starttls"
SecurityImplicitTLS SecurityMode = "tls"
SecurityPlain SecurityMode = "plain"
@ -55,6 +55,18 @@ func NewSMTPDelivery(settings SMTPSettings) (*SMTPDelivery, error) {
return &SMTPDelivery{settings: settings, send: sendSMTPMessage}, nil
}
// NewSMTPDeliveryContext 将中继投递纳入调用方的总时限。
func NewSMTPDeliveryContext(ctx context.Context, settings SMTPSettings) (*SMTPDelivery, error) {
delivery, err := NewSMTPDelivery(settings)
if err != nil {
return nil, err
}
delivery.send = func(settings SMTPSettings, recipient string, message []byte) error {
return SendSMTPMessageContext(ctx, settings, recipient, message)
}
return delivery, nil
}
func (d *SMTPDelivery) Delivery() Delivery {
return func(challenge Challenge, code string) error {
if d == nil || d.send == nil || challenge.Email == "" || code == "" {

View File

@ -0,0 +1,46 @@
package smtp
import (
"context"
"net"
"testing"
"time"
)
func TestSMTPContextCancelsGreeting(t *testing.T) {
client, server := net.Pipe()
defer server.Close()
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
started := time.Now()
if _, err := newClientWithContext(ctx, client, "localhost", "example.com"); err == nil {
t.Fatal("blocked greeting succeeded")
}
if time.Since(started) > time.Second {
t.Fatal("context did not cancel greeting")
}
}
func TestSMTPContextCancelsCommand(t *testing.T) {
client, server := net.Pipe()
defer server.Close()
go func() {
_, _ = server.Write([]byte("220 local ready\r\n"))
var command [512]byte
_, _ = server.Read(command[:])
}()
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
smtpClient, err := newClientWithContext(ctx, client, "localhost", "example.com")
if err != nil {
t.Fatal(err)
}
defer smtpClient.Close()
started := time.Now()
if err := smtpClient.Hello("example.com"); err == nil {
t.Fatal("blocked command succeeded")
}
if time.Since(started) > time.Second {
t.Fatal("context did not cancel command")
}
}

View File

@ -17,6 +17,7 @@
"axios": "1.19.0",
"element-plus": "2.14.5",
"pinia": "^2.0.36",
"qrcode": "^1.5.4",
"vue": "3.5.41",
"vue-icons-plus": "^0.1.6",
"vue-router": "^4.2.0"

View File

@ -148,6 +148,10 @@ const useGlobalStatusStore = defineStore("useGlobalStatusStore", {
challengeId: payload?.challenge_id || "",
account: payload?.account || "",
email: payload?.email || "",
methods: Array.isArray(payload?.methods) ? payload.methods : ["email"],
deliveryStatus: payload?.delivery_status || "idle",
deliveryError: payload?.delivery_error || "",
retryAt: payload?.retry_at || "",
expiresAt: payload?.expires_at || "",
redirect: getSafeLoginRedirect(payload?.redirect)
};

View File

@ -27,7 +27,7 @@ export function usePage() {
}
loading.value = true
try {
const res = await http.post("/api/v1/login", form)
const res = await http.post("/api/v1/login", form, { timeout: 4500 })
if (res.errorNo !== 0) {
ElMessage.error(res.errorMsg)
return

View File

@ -44,6 +44,27 @@
overflow-wrap: anywhere;
}
.verification-methods {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 16px;
}
.verification-methods .el-button {
flex: 1;
min-height: 48px;
margin: 0;
}
.verification-hint,
.verification-delivery {
color: var(--pm-text-secondary);
font-size: 14px;
line-height: 1.7;
margin-bottom: 20px;
}
.verification-input :deep(.el-input__wrapper) {
min-height: 48px;
border-radius: 12px;
@ -104,4 +125,4 @@
flex-direction: column;
align-items: center;
}
}
}

View File

@ -7,11 +7,6 @@ import { useRouter } from "vue-router"
import { useGlobalStatusStore } from "@/stores/useGlobalStatusStore"
import { http } from "@/utils/axios"
import { getSafeLoginRedirect } from "@/utils/navigation.mjs"
import {
normalizeRequestOptions,
serializeCredential,
webAuthnSupported
} from "@/utils/webauthn"
export function usePage() {
const router = useRouter()
@ -20,24 +15,71 @@ export function usePage() {
const code = ref("")
const loading = ref(false)
const resending = ref(false)
const webAuthnLoading = ref(false)
const method = ref("")
const retrySeconds = ref(0)
const remainingSeconds = ref(0)
const errorMessage = ref("")
let timer = null
let polling = false
let disposed = false
const expiresAt = computed(() => pending.value?.expiresAt || "")
const webAuthnAvailable = computed(() => webAuthnSupported())
const methods = computed(() => pending.value?.methods || [])
const sending = computed(() => pending.value?.deliveryStatus === "sending")
const deliveryMessage = computed(() => {
switch (pending.value?.deliveryStatus) {
case "sending": return "正在发送邮件,您可以等待,也可以切换到手机动态码。"
case "sent": return "收件服务器已接收邮件,请检查收件箱和垃圾邮件。"
case "failed": return pending.value?.deliveryError || "邮件发送失败,请稍后重试。"
default: return "点击发送验证码后,我们会向绑定邮箱发送邮件。"
}
})
const savePending = (data, id) => {
if (disposed || pending.value?.challengeId !== id) return
globalStatus.setPendingLogin({ ...data, redirect: pending.value.redirect })
updateRemaining()
}
const pollStatus = async () => {
if (polling || !sending.value || remainingSeconds.value <= 0) return
polling = true
const id = pending.value.challengeId
try {
const result = await http.post("/api/v1/security/login/status", {
id, account: pending.value.account
}, { timeout: 4500 })
if (result.errorNo !== 0) throw new Error(result.errorMsg || "无法查询发送状态")
savePending(result.data, id)
errorMessage.value = ""
} catch (error) {
if (!disposed) errorMessage.value = "发送状态暂时无法更新,请检查网络;无需重复发送。"
} finally {
polling = false
}
}
const chooseMethod = value => {
if (!methods.value.includes(value) || loading.value) return
method.value = value
code.value = ""
errorMessage.value = ""
}
const updateRemaining = () => {
const timestamp = Date.parse(expiresAt.value)
remainingSeconds.value = Number.isFinite(timestamp)
? Math.max(0, Math.ceil((timestamp - Date.now()) / 1000))
: 0
retrySeconds.value = Math.max(0, Math.ceil((Date.parse(pending.value?.retryAt) - Date.now()) / 1000)) || 0
}
const startTimer = () => {
updateRemaining()
timer = window.setInterval(updateRemaining, 1000)
timer = window.setInterval(() => {
updateRemaining()
pollStatus()
}, 1000)
}
const stopTimer = () => {
@ -57,8 +99,10 @@ export function usePage() {
await goBackToLogin()
return
}
if (!code.value.trim()) {
ElMessage.warning("请输入邮箱验证码")
if (loading.value || !method.value) return
const pattern = method.value === "totp" ? /^\d{6}$/ : /^\d{8}$/
if (!pattern.test(code.value.trim())) {
ElMessage.warning(method.value === "totp" ? "请输入六位手机动态码" : "请输入八位邮箱验证码")
return
}
if (remainingSeconds.value <= 0) {
@ -73,8 +117,9 @@ export function usePage() {
const response = await http.post("/api/v1/security/login/verify", {
id: pending.value.challengeId,
account: pending.value.account,
code: code.value.trim()
})
code: code.value.trim(),
method: method.value
}, { timeout: 4500 })
if (response.errorNo !== 0) {
throw new Error(response.errorMsg || "验证码验证失败")
}
@ -91,75 +136,35 @@ export function usePage() {
}
const resendCode = async () => {
if (!pending.value?.challengeId || resending.value) {
if (!pending.value?.challengeId || resending.value || sending.value || retrySeconds.value > 0) {
return
}
resending.value = true
errorMessage.value = ""
try {
const id = pending.value.challengeId
const response = await http.post("/api/v1/security/login/resend", {
id: pending.value.challengeId,
id,
account: pending.value.account
})
}, { timeout: 4500 })
if (response.errorNo !== 0 || !response.data?.pending) {
throw new Error(response.errorMsg || "验证码发送失败")
}
globalStatus.setPendingLogin({
...response.data,
redirect: pending.value.redirect
})
savePending(response.data, id)
code.value = ""
ElMessage.success("验证码已重新发送")
stopTimer()
startTimer()
} catch (error) {
errorMessage.value = error?.message || "验证码发送失败,请稍后重试"
if (error?.code === 'ECONNABORTED' || error?.code === 'ERR_NETWORK') {
errorMessage.value = "发送请求未确认,正在查询状态,请勿重复点击。"
if (pending.value) pending.value.deliveryStatus = "sending"
} else {
errorMessage.value = error?.message || "验证码发送失败,请稍后重试"
}
ElMessage.error(errorMessage.value)
} finally {
resending.value = false
}
}
const verifyWithWebAuthn = async () => {
if (!webAuthnAvailable.value || webAuthnLoading.value) {
return
}
webAuthnLoading.value = true
errorMessage.value = ""
try {
const begin = await http.post("/api/v1/security/webauthn/login/begin", {
challenge_id: pending.value.challengeId,
account: pending.value.account
})
if (begin.errorNo !== 0) {
throw new Error(begin.errorMsg || "安全密钥登录不可用")
}
const credential = await navigator.credentials.get({
publicKey: normalizeRequestOptions(begin.data.options)
})
const finish = await http.post("/api/v1/security/webauthn/login/finish", {
challenge_id: pending.value.challengeId,
account: pending.value.account,
session_id: begin.data.session_id,
credential: serializeCredential(credential)
})
if (finish.errorNo !== 0) {
throw new Error(finish.errorMsg || "安全密钥验证失败")
}
const redirect = getSafeLoginRedirect(pending.value?.redirect)
globalStatus.clearPendingLogin()
globalStatus.setUser(finish.data, true)
await router.replace(redirect)
} catch (error) {
if (error?.name !== "NotAllowedError") {
errorMessage.value = error?.message || "安全密钥验证失败,请重试"
ElMessage.error(errorMessage.value)
}
} finally {
webAuthnLoading.value = false
}
}
onMounted(() => {
if (!pending.value?.challengeId) {
router.replace({ name: "login" })
@ -168,20 +173,26 @@ export function usePage() {
startTimer()
})
onBeforeUnmount(stopTimer)
onBeforeUnmount(() => {
disposed = true
stopTimer()
})
return {
pending,
code,
loading,
resending,
webAuthnLoading,
method,
methods,
sending,
retrySeconds,
deliveryMessage,
chooseMethod,
remainingSeconds,
errorMessage,
webAuthnAvailable,
verifyCode,
resendCode,
verifyWithWebAuthn,
goBackToLogin
}
}

View File

@ -2,24 +2,53 @@
<AuthLayout
brand-caption="安全、简洁的邮件管理"
intro-title="完成安全验证"
intro-description="密码验证通过后,还需要确认已绑定的外部安全邮箱或安全密钥。"
intro-description="账号密码已通过验证。选择邮箱验证码或手机动态码,完成其中一种验证即可登录。"
>
<section class="verification-content">
<div class="verification-card">
<div class="verification-heading">
<span class="verification-eyebrow">登录保护</span>
<h2>输入邮箱验证码</h2>
<p>
验证码已发送至
<strong>{{ pending?.email || "已绑定邮箱" }}</strong>
</p>
<h2>选择验证方式</h2>
<p>当前账号:<strong>{{ pending?.account }}</strong></p>
</div>
<el-form class="verification-form" @submit.prevent="verifyCode">
<el-form-item label="邮箱验证码">
<div class="verification-methods">
<el-button
:type="method === 'email' ? 'primary' : 'default'"
:disabled="!methods.includes('email') || loading"
@click="chooseMethod('email')"
>邮箱验证码</el-button>
<el-button
:type="method === 'totp' ? 'primary' : 'default'"
:disabled="!methods.includes('totp') || loading"
@click="chooseMethod('totp')"
>手机动态码(TOTP)</el-button>
</div>
<p v-if="!methods.includes('totp')" class="verification-hint">
尚未启用手机动态码,可在登录后的安全中心自行开启。
</p>
<p v-if="!methods.includes('email')" class="verification-hint">
尚未绑定可用的安全邮箱,请使用手机动态码。
</p>
<div v-if="method === 'email'" class="verification-delivery" aria-live="polite">
<p>绑定邮箱:<strong>{{ pending?.email }}</strong></p>
<p>{{ deliveryMessage }}</p>
<el-button
:loading="resending"
:disabled="sending || retrySeconds > 0 || remainingSeconds <= 0"
@click="resendCode"
>{{ sending ? '正在发送' : retrySeconds > 0 ? `${retrySeconds} 秒后可重发` : '发送邮箱验证码' }}</el-button>
</div>
<p v-if="method === 'totp'" class="verification-hint">
打开已绑定的手机验证器,输入当前六位动态码。已使用的动态码请等待更新。
</p>
<el-form v-if="method" class="verification-form" @submit.prevent="verifyCode">
<el-form-item :label="method === 'totp' ? '六位手机动态码' : '八位邮箱验证码'">
<el-input
v-model="code"
maxlength="8"
:maxlength="method === 'totp' ? 6 : 8"
inputmode="numeric"
autocomplete="one-time-code"
placeholder="请输入验证码"
@ -38,8 +67,8 @@
<p class="verification-expiry">
{{ remainingSeconds > 0
? `验证码将在 ${remainingSeconds} 秒后过期`
: "验证码已过期,请重新登录" }}
? `请在 ${remainingSeconds} 秒内完成登录验证`
: "登录验证已过期,请重新登录" }}
</p>
<el-button
@ -47,41 +76,16 @@
size="large"
class="verification-submit"
:loading="loading"
:disabled="remainingSeconds <= 0"
:disabled="remainingSeconds <= 0 || (method === 'email' && pending?.deliveryStatus !== 'sent')"
@click="verifyCode"
>
验证并登录
</el-button>
<div class="verification-actions">
<el-button
link
type="primary"
:loading="resending"
:disabled="remainingSeconds > 540"
@click="resendCode"
>
重新发送验证码
</el-button>
<el-button link @click="goBackToLogin">返回登录</el-button>
</div>
</el-form>
<el-divider>或</el-divider>
<el-button
v-if="webAuthnAvailable"
plain
size="large"
class="webauthn-login-button"
:loading="webAuthnLoading"
@click="verifyWithWebAuthn"
>
使用安全密钥验证
</el-button>
<p v-else class="webauthn-unavailable">
当前浏览器不支持安全密钥验证,请使用邮箱验证码。
</p>
<div class="verification-actions">
<el-button link :disabled="loading" @click="goBackToLogin">返回登录</el-button>
</div>
</div>
</section>
</AuthLayout>
@ -96,15 +100,18 @@ const {
code,
loading,
resending,
webAuthnLoading,
method,
methods,
sending,
retrySeconds,
deliveryMessage,
chooseMethod,
remainingSeconds,
errorMessage,
webAuthnAvailable,
verifyCode,
resendCode,
verifyWithWebAuthn,
goBackToLogin
} = usePage()
</script>
<style scoped src="./LoginVerification.css"></style>
<style scoped src="./LoginVerification.css"></style>

View File

@ -25,6 +25,7 @@ export function usePage() {
options.push({ value: 'recovery', label: '离线恢复码' })
}
options.push({ value: 'external-email', label: '外部安全邮箱' })
options.push({ value: 'totp', label: '手机动态码' })
options.push({ value: 'webauthn', label: '安全密钥' })
return options
})

View File

@ -110,6 +110,8 @@
<el-empty v-else v-loading="externalListLoading" description="暂无外部安全邮箱" />
</section>
<TOTPSettings v-if="selectedSection === 'totp'" />
<el-divider v-if="selectedSection === 'webauthn'" class="settings-divider" />
<el-alert
v-if="selectedSection === 'webauthn' && !webAuthnEnabled"
@ -137,6 +139,7 @@
<script setup>
import { usePage } from './SecuritySettings.js'
import ProfileSettings from './ProfileSettings/ProfileSettings.vue'
import TOTPSettings from './TOTPSettings/TOTPSettings.vue'
import SettingsSubmenu from '../SettingsSubmenu/SettingsSubmenu.vue'
import GlobalSecurityPolicy from './GlobalSecurityPolicy/GlobalSecurityPolicy.vue'
import ExternalEmailBindingDialog from './ExternalEmailBindingDialog/ExternalEmailBindingDialog.vue'

View File

@ -0,0 +1,34 @@
.totp-settings {
max-width: 640px;
padding: 20px 0;
}
.totp-settings p {
color: var(--pm-text-secondary);
line-height: 1.7;
}
.totp-settings .el-alert {
margin-bottom: 16px;
}
.totp-enrollment img,
.totp-enrollment label,
.totp-enrollment code {
display: block;
margin: 12px 0;
}
.totp-enrollment code {
padding: 12px;
border: 1px solid var(--pm-border-color);
border-radius: 8px;
overflow-wrap: anywhere;
user-select: all;
}
.totp-actions {
display: flex;
gap: 12px;
margin-top: 16px;
}

View File

@ -0,0 +1,75 @@
import { onBeforeUnmount, onMounted, ref } from 'vue'
import QRCode from 'qrcode'
import { ElMessage } from 'element-plus'
import { http } from '@/utils/axios'
import { isRecentAuthenticationCancelled, requestWithRecentAuthentication } from '@/utils/recentAuthentication'
export function useTOTPSettings() {
const enabled = ref(false)
const ready = ref(false)
const busy = ref(false)
const enrollment = ref(null)
const qrCode = ref('')
const code = ref('')
const errorMessage = ref('')
let disposed = false
const clearEnrollment = () => {
enrollment.value = null
qrCode.value = ''
code.value = ''
}
const load = async () => {
const result = await http.get('/api/v1/security/totp', { timeout: 4500 })
if (result.errorNo !== 0) throw new Error(result.errorMsg || '无法读取动态码状态')
if (disposed) return
enabled.value = result.data.enabled
ready.value = true
}
const perform = async action => {
if (busy.value) return
if (['confirm', 'disable'].includes(action) && !/^\d{6}$/.test(code.value)) {
ElMessage.warning('请输入手机验证器生成的六位动态码')
return
}
busy.value = true
errorMessage.value = ''
try {
const result = await requestWithRecentAuthentication(() => http.post('/api/v1/security/totp', {
action, id: enrollment.value?.id, code: code.value
}, { timeout: 4500 }))
if (result.errorNo !== 0) throw new Error(result.errorMsg || '动态码设置失败')
if (disposed) return
if (action === 'begin') {
enrollment.value = result.data
// 二维码在浏览器本地生成,密钥不会发送给第三方服务。
qrCode.value = await QRCode.toDataURL(result.data.uri, { width: 240, margin: 2 })
} else {
clearEnrollment()
await load()
if (action !== 'cancel') ElMessage.success(action === 'confirm' ? '手机动态码已启用' : '手机动态码已关闭')
}
} catch (error) {
if (!disposed && !isRecentAuthenticationCancelled(error)) {
errorMessage.value = error?.message || '操作失败,请刷新状态后重试'
}
} finally {
busy.value = false
}
}
const refresh = async () => {
busy.value = true
errorMessage.value = ''
try { await load() } catch (error) { errorMessage.value = error.message } finally { busy.value = false }
}
onMounted(refresh)
onBeforeUnmount(() => {
disposed = true
clearEnrollment()
})
return { enabled, ready, busy, enrollment, qrCode, code, errorMessage, perform, refresh }
}

View File

@ -0,0 +1,45 @@
<template>
<section class="totp-settings" v-loading="busy">
<h3>手机动态码(TOTP)</h3>
<p>所有账号都可以自行选择是否开启。开启后,输入账号密码即可选择手机动态码或邮箱验证码登录。</p>
<el-alert v-if="errorMessage" :title="errorMessage" type="error" :closable="false" show-icon />
<el-button v-if="!ready" :disabled="busy" @click="refresh">重新读取状态</el-button>
<template v-else>
<el-tag :type="enabled ? 'success' : 'info'">{{ enabled ? '已启用' : '未启用' }}</el-tag>
<div v-if="enrollment" class="totp-enrollment">
<p>使用手机验证器扫描二维码,或手动输入下面的密钥。绑定申请十分钟内有效。</p>
<img v-if="qrCode" :src="qrCode" alt="手机验证器绑定二维码" width="240" height="240" />
<label>手动绑定密钥</label>
<code>{{ enrollment.secret }}</code>
<p>算法:SHA256;位数:6;周期:30 秒。请妥善保管密钥,勿分享给他人。</p>
<el-form @submit.prevent="perform('confirm')">
<el-form-item label="六位手机动态码">
<el-input v-model="code" maxlength="6" inputmode="numeric" autocomplete="one-time-code" placeholder="输入手机上当前显示的动态码" />
</el-form-item>
<div class="totp-actions">
<el-button type="primary" :disabled="busy" @click="perform('confirm')">确认启用</el-button>
<el-button :disabled="busy" @click="perform('cancel')">取消绑定</el-button>
</div>
</el-form>
</div>
<el-form v-else-if="enabled" class="totp-disable" @submit.prevent="perform('disable')">
<p>关闭前请输入当前动态码。刚绑定或已用于验证的动态码需要等待下一次更新。</p>
<el-form-item label="六位手机动态码">
<el-input v-model="code" maxlength="6" inputmode="numeric" autocomplete="one-time-code" placeholder="输入动态码以确认关闭" />
</el-form-item>
<el-button type="danger" plain :disabled="busy" @click="perform('disable')">关闭手机动态码</el-button>
</el-form>
<div v-else class="totp-actions">
<el-button type="primary" :disabled="busy" @click="perform('begin')">绑定手机验证器</el-button>
</div>
</template>
</section>
</template>
<script setup>
import { useTOTPSettings } from './TOTPSettings.js'
const { enabled, ready, busy, enrollment, qrCode, code, errorMessage, perform, refresh } = useTOTPSettings()
</script>
<style scoped src="./TOTPSettings.css"></style>

View File

@ -680,9 +680,9 @@ ansi-regex@^5.0.1:
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304"
integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==
ansi-styles@^4.1.0:
ansi-styles@^4.0.0, ansi-styles@^4.1.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937"
resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz"
integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==
dependencies:
color-convert "^2.0.1"
@ -770,6 +770,11 @@ callsites@^3.0.0:
resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73"
integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==
camelcase@^5.0.0:
version "5.3.1"
resolved "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz"
integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==
chalk@^4.0.0:
version "4.1.2"
resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01"
@ -793,6 +798,15 @@ chokidar@^3.5.3:
optionalDependencies:
fsevents "~2.3.2"
cliui@^6.0.0:
version "6.0.0"
resolved "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz"
integrity sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==
dependencies:
string-width "^4.2.0"
strip-ansi "^6.0.0"
wrap-ansi "^6.2.0"
color-convert@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3"
@ -883,6 +897,11 @@ debug@^4.3.1, debug@^4.3.2, debug@^4.3.4:
dependencies:
ms "^2.1.3"
decamelize@^1.2.0:
version "1.2.0"
resolved "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz"
integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==
deep-is@^0.1.3:
version "0.1.4"
resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831"
@ -893,6 +912,11 @@ delayed-stream@~1.0.0:
resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==
dijkstrajs@^1.0.1:
version "1.0.3"
resolved "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz"
integrity sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==
doctrine@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-3.0.0.tgz#addebead72a6574db783639dc87a121773973961"
@ -937,6 +961,11 @@ element-plus@2.14.5:
normalize-wheel-es "^1.2.0"
vue-component-type-helpers "^3.3.9"
emoji-regex@^8.0.0:
version "8.0.0"
resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz"
integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==
entities@^4.5.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48"
@ -1232,6 +1261,14 @@ fill-range@^7.1.1:
dependencies:
to-regex-range "^5.0.1"
find-up@^4.1.0:
version "4.1.0"
resolved "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz"
integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==
dependencies:
locate-path "^5.0.0"
path-exists "^4.0.0"
find-up@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc"
@ -1285,6 +1322,11 @@ function-bind@^1.1.2:
resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c"
integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==
get-caller-file@^2.0.1:
version "2.0.5"
resolved "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz"
integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==
get-intrinsic@^1.2.6:
version "1.3.0"
resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01"
@ -1463,6 +1505,11 @@ is-extglob@^2.1.1:
resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==
is-fullwidth-code-point@^3.0.0:
version "3.0.0"
resolved "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz"
integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==
is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1:
version "4.0.3"
resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084"
@ -1564,6 +1611,13 @@ local-pkg@^1.0.0:
pkg-types "^2.0.1"
quansync "^0.2.8"
locate-path@^5.0.0:
version "5.0.0"
resolved "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz"
integrity sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==
dependencies:
p-locate "^4.1.0"
locate-path@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286"
@ -1777,6 +1831,13 @@ optionator@^0.9.3:
type-check "^0.4.0"
word-wrap "^1.2.5"
p-limit@^2.2.0:
version "2.3.0"
resolved "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz"
integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==
dependencies:
p-try "^2.0.0"
p-limit@^3.0.2:
version "3.1.0"
resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b"
@ -1784,6 +1845,13 @@ p-limit@^3.0.2:
dependencies:
yocto-queue "^0.1.0"
p-locate@^4.1.0:
version "4.1.0"
resolved "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz"
integrity sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==
dependencies:
p-limit "^2.2.0"
p-locate@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834"
@ -1791,6 +1859,11 @@ p-locate@^5.0.0:
dependencies:
p-limit "^3.0.2"
p-try@^2.0.0:
version "2.2.0"
resolved "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz"
integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==
parent-module@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2"
@ -1869,6 +1942,11 @@ pkg-types@^2.0.1:
exsolve "^1.0.1"
pathe "^2.0.3"
pngjs@^5.0.0:
version "5.0.0"
resolved "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz"
integrity sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==
postcss-selector-parser@^6.0.15:
version "6.1.2"
resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz#27ecb41fb0e3b6ba7a1ec84fff347f734c7929de"
@ -1920,6 +1998,15 @@ punycode@^2.1.0:
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
qrcode@^1.5.4:
version "1.5.4"
resolved "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz"
integrity sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==
dependencies:
dijkstrajs "^1.0.1"
pngjs "^5.0.0"
yargs "^15.3.1"
quansync@^0.2.8:
version "0.2.10"
resolved "https://registry.yarnpkg.com/quansync/-/quansync-0.2.10.tgz#32053cf166fa36511aae95fc49796116f2dc20e1"
@ -1937,6 +2024,16 @@ readdirp@~3.6.0:
dependencies:
picomatch "^2.2.1"
require-directory@^2.1.1:
version "2.1.1"
resolved "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz"
integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==
require-main-filename@^2.0.0:
version "2.0.0"
resolved "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz"
integrity sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==
resolve-from@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6"
@ -1994,6 +2091,11 @@ semver@^7.3.6, semver@^7.6.3:
resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.1.tgz#abd5098d82b18c6c81f6074ff2647fd3e7220c9f"
integrity sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==
set-blocking@^2.0.0:
version "2.0.0"
resolved "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz"
integrity sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==
shebang-command@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea"
@ -2037,7 +2139,16 @@ ssr-window@^3.0.0-alpha.1:
resolved "https://registry.npmmirror.com/ssr-window/-/ssr-window-3.0.0.tgz#fd5b82801638943e0cc704c4691801435af7ac37"
integrity sha512-q+8UfWDg9Itrg0yWK7oe5p/XRCJpJF9OBtXfOPgSJl+u3Xd5KI328RUEvUqSMVM9CiQUEf1QdBzJMkYGErj9QA==
strip-ansi@^6.0.1:
string-width@^4.1.0, string-width@^4.2.0:
version "4.2.3"
resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@ -2257,6 +2368,11 @@ webpack-virtual-modules@^0.6.2:
resolved "https://registry.yarnpkg.com/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz#057faa9065c8acf48f24cb57ac0e77739ab9a7e8"
integrity sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==
which-module@^2.0.0:
version "2.0.1"
resolved "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz"
integrity sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==
which@^2.0.1:
version "2.0.2"
resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1"
@ -2274,6 +2390,15 @@ word-wrap@^1.2.5:
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34"
integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==
wrap-ansi@^6.2.0:
version "6.2.0"
resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz"
integrity sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==
dependencies:
ansi-styles "^4.0.0"
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrappy@1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
@ -2284,6 +2409,36 @@ xml-name-validator@^4.0.0:
resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-4.0.0.tgz#79a006e2e63149a8600f15430f0a4725d1524835"
integrity sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==
y18n@^4.0.0:
version "4.0.3"
resolved "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz"
integrity sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==
yargs-parser@^18.1.2:
version "18.1.3"
resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz"
integrity sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==
dependencies:
camelcase "^5.0.0"
decamelize "^1.2.0"
yargs@^15.3.1:
version "15.4.1"
resolved "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz"
integrity sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==
dependencies:
cliui "^6.0.0"
decamelize "^1.2.0"
find-up "^4.1.0"
get-caller-file "^2.0.1"
require-directory "^2.1.1"
require-main-filename "^2.0.0"
set-blocking "^2.0.0"
string-width "^4.2.0"
which-module "^2.0.0"
y18n "^4.0.0"
yargs-parser "^18.1.2"
yocto-queue@^0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"

View File

@ -0,0 +1,57 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { compileFunction } from 'node:vm'
import { computed, ref } from './vue.mjs'
const source = readFileSync(new URL('../../frontend/src/views/LoginVerification/LoginVerification.js', import.meta.url), 'utf8')
const createPage = compileFunction(
source.replace(/^import .*$/gm, '').replace('export function usePage', 'function usePage') + '\nreturn usePage()',
['computed', 'onBeforeUnmount', 'onMounted', 'ref', 'ElMessage', 'useRouter', 'useGlobalStatusStore', 'http', 'getSafeLoginRedirect', 'window']
)
function fixture() {
const calls = []
const pending = { challengeId: 'pending-id', account: 'alice', methods: ['email', 'totp'], deliveryStatus: 'idle', expiresAt: new Date(Date.now() + 600000).toISOString() }
const store = {
pendingLogin: pending,
setPendingLogin: data => Object.assign(pending, { deliveryStatus: data.delivery_status, retryAt: data.retry_at }),
clearPendingLogin: () => { store.pendingLogin = null },
setUser: () => calls.push('session')
}
let mount
const page = createPage(computed, () => {}, callback => { mount = callback }, ref,
{ error() {}, warning() {} }, () => ({ replace: async () => {} }), () => store,
{ post: async (url, input, options) => {
calls.push({ url, input, options })
if (url.endsWith('/resend')) return { errorNo: 0, data: { pending: true, delivery_status: 'sending', retry_at: new Date(Date.now() + 60000).toISOString() } }
return { errorNo: 0, data: { account: 'alice' } }
} }, () => '/', { setInterval: () => 1, clearInterval() {} })
mount()
return { page, calls }
}
test('进入选择页和切换验证方式均不发信,只有点击发送按钮才请求一次', async () => {
const { page, calls } = fixture()
assert.deepEqual(calls, [])
page.chooseMethod('email')
page.chooseMethod('totp')
page.chooseMethod('email')
assert.deepEqual(calls, [])
await page.resendCode()
await page.resendCode()
assert.equal(calls.length, 1)
assert.equal(calls[0].url, '/api/v1/security/login/resend')
assert.equal(calls[0].options.timeout, 4500)
})
test('选择 TOTP 后直接验证六位动态码,全程不请求发送邮件', async () => {
const { page, calls } = fixture()
page.chooseMethod('totp')
page.code.value = '123456'
await page.verifyCode()
assert.equal(calls[0].url, '/api/v1/security/login/verify')
assert.equal(calls[0].input.method, 'totp')
assert.equal(calls[0].options.timeout, 4500)
assert.equal(calls.some(call => call.url?.endsWith('/resend')), false)
})