diff --git a/backend/controllers/login.go b/backend/controllers/login.go index c9b9193..fb91526 100644 --- a/backend/controllers/login.go +++ b/backend/controllers/login.go @@ -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 diff --git a/backend/controllers/login_methods_test.go b/backend/controllers/login_methods_test.go new file mode 100644 index 0000000..d4757ef --- /dev/null +++ b/backend/controllers/login_methods_test.go @@ -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") + } +} diff --git a/backend/controllers/login_verification.go b/backend/controllers/login_verification.go index d376bcf..740ebf9 100644 --- a/backend/controllers/login_verification.go +++ b/backend/controllers/login_verification.go @@ -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)) diff --git a/backend/controllers/security.go b/backend/controllers/security.go index c864c21..d6dc1d3 100644 --- a/backend/controllers/security.go +++ b/backend/controllers/security.go @@ -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) } diff --git a/backend/controllers/totp.go b/backend/controllers/totp.go new file mode 100644 index 0000000..96a7eb6 --- /dev/null +++ b/backend/controllers/totp.go @@ -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) +} diff --git a/backend/listen/http_server/http_server.go b/backend/listen/http_server/http_server.go index 162ce56..7efec67 100644 --- a/backend/listen/http_server/http_server.go +++ b/backend/listen/http_server/http_server.go @@ -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))) diff --git a/backend/listen/http_server/https_server.go b/backend/listen/http_server/https_server.go index 2cddd03..3b1acea 100644 --- a/backend/listen/http_server/https_server.go +++ b/backend/listen/http_server/https_server.go @@ -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: diff --git a/backend/res_init/init.go b/backend/res_init/init.go index 2e25d03..4c09458 100644 --- a/backend/res_init/init.go +++ b/backend/res_init/init.go @@ -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 diff --git a/backend/res_init/verification_delivery.go b/backend/res_init/verification_delivery.go index b993cd5..ece3de1 100644 --- a/backend/res_init/verification_delivery.go +++ b/backend/res_init/verification_delivery.go @@ -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 { diff --git a/backend/services/totp/service.go b/backend/services/totp/service.go index a318fcb..106ada1 100644 --- a/backend/services/totp/service.go +++ b/backend/services/totp/service.go @@ -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, diff --git a/backend/services/verification/delivery.go b/backend/services/verification/delivery.go index ce03a17..e8a8ebe 100644 --- a/backend/services/verification/delivery.go +++ b/backend/services/verification/delivery.go @@ -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 == "" { diff --git a/backend/utils/smtp/context_test.go b/backend/utils/smtp/context_test.go new file mode 100644 index 0000000..94fafec --- /dev/null +++ b/backend/utils/smtp/context_test.go @@ -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") + } +} diff --git a/frontend/package.json b/frontend/package.json index 142e2fc..44bfac1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" diff --git a/frontend/src/stores/useGlobalStatusStore.js b/frontend/src/stores/useGlobalStatusStore.js index 7537605..b2c2a48 100644 --- a/frontend/src/stores/useGlobalStatusStore.js +++ b/frontend/src/stores/useGlobalStatusStore.js @@ -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) }; diff --git a/frontend/src/views/Login/Login.js b/frontend/src/views/Login/Login.js index 6fae0a2..25de574 100644 --- a/frontend/src/views/Login/Login.js +++ b/frontend/src/views/Login/Login.js @@ -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 diff --git a/frontend/src/views/LoginVerification/LoginVerification.css b/frontend/src/views/LoginVerification/LoginVerification.css index 82430cb..178fbfe 100644 --- a/frontend/src/views/LoginVerification/LoginVerification.css +++ b/frontend/src/views/LoginVerification/LoginVerification.css @@ -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; } -} \ No newline at end of file +} diff --git a/frontend/src/views/LoginVerification/LoginVerification.js b/frontend/src/views/LoginVerification/LoginVerification.js index 1985f68..8e5192d 100644 --- a/frontend/src/views/LoginVerification/LoginVerification.js +++ b/frontend/src/views/LoginVerification/LoginVerification.js @@ -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 } } diff --git a/frontend/src/views/LoginVerification/LoginVerification.vue b/frontend/src/views/LoginVerification/LoginVerification.vue index fffd44a..83c2c5b 100644 --- a/frontend/src/views/LoginVerification/LoginVerification.vue +++ b/frontend/src/views/LoginVerification/LoginVerification.vue @@ -2,24 +2,53 @@
登录保护 -

输入邮箱验证码

-

- 验证码已发送至 - {{ pending?.email || "已绑定邮箱" }} -

+

选择验证方式

+

当前账号:{{ pending?.account }}

- - +
+ 邮箱验证码 + 手机动态码(TOTP) +
+

+ 尚未启用手机动态码,可在登录后的安全中心自行开启。 +

+

+ 尚未绑定可用的安全邮箱,请使用手机动态码。 +

+ +
+

绑定邮箱:{{ pending?.email }}

+

{{ deliveryMessage }}

+ {{ sending ? '正在发送' : retrySeconds > 0 ? `${retrySeconds} 秒后可重发` : '发送邮箱验证码' }} +
+

+ 打开已绑定的手机验证器,输入当前六位动态码。已使用的动态码请等待更新。 +

+ + + {{ remainingSeconds > 0 - ? `验证码将在 ${remainingSeconds} 秒后过期` - : "验证码已过期,请重新登录" }} + ? `请在 ${remainingSeconds} 秒内完成登录验证` + : "登录验证已过期,请重新登录" }}

验证并登录 -
- - 重新发送验证码 - - 返回登录 -
- - 或 - - -

- 当前浏览器不支持安全密钥验证,请使用邮箱验证码。 -

+
+ 返回登录 +
@@ -96,15 +100,18 @@ const { code, loading, resending, - webAuthnLoading, + method, + methods, + sending, + retrySeconds, + deliveryMessage, + chooseMethod, remainingSeconds, errorMessage, - webAuthnAvailable, verifyCode, resendCode, - verifyWithWebAuthn, goBackToLogin } = usePage() - \ No newline at end of file + diff --git a/frontend/src/views/components/SecuritySettings/SecuritySettings.js b/frontend/src/views/components/SecuritySettings/SecuritySettings.js index 6de1426..0f383da 100644 --- a/frontend/src/views/components/SecuritySettings/SecuritySettings.js +++ b/frontend/src/views/components/SecuritySettings/SecuritySettings.js @@ -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 }) diff --git a/frontend/src/views/components/SecuritySettings/SecuritySettings.vue b/frontend/src/views/components/SecuritySettings/SecuritySettings.vue index 7ebec00..3227ed6 100644 --- a/frontend/src/views/components/SecuritySettings/SecuritySettings.vue +++ b/frontend/src/views/components/SecuritySettings/SecuritySettings.vue @@ -110,6 +110,8 @@ + + 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' diff --git a/frontend/src/views/components/SecuritySettings/TOTPSettings/TOTPSettings.css b/frontend/src/views/components/SecuritySettings/TOTPSettings/TOTPSettings.css new file mode 100644 index 0000000..0e5676a --- /dev/null +++ b/frontend/src/views/components/SecuritySettings/TOTPSettings/TOTPSettings.css @@ -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; +} diff --git a/frontend/src/views/components/SecuritySettings/TOTPSettings/TOTPSettings.js b/frontend/src/views/components/SecuritySettings/TOTPSettings/TOTPSettings.js new file mode 100644 index 0000000..81a8380 --- /dev/null +++ b/frontend/src/views/components/SecuritySettings/TOTPSettings/TOTPSettings.js @@ -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 } +} diff --git a/frontend/src/views/components/SecuritySettings/TOTPSettings/TOTPSettings.vue b/frontend/src/views/components/SecuritySettings/TOTPSettings/TOTPSettings.vue new file mode 100644 index 0000000..a5a3a88 --- /dev/null +++ b/frontend/src/views/components/SecuritySettings/TOTPSettings/TOTPSettings.vue @@ -0,0 +1,45 @@ + + + + + diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 7dbfbf3..fb7d701 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -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" diff --git a/test/frontend/login-methods.test.mjs b/test/frontend/login-methods.test.mjs new file mode 100644 index 0000000..8548aa9 --- /dev/null +++ b/test/frontend/login-methods.test.mjs @@ -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) +})