import { test, expect } from "@playwright/test"; // 테스트 순차 실행 보장 (전 단계 상태가 영향을 줄 수 있으므로 serial 모드로 설정) // npx playwright test tests/multi-device-auth.spec.ts --debug test.describe.configure({ mode: "serial" }); const adminId = process.env.TEST_ADMIN_ID || "admin1gmail"; const adminPassword = process.env.TEST_ADMIN_PW || "test1234!"; const DEVICE_PC = "device-pc-chrome-001"; const DEVICE_MOBILE = "device-mobile-safari-002"; const DEVICE_TABLET = "device-tablet-firefox-003"; // 디바이스별로 완전히 분리된 HTTP 요청 컨텍스트를 생성하여 세션 격리를 모사하는 헬퍼 함수 async function createDeviceContext(playwrightRequest: any, deviceId: string) { return await playwrightRequest.newContext({ baseURL: process.env.PLAYWRIGHT_BASE_URL || "http://localhost:3000", extraHTTPHeaders: { "X-Device-Id": deviceId, "Content-Type": "application/json", }, }); } // 1초 이상의 간격을 두어 토큰 발급/갱신 시 만료 시각(초)이 다르게 생성되도록 유도하는 지연 함수 const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); // 응답의 Set-Cookie 헤더 배열에서 특정 쿠키 값을 추출하는 헬퍼 함수 function getCookieValue( headers: { name: string; value: string }[], name: string, ): string | null { const cookieHeaders = headers.filter( (h) => h.name.toLowerCase() === "set-cookie", ); for (const header of cookieHeaders) { const firstPart = header.value.split(";")[0]; if (!firstPart) continue; const parts = firstPart.split("="); const key = parts[0]?.trim(); const val = parts[1]?.trim(); if (key === name) { return val ?? null; } } return null; } // 응답의 Set-Cookie 헤더에서 특정 쿠키의 속성(httpOnly, sameSite 등) 존재 유무를 확인하는 헬퍼 함수 function hasCookieAttribute( headers: { name: string; value: string }[], name: string, attribute: string, ): boolean { const cookieHeaders = headers.filter( (h) => h.name.toLowerCase() === "set-cookie", ); for (const header of cookieHeaders) { const parts = header.value.split(";"); const firstPart = parts[0]; if (!firstPart) continue; const cookieName = firstPart.split("=")[0]!.trim(); if (cookieName === name) { return parts .slice(1) .some((p) => p.trim().toLowerCase() === attribute.toLowerCase()); } } return false; } test.describe("다중 디바이스 인증 & Refresh Token Rotation (RTR) 테스트", () => { test("1. 로그인 성공 시 httpOnly refresh-token 쿠키 설정", async ({ request }) => { const response = await request.post("/api/proxy/user-service/login", { data: { userId: adminId, password: adminPassword }, headers: { "X-Device-Id": DEVICE_PC }, }); expect(response.status()).toBe(200); const headers = response.headersArray(); const refreshToken = getCookieValue(headers, "refresh-token"); expect(refreshToken).not.toBeNull(); expect(refreshToken).not.toBe(""); // httpOnly 속성이 설정되었는지 검증 const isHttpOnly = hasCookieAttribute(headers, "refresh-token", "httponly"); expect(isHttpOnly).toBe(true); }); test("2. PC/모바일/태블릿 독립 refresh-token 발급", async ({ playwright }) => { const pcContext = await createDeviceContext(playwright.request, DEVICE_PC); const mobileContext = await createDeviceContext(playwright.request, DEVICE_MOBILE); const tabletContext = await createDeviceContext(playwright.request, DEVICE_TABLET); const pcRes = await pcContext.post("/api/proxy/user-service/login", { data: { userId: adminId, password: adminPassword }, }); const pcToken = getCookieValue(pcRes.headersArray(), "refresh-token"); await delay(1000); // 1초 간격 지연 const mobileRes = await mobileContext.post("/api/proxy/user-service/login", { data: { userId: adminId, password: adminPassword }, }); const mobileToken = getCookieValue(mobileRes.headersArray(), "refresh-token"); await delay(1000); // 1초 간격 지연 const tabletRes = await tabletContext.post("/api/proxy/user-service/login", { data: { userId: adminId, password: adminPassword }, }); const tabletToken = getCookieValue(tabletRes.headersArray(), "refresh-token"); expect(pcToken).not.toBeNull(); expect(mobileToken).not.toBeNull(); expect(tabletToken).not.toBeNull(); // 다중 세션 환경에서는 디바이스마다 리프레시 토큰이 고유해야 함 expect(pcToken).not.toBe(mobileToken); expect(pcToken).not.toBe(tabletToken); expect(mobileToken).not.toBe(tabletToken); await pcContext.dispose(); await mobileContext.dispose(); await tabletContext.dispose(); }); test("3. Silent Refresh RTR - 새 토큰 교체", async ({ playwright }) => { const pcContext = await createDeviceContext(playwright.request, DEVICE_PC); // 로그인으로 최초 토큰 발급 const loginRes = await pcContext.post("/api/proxy/user-service/login", { data: { userId: adminId, password: adminPassword }, }); const firstToken = getCookieValue(loginRes.headersArray(), "refresh-token"); expect(firstToken).not.toBeNull(); await delay(1000); // 1초 간격 지연 // 갱신 시도 (RTR 동작) const refreshRes = await pcContext.put("/api/proxy/user-service/api/v1/users/token/refresh"); expect(refreshRes.status()).toBe(200); const secondToken = getCookieValue(refreshRes.headersArray(), "refresh-token"); expect(secondToken).not.toBeNull(); // 이전 토큰과 달라져야 함 expect(secondToken).not.toBe(firstToken); await pcContext.dispose(); }); test("4. PC RTR 갱신 후 모바일 세션 독립 유지", async ({ playwright }) => { const pcContext = await createDeviceContext(playwright.request, DEVICE_PC); const mobileContext = await createDeviceContext(playwright.request, DEVICE_MOBILE); await pcContext.post("/api/proxy/user-service/login", { data: { userId: adminId, password: adminPassword }, }); await delay(1000); // 1초 간격 지연 await mobileContext.post("/api/proxy/user-service/login", { data: { userId: adminId, password: adminPassword }, }); await delay(1000); // 1초 간격 지연 // PC 토큰을 RTR 갱신함 const pcRefresh = await pcContext.put("/api/proxy/user-service/api/v1/users/token/refresh"); expect(pcRefresh.status()).toBe(200); // 모바일 세션은 PC와 연동되지 않고 독립적으로 정상 갱신되어야 함 const mobileRefresh = await mobileContext.put("/api/proxy/user-service/api/v1/users/token/refresh"); expect(mobileRefresh.status()).toBe(200); await pcContext.dispose(); await mobileContext.dispose(); }); test("5. Replay Attack - 구 토큰 재주입 → 400", async ({ playwright }) => { const pcContext = await createDeviceContext(playwright.request, DEVICE_PC); // 로그인 후 구 토큰 확보 const loginRes = await pcContext.post("/api/proxy/user-service/login", { data: { userId: adminId, password: adminPassword }, }); const oldToken = getCookieValue(loginRes.headersArray(), "refresh-token"); await delay(1000); // 1초 간격 지연 // 1차 갱신을 수행하여 서버(Redis)의 저장 값을 갱신 const refreshRes1 = await pcContext.put("/api/proxy/user-service/api/v1/users/token/refresh"); expect(refreshRes1.status()).toBe(200); // 구 토큰을 명시적으로 세팅하여 재사용 시도 const replayContext = await createDeviceContext(playwright.request, DEVICE_PC); await replayContext.storageState({ cookies: [ { name: "refresh-token", value: oldToken!, domain: "localhost", path: "/", }, ], }); const replayRes = await replayContext.put("/api/proxy/user-service/api/v1/users/token/refresh"); // 사용 완료된 토큰 재주입은 거부되어야 함 (백엔드는 401 반환) expect(replayRes.status()).toBe(401); await pcContext.dispose(); await replayContext.dispose(); }); test("6. refresh-token 없이 silent refresh → 401", async ({ playwright }) => { const cleanContext = await createDeviceContext(playwright.request, DEVICE_PC); const res = await cleanContext.put("/api/proxy/user-service/api/v1/users/token/refresh"); // 토큰 부재 시 401 반환 expect(res.status()).toBe(401); const body = await res.json(); expect(body.message).toBe("Can't refresh"); await cleanContext.dispose(); }); test("7. 잘못된 비밀번호 → 401", async ({ request }) => { const res = await request.post("/api/proxy/user-service/login", { data: { userId: adminId, password: "WrongPassword999!" }, headers: { "X-Device-Id": DEVICE_PC }, }); expect(res.status()).toBe(401); const body = await res.json(); expect(body.message).toBe("Invalid Credentials"); }); test("8. 존재하지 않는 ID → 401", async ({ request }) => { const res = await request.post("/api/proxy/user-service/login", { data: { userId: "non_existent_user_xyz", password: adminPassword }, headers: { "X-Device-Id": DEVICE_PC }, }); expect(res.status()).toBe(401); const body = await res.json(); expect(body.message).toBe("Invalid Credentials"); }); test("9. 로그아웃 후 쿠키 전체 삭제", async ({ playwright }) => { const pcContext = await createDeviceContext(playwright.request, DEVICE_PC); await pcContext.post("/api/proxy/user-service/login", { data: { userId: adminId, password: adminPassword }, }); // 로그아웃 호출 const logoutRes = await pcContext.post("/api/auth/logout"); expect(logoutRes.status()).toBe(200); // 클라이언트의 인증 쿠키들이 소멸되었는지 검증 const state = await pcContext.storageState(); const refreshCookie = state.cookies.find((c: { name: string }) => c.name === "refresh-token"); const accessCookie = state.cookies.find((c: { name: string }) => c.name === "access-token"); const userCookie = state.cookies.find((c: { name: string }) => c.name === "user-id"); expect(refreshCookie).toBeUndefined(); expect(accessCookie).toBeUndefined(); expect(userCookie).toBeUndefined(); await pcContext.dispose(); }); test("10. PC 로그아웃 후 모바일 세션 독립 유지", async ({ playwright }) => { const pcContext = await createDeviceContext(playwright.request, DEVICE_PC); const mobileContext = await createDeviceContext(playwright.request, DEVICE_MOBILE); await pcContext.post("/api/proxy/user-service/login", { data: { userId: adminId, password: adminPassword }, }); await mobileContext.post("/api/proxy/user-service/login", { data: { userId: adminId, password: adminPassword }, }); // PC 로그아웃 await pcContext.post("/api/auth/logout"); // 모바일은 PC의 로그아웃과 무관하게 독립적으로 유효하게 동작해야 함 const mobileRefresh = await mobileContext.put("/api/proxy/user-service/api/v1/users/token/refresh"); expect(mobileRefresh.status()).toBe(200); await pcContext.dispose(); await mobileContext.dispose(); }); test("11. UI 로그인 후 httpOnly 쿠키 E2E 확인", async ({ page }) => { // UI 로그인 페이지 접속 await page.goto("/auth/login"); // Form 필드 입력 await page.locator("input#id").fill(adminId); await page.locator("input#password").fill(adminPassword); // 디바이스 ID 쿠키 설정 시뮬레이션 await page.context().addCookies([ { name: "device-id", value: DEVICE_PC, url: "http://localhost:3000", }, ]); // 로그인 버튼 클릭 await page.locator(".login_btn").click(); // 성공 시 대시보드 페이지 이동 대기 await page.waitForURL("**/dashboard"); // E2E 보안 요구사항: 클라이언트 브라우저 JavaScript(document.cookie)로는 httpOnly 쿠키를 읽을 수 없어야 함 const clientCookies = await page.evaluate(() => document.cookie); expect(clientCookies).not.toContain("refresh-token"); // 실제 브라우저의 HTTP Context 쿠키에는 저장되어 있어야 함 const contextCookies = await page.context().cookies(); const refreshCookie = contextCookies.find((c: { name: string }) => c.name === "refresh-token"); expect(refreshCookie).toBeDefined(); expect(refreshCookie?.httpOnly).toBe(true); }); test("12. 동일 계정 3개 브라우저 동시 로그인", async ({ playwright }) => { const pcContext = await createDeviceContext(playwright.request, DEVICE_PC); const mobileContext = await createDeviceContext(playwright.request, DEVICE_MOBILE); const tabletContext = await createDeviceContext(playwright.request, DEVICE_TABLET); const pcRes = await pcContext.post("/api/proxy/user-service/login", { data: { userId: adminId, password: adminPassword }, }); expect(pcRes.status()).toBe(200); const mobileRes = await mobileContext.post("/api/proxy/user-service/login", { data: { userId: adminId, password: adminPassword }, }); expect(mobileRes.status()).toBe(200); const tabletRes = await tabletContext.post("/api/proxy/user-service/login", { data: { userId: adminId, password: adminPassword }, }); expect(tabletRes.status()).toBe(200); // 3개 브라우저 세션 모두 정상 리프레시 수행 가능 확인 const pcRef = await pcContext.put("/api/proxy/user-service/api/v1/users/token/refresh"); expect(pcRef.status()).toBe(200); const mobRef = await mobileContext.put("/api/proxy/user-service/api/v1/users/token/refresh"); expect(mobRef.status()).toBe(200); const tabRef = await tabletContext.put("/api/proxy/user-service/api/v1/users/token/refresh"); expect(tabRef.status()).toBe(200); await pcContext.dispose(); await mobileContext.dispose(); await tabletContext.dispose(); }); test("13. sameSite=Strict 쿠키 속성 확인", async ({ request }) => { const response = await request.post("/api/proxy/user-service/login", { data: { userId: adminId, password: adminPassword }, headers: { "X-Device-Id": DEVICE_PC }, }); expect(response.status()).toBe(200); const headers = response.headersArray(); const isStrict = hasCookieAttribute(headers, "refresh-token", "samesite=strict"); expect(isStrict).toBe(true); }); test("14. 연속 3회 RTR 갱신 - 4개 고유 토큰", async ({ playwright }) => { const pcContext = await createDeviceContext(playwright.request, DEVICE_PC); const tokens: string[] = []; // 로그인 (1차 토큰 발급) const loginRes = await pcContext.post("/api/proxy/user-service/login", { data: { userId: adminId, password: adminPassword }, }); const t1 = getCookieValue(loginRes.headersArray(), "refresh-token"); expect(t1).not.toBeNull(); tokens.push(t1!); await delay(1000); // 1차 갱신 (2차 토큰 발급) const r1 = await pcContext.put("/api/proxy/user-service/api/v1/users/token/refresh"); expect(r1.status()).toBe(200); const t2 = getCookieValue(r1.headersArray(), "refresh-token"); expect(t2).not.toBeNull(); tokens.push(t2!); await delay(1000); // 2차 갱신 (3차 토큰 발급) const r2 = await pcContext.put("/api/proxy/user-service/api/v1/users/token/refresh"); expect(r2.status()).toBe(200); const t3 = getCookieValue(r2.headersArray(), "refresh-token"); expect(t3).not.toBeNull(); tokens.push(t3!); await delay(1000); // 3차 갱신 (4차 토큰 발급) const r3 = await pcContext.put("/api/proxy/user-service/api/v1/users/token/refresh"); expect(r3.status()).toBe(200); const t4 = getCookieValue(r3.headersArray(), "refresh-token"); expect(t4).not.toBeNull(); tokens.push(t4!); // 4개 토큰이 모두 고유한지 검증 (Set의 중복 제거 크기로 판별) const uniqueTokens = new Set(tokens); expect(uniqueTokens.size).toBe(4); await pcContext.dispose(); }); });