Files
frontend/apps/cs/tests/multi-device-auth.spec.ts
T

416 lines
16 KiB
TypeScript

import { test, expect } from "@playwright/test";
// 테스트 순차 실행 보장 (전 단계 상태가 영향을 줄 수 있으므로 serial 모드로 설정)
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 parts = header.value.split(";")[0].split("=");
if (parts[0].trim() === name) {
return parts[1].trim();
}
}
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 cookieName = parts[0].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) => c.name === "refresh-token");
const accessCookie = state.cookies.find((c) => c.name === "access-token");
const userCookie = state.cookies.find((c) => 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) => 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();
});
});