auth-bruteforce-integration.test.ts 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172
  1. import { beforeEach, describe, expect, it, vi } from "vitest";
  2. import { NextRequest } from "next/server";
  3. const mockValidateKey = vi.hoisted(() => vi.fn());
  4. const mockSetAuthCookie = vi.hoisted(() => vi.fn());
  5. const mockGetLoginRedirectTarget = vi.hoisted(() => vi.fn());
  6. const mockGetSessionTokenMode = vi.hoisted(() => vi.fn());
  7. const mockGetTranslations = vi.hoisted(() => vi.fn());
  8. const mockLogger = vi.hoisted(() => ({
  9. warn: vi.fn(),
  10. error: vi.fn(),
  11. info: vi.fn(),
  12. debug: vi.fn(),
  13. }));
  14. vi.mock("@/lib/auth", () => ({
  15. validateKey: mockValidateKey,
  16. setAuthCookie: mockSetAuthCookie,
  17. getLoginRedirectTarget: mockGetLoginRedirectTarget,
  18. getSessionTokenMode: mockGetSessionTokenMode,
  19. withNoStoreHeaders: <T>(res: T): T => {
  20. (res as Response).headers.set("Cache-Control", "no-store, no-cache, must-revalidate");
  21. (res as Response).headers.set("Pragma", "no-cache");
  22. return res;
  23. },
  24. }));
  25. vi.mock("next-intl/server", () => ({
  26. getTranslations: mockGetTranslations,
  27. }));
  28. vi.mock("@/lib/logger", () => ({
  29. logger: mockLogger,
  30. }));
  31. vi.mock("@/lib/config/env.schema", () => ({
  32. getEnvConfig: () => ({ ENABLE_SECURE_COOKIES: false, SESSION_TOKEN_MODE: "legacy" }),
  33. }));
  34. vi.mock("@/lib/security/auth-response-headers", () => ({
  35. withAuthResponseHeaders: <T>(res: T): T => {
  36. (res as Response).headers.set("Cache-Control", "no-store, no-cache, must-revalidate");
  37. (res as Response).headers.set("Pragma", "no-cache");
  38. return res;
  39. },
  40. }));
  41. function makeRequest(body: unknown, ip: string): NextRequest {
  42. return new NextRequest("http://localhost/api/auth/login", {
  43. method: "POST",
  44. headers: {
  45. "Content-Type": "application/json",
  46. "x-forwarded-for": ip,
  47. "x-forwarded-proto": "https",
  48. },
  49. body: JSON.stringify(body),
  50. });
  51. }
  52. const fakeSession = {
  53. user: {
  54. id: 1,
  55. name: "Test User",
  56. description: "desc",
  57. role: "user" as const,
  58. },
  59. key: { canLoginWebUi: true },
  60. };
  61. async function exhaustFailures(
  62. POST: (request: NextRequest) => Promise<Response>,
  63. ip: string,
  64. count = 10
  65. ) {
  66. for (let i = 0; i < count; i++) {
  67. const res = await POST(makeRequest({ key: `bad-${i}` }, ip));
  68. expect(res.status).toBe(401);
  69. }
  70. }
  71. describe("auth login anti-bruteforce integration", () => {
  72. let POST: (request: NextRequest) => Promise<Response>;
  73. beforeEach(async () => {
  74. vi.resetModules();
  75. vi.clearAllMocks();
  76. const mockT = vi.fn((key: string) => `translated:${key}`);
  77. mockGetTranslations.mockResolvedValue(mockT);
  78. mockSetAuthCookie.mockResolvedValue(undefined);
  79. mockGetLoginRedirectTarget.mockReturnValue("/dashboard");
  80. mockGetSessionTokenMode.mockReturnValue("legacy");
  81. const mod = await import("../../src/app/api/auth/login/route");
  82. POST = mod.POST;
  83. });
  84. it("normal request passes rate-limit check", async () => {
  85. mockValidateKey.mockResolvedValue(null);
  86. const res = await POST(makeRequest({ key: "bad-key" }, "198.51.100.10"));
  87. expect(res.status).toBe(401);
  88. expect(res.headers.get("Retry-After")).toBeNull();
  89. expect(mockValidateKey).toHaveBeenCalledWith("bad-key", { allowReadOnlyAccess: true });
  90. });
  91. it("returns 429 with Retry-After after max failures", async () => {
  92. const ip = "198.51.100.20";
  93. mockValidateKey.mockResolvedValue(null);
  94. await exhaustFailures(POST, ip);
  95. const blockedRes = await POST(makeRequest({ key: "blocked-now" }, ip));
  96. expect(blockedRes.status).toBe(429);
  97. expect(blockedRes.headers.get("Retry-After")).not.toBeNull();
  98. expect(Number.parseInt(blockedRes.headers.get("Retry-After") ?? "0", 10)).toBeGreaterThan(0);
  99. expect(mockValidateKey).toHaveBeenCalledTimes(10);
  100. });
  101. it("successful login resets failure counter", async () => {
  102. const ip = "198.51.100.30";
  103. mockValidateKey.mockImplementation(async (key: string) => {
  104. return key === "valid-key" ? fakeSession : null;
  105. });
  106. for (let i = 0; i < 9; i++) {
  107. const res = await POST(makeRequest({ key: `bad-before-success-${i}` }, ip));
  108. expect(res.status).toBe(401);
  109. }
  110. const successRes = await POST(makeRequest({ key: "valid-key" }, ip));
  111. expect(successRes.status).toBe(200);
  112. const firstAfterSuccess = await POST(makeRequest({ key: "bad-after-success-1" }, ip));
  113. const secondAfterSuccess = await POST(makeRequest({ key: "bad-after-success-2" }, ip));
  114. expect(firstAfterSuccess.status).toBe(401);
  115. expect(secondAfterSuccess.status).toBe(401);
  116. expect(secondAfterSuccess.headers.get("Retry-After")).toBeNull();
  117. expect(mockSetAuthCookie).toHaveBeenCalledWith("valid-key");
  118. });
  119. it("429 response includes errorCode RATE_LIMITED", async () => {
  120. const ip = "198.51.100.40";
  121. mockValidateKey.mockResolvedValue(null);
  122. await exhaustFailures(POST, ip);
  123. const blockedRes = await POST(makeRequest({ key: "blocked-key" }, ip));
  124. expect(blockedRes.status).toBe(429);
  125. await expect(blockedRes.json()).resolves.toMatchObject({
  126. errorCode: "RATE_LIMITED",
  127. });
  128. });
  129. it("tracks different IPs independently", async () => {
  130. const blockedIp = "198.51.100.50";
  131. const freshIp = "198.51.100.51";
  132. mockValidateKey.mockResolvedValue(null);
  133. await exhaustFailures(POST, blockedIp);
  134. const blockedRes = await POST(makeRequest({ key: "blocked-key" }, blockedIp));
  135. const freshRes = await POST(makeRequest({ key: "fresh-ip-key" }, freshIp));
  136. expect(blockedRes.status).toBe(429);
  137. expect(freshRes.status).toBe(401);
  138. });
  139. });