package mysql import ( "context" "errors" "git.yuntuo.hk/k2safe/orbit/internal/consoleauth" "git.yuntuo.hk/k2safe/orbit/internal/fault" "git.yuntuo.hk/k2safe/orbit/platform/auth" "os" "strings" "sync" "testing" "time" ) func TestIntegrationConsoleLoginProtection(t *testing.T) { dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN") if dsn == "" { t.Skip("requires disposable MySQL") } ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) defer cancel() db, err := Open(ctx, dsn, 16, 4) if err != nil { t.Fatal(err) } defer db.Close() if err = Migrate(ctx, db); err != nil { t.Fatal(err) } username := "login-" + string(testID(t)) password := "login-protection-test-12345" var boot BootstrapResult if err = Bootstrap(ctx, db, BootstrapInput{Application: username, Namespace: "default", Environment: "test", Queue: "work", ConsoleUsername: username, ConsolePassword: password}, func(v BootstrapResult) error { boot = v; return nil }); err != nil { t.Fatal(err) } p := consoleauth.DefaultLoginPolicy() p.FailureLimit = 3 p.IPLimit = 1000 p.AccountLimit = 100 p.LockDuration = time.Second first, second := NewConsoleAuthStore(db, p), NewConsoleAuthStore(db, p) exec := func(q string, args ...any) { t.Helper() if _, err := db.ExecContext(ctx, q, args...); err != nil { t.Fatal(err) } } var userID string if err = db.QueryRowContext(ctx, `SELECT LOWER(HEX(id)) FROM orbit_console_users WHERE username=?`, username).Scan(&userID); err != nil { t.Fatal(err) } key := loginSubject("user:" + userID) clear := func() { exec(`DELETE FROM orbit_console_login_guards WHERE kind='ACCOUNT' AND subject=?`, key) } login := func(s *ConsoleAuthStore, pass, ip string) error { t.Helper() _, err := s.Login(ctx, username, pass, "login-regression", ip) return err } expectBlocked := func(err error, locked bool) { t.Helper() var b *consoleauth.LoginBlocked if !errors.As(err, &b) || b.Locked != locked || b.RetryAfter <= 0 { t.Fatalf("expected block locked=%v got %v", locked, err) } } t.Run("failure_success_reset_cross_node_and_expiry", func(t *testing.T) { if err = login(first, "wrong", "192.0.2.10"); !errors.Is(err, fault.Unauthorized) { t.Fatal(err) } if err = login(second, password, "192.0.2.11"); err != nil { t.Fatal(err) } for i := 0; i < 2; i++ { if err = login(first, "wrong", "192.0.2.10"); !errors.Is(err, fault.Unauthorized) { t.Fatal(err) } } expectBlocked(login(second, "wrong", "192.0.2.11"), true) var until, after time.Time if err = db.QueryRowContext(ctx, `SELECT locked_until FROM orbit_console_login_guards WHERE kind='ACCOUNT' AND subject=?`, key).Scan(&until); err != nil { t.Fatal(err) } expectBlocked(login(NewConsoleAuthStore(db, p), password, "192.0.2.12"), true) if err = db.QueryRowContext(ctx, `SELECT locked_until FROM orbit_console_login_guards WHERE kind='ACCOUNT' AND subject=?`, key).Scan(&after); err != nil || !until.Equal(after) { t.Fatal("lock was extended", err) } // 使用数据库自身时钟等待真实截止,重建存储实例不能提前解除锁定。 for { var elapsed bool if err = db.QueryRowContext(ctx, "SELECT UTC_TIMESTAMP(6)>=?", until).Scan(&elapsed); err != nil { t.Fatal(err) } if elapsed { break } select { case <-ctx.Done(): t.Fatal(ctx.Err()) case <-time.After(25 * time.Millisecond): } } if err = login(second, password, "192.0.2.12"); err != nil { t.Fatal(err) } }) t.Run("concurrent_failures_serialize_and_lock", func(t *testing.T) { clear() policy := p policy.LockDuration = time.Minute a, b := NewConsoleAuthStore(db, policy), NewConsoleAuthStore(db, policy) results := make(chan error, 6) var wg sync.WaitGroup start := make(chan struct{}) for i := 0; i < 6; i++ { wg.Add(1) go func(i int) { defer wg.Done() <-start s := a if i%2 == 1 { s = b } results <- login(s, "wrong", "192.0.2.20") }(i) } close(start) wg.Wait() close(results) invalid, blocked := 0, 0 for err := range results { var v *consoleauth.LoginBlocked if errors.As(err, &v) && v.Locked { blocked++ } else if errors.Is(err, fault.Unauthorized) { invalid++ } else { t.Fatal(err) } } if invalid != 2 || blocked != 4 { t.Fatalf("invalid=%d blocked=%d", invalid, blocked) } expectBlocked(login(a, password, "192.0.2.21"), true) var count int if err = db.QueryRowContext(ctx, `SELECT failures FROM orbit_console_login_guards WHERE kind='ACCOUNT' AND subject=?`, key).Scan(&count); err != nil || count != 3 { t.Fatalf("failure count=%d %v", count, err) } }) t.Run("account_and_ip_limits", func(t *testing.T) { clear() policy := p policy.AccountLimit = 2 s := NewConsoleAuthStore(db, policy) for _, ip := range []string{"192.0.2.30", "192.0.2.31"} { if err = login(s, password, ip); err != nil { t.Fatal(err) } } expectBlocked(login(NewConsoleAuthStore(db, policy), password, "192.0.2.32"), false) clear() policy.IPLimit = 2 s = NewConsoleAuthStore(db, policy) for i := 0; i < 2; i++ { _, err = s.Login(ctx, "unknown-"+string(testID(t)), "wrong", "test", "192.0.2.40") if !errors.Is(err, fault.Unauthorized) { t.Fatal(err) } } expectBlocked(login(s, password, "192.0.2.40"), false) exec(`UPDATE orbit_console_login_guards SET window_start=UTC_TIMESTAMP(6)-INTERVAL 2 MINUTE WHERE kind='IP' AND subject=?`, loginSubject("192.0.2.40")) if err = login(s, password, "192.0.2.40"); err != nil { t.Fatal(err) } }) t.Run("unknown_account_and_failure_window", func(t *testing.T) { unknown := "unknown-" + string(testID(t)) policy := p policy.FailureLimit = 2 s := NewConsoleAuthStore(db, policy) _, err = s.Login(ctx, unknown, "wrong", "test", "192.0.2.50") if !errors.Is(err, fault.Unauthorized) { t.Fatal(err) } _, err = s.Login(ctx, unknown, "wrong", "test", "192.0.2.50") expectBlocked(err, true) clear() if err = login(first, "wrong", "192.0.2.51"); !errors.Is(err, fault.Unauthorized) { t.Fatal(err) } exec(`UPDATE orbit_console_login_guards SET failures=2,first_failed_at=UTC_TIMESTAMP(6)-INTERVAL 1 HOUR WHERE kind='ACCOUNT' AND subject=?`, key) if err = login(second, "wrong", "192.0.2.51"); !errors.Is(err, fault.Unauthorized) { t.Fatal(err) } }) t.Run("audit_failure_cannot_issue_session", func(t *testing.T) { clear() var before, after int db.QueryRowContext(ctx, `SELECT COUNT(*) FROM orbit_console_sessions WHERE user_id=UNHEX(?)`, userID).Scan(&before) trigger := "login_audit_" + string(testID(t)) exec(`CREATE TRIGGER ` + trigger + ` BEFORE UPDATE ON orbit_console_login_audit FOR EACH ROW BEGIN IF NEW.username='` + username + `' AND NEW.outcome='SUCCESS' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='test audit unavailable'; END IF; END`) defer exec("DROP TRIGGER " + trigger) if err = login(first, password, "192.0.2.60"); err == nil { t.Fatal("audit failure issued session") } db.QueryRowContext(ctx, `SELECT COUNT(*) FROM orbit_console_sessions WHERE user_id=UNHEX(?)`, userID).Scan(&after) if before != after { t.Fatal("session survived failed audit") } }) t.Run("audit_admin_pagination_secrets_and_cleanup", func(t *testing.T) { clear() result, err := first.Login(ctx, username, password, "test", "192.0.2.70") if err != nil { t.Fatal(err) } session, _ := auth.Parse(result.Token) sdk, _ := auth.Parse(boot.Token) store := NewConsoleApplicationStore(db, username) if _, err = store.ListLoginAudit(ctx, sdk, ""); !errors.Is(err, fault.Forbidden) { t.Fatal("SDK read login audit", err) } if _, err = NewConsoleApplicationStore(db, "another-admin").ListLoginAudit(ctx, session, ""); !errors.Is(err, fault.Forbidden) { t.Fatal("non-admin read audit", err) } page, err := store.ListLoginAudit(ctx, session, "") if err != nil || len(page.Items) != 10 || page.Next == "" { t.Fatalf("page: %+v %v", page, err) } next, err := store.ListLoginAudit(ctx, session, page.Next) if err != nil || len(next.Items) == 0 || next.Items[0].ID >= page.Items[9].ID { t.Fatal("audit pagination", err) } rows, err := db.QueryContext(ctx, `SELECT CONCAT(username,remote_ip,user_agent,outcome) FROM orbit_console_login_audit WHERE username=?`, username) if err != nil { t.Fatal(err) } for rows.Next() { var value string if err = rows.Scan(&value); err != nil { t.Fatal(err) } if strings.Contains(value, password) || strings.Contains(value, result.Token) { t.Fatal("audit contains secret") } } rows.Close() exec(`UPDATE orbit_console_login_audit SET created_at=UTC_TIMESTAMP(6)-INTERVAL 91 DAY WHERE username=? AND outcome='INVALID'`, username) exec(`UPDATE orbit_console_login_audit SET created_at=UTC_TIMESTAMP(6)-INTERVAL 11 MINUTE WHERE username=? AND outcome='PENDING'`, username) exec(`UPDATE orbit_console_login_guards SET expires_at=UTC_TIMESTAMP(6)-INTERVAL 1 DAY WHERE kind='IP' AND subject=?`, loginSubject("192.0.2.70")) if err = first.CleanupLoginSecurity(ctx); err != nil { t.Fatal(err) } var n int if err = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM orbit_console_login_audit WHERE username=? AND (outcome='PENDING' OR created_at