This commit is contained in:
parent
04cc5774e0
commit
41593ee762
@ -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
|
||||
|
||||
|
||||
295
backend/controllers/login_methods_test.go
Normal file
295
backend/controllers/login_methods_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
77
backend/controllers/totp.go
Normal file
77
backend/controllers/totp.go
Normal 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)
|
||||
}
|
||||
@ -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)))
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 == "" {
|
||||
|
||||
46
backend/utils/smtp/context_test.go
Normal file
46
backend/utils/smtp/context_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
|
||||
@ -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)
|
||||
};
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
})
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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 }
|
||||
}
|
||||
@ -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>
|
||||
@ -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"
|
||||
|
||||
57
test/frontend/login-methods.test.mjs
Normal file
57
test/frontend/login-methods.test.mjs
Normal 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)
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user