423 lines
16 KiB
TypeScript
423 lines
16 KiB
TypeScript
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();
|
|
});
|
|
});
|