Compare commits

...
23 changed files with 6049 additions and 7725 deletions
+20
View File
@@ -23,6 +23,26 @@
"NODE_OPTIONS": "--inspect"
}
},
{
"type": "node-terminal",
"request": "launch",
"name": "dev:as 서버 컴포넌트 디버그 (node-terminal)",
"command": "pnpm --filter as dev",
"cwd": "${workspaceFolder}"
},
{
"type": "node-terminal",
"request": "launch",
"name": "dev:as 통합 디버그 (Server + Client)",
"command": "pnpm --filter as dev",
"cwd": "${workspaceFolder}",
"serverReadyAction": {
"action": "debugWithChrome",
"pattern": "- Local:\\s+(https?://.+)",
"uriFormat": "%s",
"webRoot": "${workspaceFolder}/apps/as"
}
},
{
"type": "node",
"request": "launch",
+13
View File
@@ -0,0 +1,13 @@
import * as Sentry from "@sentry/nextjs";
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT ?? "local",
tracesSampleRate: 1.0,
replaysSessionSampleRate: 1.0,
replaysOnErrorSampleRate: 1.0,
integrations: [Sentry.replayIntegration()],
debug: process.env.NEXT_PUBLIC_SENTRY_DEBUG === "true",
});
+13
View File
@@ -0,0 +1,13 @@
import * as Sentry from "@sentry/nextjs";
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
await import("./sentry.server.config");
}
if (process.env.NEXT_RUNTIME === "edge") {
await import("./sentry.edge.config");
}
}
export const onRequestError = Sentry.captureRequestError;
+17 -1
View File
@@ -1,8 +1,24 @@
import type { NextConfig } from "next";
import { withSentryConfig } from "@sentry/nextjs";
import path from "path";
const nextConfig: NextConfig = {
reactStrictMode: true,
transpilePackages: ["@common/assets"],
outputFileTracingRoot: path.join(__dirname, "../../"),
};
export default nextConfig;
export default withSentryConfig(nextConfig, {
org: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT,
silent: true,
widenClientFileUpload: true,
tunnelRoute: "/monitoring",
webpack: {
treeshake: {
removeDebugLogging: process.env.NEXT_PUBLIC_SENTRY_DEBUG === "true" ? false : true,
},
automaticVercelMonitors: false,
},
});
+7
View File
@@ -20,6 +20,13 @@
"@faker-js/faker": "^9.9.0",
"@frontend-opensource/use-react-hooks": "^1.1.4",
"@hookform/resolvers": "^4.1.3",
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/context-zone": "^2.8.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.219.0",
"@opentelemetry/instrumentation-fetch": "^0.219.0",
"@opentelemetry/instrumentation-xml-http-request": "^0.219.0",
"@opentelemetry/sdk-trace-web": "^2.8.0",
"@sentry/nextjs": "^10.57.0",
"@tanstack/react-query": "^5.75.5",
"@tanstack/react-query-devtools": "^5.75.5",
"@tanstack/react-table": "^8.21.2",
+8
View File
@@ -0,0 +1,8 @@
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT ?? "local",
tracesSampleRate: 1.0,
debug: process.env.NEXT_PUBLIC_SENTRY_DEBUG === "true",
});
+8
View File
@@ -0,0 +1,8 @@
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT ?? "local",
tracesSampleRate: 1.0,
debug: process.env.NEXT_PUBLIC_SENTRY_DEBUG === "true",
});
@@ -865,7 +865,7 @@ function CnfmViewPage({ id }: { id: string }) {
<tr key={f.atflId}>
<td>
{f.orgnlFileNm ? (
<a className="text-blue-600" href={`${PROXY_HOST}/attach-service/api/v1/atflcd/download/${f.atflCd}`} target="_blank" rel="noreferrer">{f.orgnlFileNm}</a>
<a className="text-blue-600" href={`${PROXY_HOST}/attach-service/api/v1/download/${f.atflId}`} target="_blank" rel="noreferrer">{f.orgnlFileNm}</a>
) : (
<span className="text-gray-700">{f.physFileNm || f.atflId}</span>
)}
+15
View File
@@ -20,6 +20,16 @@ async function handler(
"Content-Type": "application/json",
"X-Site-Id": SITE_ID,
};
// X-Device-Id 중계 로직 (헤더 또는 쿠키에서 획득)
const deviceId =
request.headers.get("x-device-id") ||
request.headers.get("X-Device-Id") ||
cookieStore.get("device-id")?.value;
if (deviceId) {
headers["X-Device-Id"] = deviceId;
}
let body = {};
const isRefreshHandler = params.url.includes("refresh");
@@ -45,6 +55,11 @@ async function handler(
});
if (!result.ok) {
console.error(`[BFF Proxy Error] Backend returned status ${result.status} for ${params.url.join("/")}`);
try {
const errorText = await result.text();
console.error(`[BFF Proxy Error Body] ${errorText}`);
} catch (err) {}
return NextResponse.json(
{ message: "Invalid Credentials" },
{ status: 401 },
+13
View File
@@ -0,0 +1,13 @@
import * as Sentry from "@sentry/nextjs";
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT ?? "local",
tracesSampleRate: 1.0,
replaysSessionSampleRate: 1.0,
replaysOnErrorSampleRate: 1.0,
integrations: [Sentry.replayIntegration()],
debug: process.env.NEXT_PUBLIC_SENTRY_DEBUG === "true",
});
+13
View File
@@ -0,0 +1,13 @@
import * as Sentry from "@sentry/nextjs";
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
await import("./sentry.server.config");
}
if (process.env.NEXT_RUNTIME === "edge") {
await import("./sentry.edge.config");
}
}
export const onRequestError = Sentry.captureRequestError;
+14 -1
View File
@@ -1,4 +1,5 @@
import type { NextConfig } from "next";
import { withSentryConfig } from "@sentry/nextjs";
const subDomain =
process.env.NEXT_PUBLIC_SUB_DOMAIN || "http://localhost:13000";
@@ -58,4 +59,16 @@ const nextConfig: NextConfig = {
},
};
export default nextConfig;
export default withSentryConfig(nextConfig, {
org: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT,
silent: true,
widenClientFileUpload: true,
tunnelRoute: "/monitoring",
webpack: {
treeshake: {
removeDebugLogging: process.env.NEXT_PUBLIC_SENTRY_DEBUG === "true" ? false : true,
},
automaticVercelMonitors: false,
},
});
+4 -1
View File
@@ -6,7 +6,8 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
"lint": "next lint",
"test:playwright": "playwright test"
},
"dependencies": {
"@common/assets": "workspace:*",
@@ -26,6 +27,7 @@
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8",
"@sentry/nextjs": "^10.57.0",
"@tanstack/react-query": "^5.75.5",
"@tanstack/react-query-devtools": "^5.75.5",
"@tanstack/react-table": "^8.21.2",
@@ -49,6 +51,7 @@
},
"devDependencies": {
"@eslint/eslintrc": "^3.0.0",
"@playwright/test": "^1.61.1",
"@types/js-cookie": "^3.0.6",
"@types/node": "^22.13.1",
"@types/webpack": "^5.28.5",
File diff suppressed because one or more lines are too long
+60
View File
@@ -0,0 +1,60 @@
import { defineConfig, devices } from "@playwright/test";
/**
* Playwright 설정 파일
* https://playwright.dev/docs/test-configuration
*/
export default defineConfig({
testDir: "./tests",
/* 하나의 테스트 파일 내에서 병렬 실행 금지 및 직렬 실행으로 세션 상태 격리 보장 */
fullyParallel: false,
/* Redis 및 PostgreSQL 공유 자원의 세션 정렬을 위해 워커를 1개로 제한 */
workers: 1,
/* 테스트 실패 시 재시도 횟수 */
retries: 0,
/* 각 테스트 타임아웃 (30초) */
timeout: 30000,
/* 전체 테스트 실행 타임아웃 */
globalTimeout: 600000,
/* Assertion 타임아웃 */
expect: {
timeout: 5000,
},
/* 리포터 설정 (HTML 리포트 및 터미널 요약) */
reporter: [["html", { open: "never" }], ["list"]],
/* 공유 설정 */
use: {
/* 테스트 타겟 URL */
baseURL: process.env.PLAYWRIGHT_BASE_URL || "http://localhost:3000",
/* 실패한 테스트에 대해서만 트레이스 수집 */
trace: "retain-on-failure",
/* 스크린샷 캡처 설정 */
screenshot: "only-on-failure",
},
/* 프로젝트별 브라우저 구성 (기본적으로 Chromium 위주로 동작 확인) */
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit",
use: { ...devices["Desktop Safari"] },
},
],
/* 로컬 서버 자동 기동 설정 */
webServer: {
command: "pnpm dev",
url: "http://localhost:3000",
reuseExistingServer: true,
timeout: 120000,
stdout: "pipe",
stderr: "pipe",
},
});
+8
View File
@@ -0,0 +1,8 @@
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT ?? "local",
tracesSampleRate: 1.0,
debug: process.env.NEXT_PUBLIC_SENTRY_DEBUG === "true",
});
+8
View File
@@ -0,0 +1,8 @@
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT ?? "local",
tracesSampleRate: 1.0,
debug: process.env.NEXT_PUBLIC_SENTRY_DEBUG === "true",
});
+12 -8
View File
@@ -2,9 +2,10 @@
import { useEffect } from "react";
import { usePathname, useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { MenuAuthorityApi } from "@/entities/menu-authority/@x/menu";
import { getCookie, useAlertStore, useMenuStore } from "@/shared/lib";
import { SITE_ID } from "@/shared/config";
import { SITE_ID, ENDPOINT } from "@/shared/config";
import { MenuApi } from "../api/menu-service";
import { getCurrentMenu } from "./utils";
@@ -16,11 +17,10 @@ const MenuWrapper = ({ children }: { children: React.ReactNode }) => {
const { pageMenuAuthority, setCurrentMenuInfo, setPageMenuAuthority } =
useMenuStore((state) => state);
const getCurrentMenuInfo = async () => {
const menuRoles = await MenuApi.getMenuRoles(SITE_ID);
if (!menuRoles) return;
return getCurrentMenu(menuRoles, pathname ?? "");
};
const { data: menuRoles } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.MENU_ROLES],
queryFn: () => MenuApi.getMenuRoles(SITE_ID),
});
const getPageMenuAuthority = async () => {
const menuCd = getCookie("menu-code-key");
@@ -36,10 +36,14 @@ const MenuWrapper = ({ children }: { children: React.ReactNode }) => {
};
useEffect(() => {
(async () => {
const currentMenu = await getCurrentMenuInfo();
if (menuRoles) {
const currentMenu = getCurrentMenu(menuRoles, pathname ?? "");
if (currentMenu) setCurrentMenuInfo(currentMenu);
}
}, [menuRoles, pathname]);
useEffect(() => {
(async () => {
const response = await getPageMenuAuthority();
if (response?.length) setPageMenuAuthority(response[0]!);
})();
+415
View File
@@ -0,0 +1,415 @@
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();
});
});
+84
View File
@@ -0,0 +1,84 @@
pnpm exec playwright test tests/multi-device-auth.spec.ts --project=chromium
cd frontend/apps/cs
pnpm test:playwright
💡 요약 및 권장 가이드 device-id의 유지 처리
로그아웃 시 파기 대상: 인증 토큰 쿠키 (access-token, refresh-token)
로그아웃 시 유지 대상: 기기 식별자 쿠키 및 로컬 스토리지 데이터 (device-id)
따라서 현재 구성하신 대로 쿠키/로컬 스토리지 상의 device-id는 로그아웃 시 지우지 않고 브라우저에 영속적으로 남겨두는 설계가 가장 안전하고 올바른 아키텍처입니다.
[WebServer]
[WebServer] > cs@0.1.0 dev /Users/changwng/workspace.ona/frontend/apps/cs
[WebServer] > next dev
[WebServer]
[WebServer] ⚠ Warning: Next.js inferred your workspace root, but it may not be correct.
[WebServer] We detected multiple lockfiles and selected the directory of /Users/changwng/package-lock.json as the root directory.
[WebServer] To silence this warning, set `outputFileTracingRoot` in your Next.js config, or consider removing one of the lockfiles if it's not needed.
[WebServer] See https://nextjs.org/docs/app/api-reference/config/next-config-js/output#caveats for more information.
[WebServer] Detected additional lockfiles:
[WebServer] * /Users/changwng/workspace.ona/frontend/pnpm-lock.yaml
[WebServer]
[WebServer] ▲ Next.js 15.5.18
[WebServer] - Local: http://localhost:3000
[WebServer] - Network: http://192.168.0.250:3000
[WebServer] - Environments: .env.local
[WebServer] - Experiments (use with caution):
[WebServer] · proxyTimeout: 600000
[WebServer]
[WebServer] ✓ Starting...
[WebServer] ✓ Ready in 1733ms
[WebServer] ✓ Compiled /middleware in 114ms (161 modules)
[WebServer] ○ Compiling /dashboard ...
[WebServer] ✓ Compiled /dashboard in 4.3s (6804 modules)
Running 14 tests using 1 worker
[WebServer] GET /dashboard 200 in 4913ms
[WebServer] ○ Compiling /api/proxy/[...url] ...
[WebServer] ✓ Compiled /api/proxy/[...url] in 10.2s (7070 modules)
[WebServer] [BFF Proxy Error] Backend returned status 401 for user-service/login
[WebServer] [BFF Proxy Error Body] {"status":400,"message":"해당 사용자가 없습니다.","code":"B001"}
[WebServer] POST /api/proxy/user-service/login 401 in 10766ms
✘ 1 [chromium] › tests/multi-device-auth.spec.ts:64:7 › 다중 디바이스 인증 & Refresh Token Rotation (RTR) 테스트 › 1. 로그인 성공 시 httpOnly refresh-token 쿠키 설정 (10.8s)
- 2 [chromium] › tests/multi-device-auth.spec.ts:81:7 › 다중 디바이스 인증 & Refresh Token Rotation (RTR) 테스트 › 2. PC/모바일/태블릿 독립 refresh-token 발급
- 3 [chromium] › tests/multi-device-auth.spec.ts:115:7 › 다중 디바이스 인증 & Refresh Token Rotation (RTR) 테스트 › 3. Silent Refresh RTR - 새 토큰 교체
- 4 [chromium] › tests/multi-device-auth.spec.ts:138:7 › 다중 디바이스 인증 & Refresh Token Rotation (RTR) 테스트 › 4. PC RTR 갱신 후 모바일 세션 독립 유지
- 5 [chromium] › tests/multi-device-auth.spec.ts:161:7 › 다중 디바이스 인증 & Refresh Token Rotation (RTR) 테스트 › 5. Replay Attack - 구 토큰 재주입 → 400
- 6 [chromium] › tests/multi-device-auth.spec.ts:195:7 › 다중 디바이스 인증 & Refresh Token Rotation (RTR) 테스트 › 6. refresh-token 없이 silent refresh → 401
- 7 [chromium] › tests/multi-device-auth.spec.ts:207:7 › 다중 디바이스 인증 & Refresh Token Rotation (RTR) 테스트 › 7. 잘못된 비밀번호 → 401
- 8 [chromium] › tests/multi-device-auth.spec.ts:217:7 › 다중 디바이스 인증 & Refresh Token Rotation (RTR) 테스트 › 8. 존재하지 않는 ID → 401
- 9 [chromium] › tests/multi-device-auth.spec.ts:227:7 › 다중 디바이스 인증 & Refresh Token Rotation (RTR) 테스트 › 9. 로그아웃 후 쿠키 전체 삭제
- 10 [chromium] › tests/multi-device-auth.spec.ts:250:7 › 다중 디바이스 인증 & Refresh Token Rotation (RTR) 테스트 › 10. PC 로그아웃 후 모바일 세션 독립 유지
- 11 [chromium] › tests/multi-device-auth.spec.ts:272:7 › 다중 디바이스 인증 & Refresh Token Rotation (RTR) 테스트 › 11. UI 로그인 후 httpOnly 쿠키 E2E 확인
- 12 [chromium] › tests/multi-device-auth.spec.ts:306:7 › 다중 디바이스 인증 & Refresh Token Rotation (RTR) 테스트 › 12. 동일 계정 3개 브라우저 동시 로그인
- 13 [chromium] › tests/multi-device-auth.spec.ts:341:7 › 다중 디바이스 인증 & Refresh Token Rotation (RTR) 테스트 › 13. sameSite=Strict 쿠키 속성 확인
- 14 [chromium] › tests/multi-device-auth.spec.ts:353:7 › 다중 디바이스 인증 & Refresh Token Rotation (RTR) 테스트 › 14. 연속 3회 RTR 갱신 - 4개 고유 토큰
1) [chromium] › tests/multi-device-auth.spec.ts:64:7 › 다중 디바이스 인증 & Refresh Token Rotation (RTR) 테스트 › 1. 로그인 성공 시 httpOnly refresh-token 쿠키 설정
Error: expect(received).toBe(expected) // Object.is equality
Expected: 200
Received: 401
67 | headers: { "X-Device-Id": DEVICE_PC },
68 | });
> 69 | expect(response.status()).toBe(200);
| ^
70 |
71 | const headers = response.headersArray();
72 | const refreshToken = getCookieValue(headers, "refresh-token");
at /Users/changwng/workspace.ona/frontend/apps/cs/tests/multi-device-auth.spec.ts:69:31
Error Context: test-results/multi-device-auth-다중-디바이스--4f452-ttpOnly-refresh-token-쿠키-설정-chromium/error-context.md
attachment #2: trace (application/zip) ─────────────────────────────────────────────────────────
test-results/multi-device-auth-다중-디바이스--4f452-ttpOnly-refresh-token-쿠키-설정-chromium/trace.zip
Usage:
pnpm exec playwright show-trace test-results/multi-device-auth-다중-디바이스--4f452-ttpOnly-refresh-token-쿠키-설정-chromium/trace.zip
+27
View File
@@ -0,0 +1,27 @@
# apps/st/src/shared/config/env.ts 참고
NEXT_PUBLIC_PORT=3000
NEXT_PUBLIC_PROXY_HOST=http://localhost:3003
NEXT_PUBLIC_SITE_ID=AS
NEXT_PUBLIC_SUB_DOMAIN=http://localhost:3003
NEXT_PUBLIC_SERVER_API_URL=http://localhost:8000
SERVER_API_URL=http://localhost:8000
NEXT_PUBLIC_TINY_API_KEY=t9nc4z6inquvvd5pq1vea8wcp7lb01qmqseh6egms9vpaju2
NEXT_PUBLIC_ST_SERVICE_URL=https://apigateway.apps.rsonesoft.com
NEXT_PUBLIC_ST_REALGRID_KEY=upVcPE+wPOmtLjqyBIh9RkM/nBOseBrflwxYpzGZyYm9cY8amGDkiHqyYT2U1Yh3Dufv8SUhNy63kJZj9DH/cmSe3PbBoyaMZTR5mNBsNuyY63NNzIfzqxzwYuagP76LkxValpjT3ck=
# 401 시 브라우저 리다이렉트 대상
NEXT_PUBLIC_AUTH_LOGIN_REDIRECT_URL=/auth/login
# Sentry Configuration
NEXT_PUBLIC_SENTRY_DSN=http://f43f0aeff1cae8ea3f50a57f766ae37a@localhost:9001/2
NEXT_PUBLIC_SENTRY_ENVIRONMENT=local
SENTRY_ORG=sentry
SENTRY_PROJECT=as-frontend
SENTRY_AUTH_TOKEN=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
# OpenTelemetry Tracing Configuration
OTEL_SERVICE_NAME="as-frontend"
OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4320/v1/traces"
NEXT_PUBLIC_SENTRY_DEBUG=true
+28
View File
@@ -24,6 +24,21 @@ class Client {
});
this.axiosInstance.interceptors.request.use((config) => {
// X-Device-Id 자동 주입 로직 (기기에 고유한 UUID 세팅 및 쿠키/로컬스토리지 보관)
let deviceId = Cookies.get("device-id");
if (!deviceId && typeof window !== "undefined") {
deviceId = localStorage.getItem("device-id") || undefined;
if (!deviceId) {
deviceId = generateUUID();
localStorage.setItem("device-id", deviceId);
}
Cookies.set("device-id", deviceId, { expires: 365, path: "/" });
}
if (deviceId) {
config.headers["X-Device-Id"] = deviceId;
}
const token = Cookies.get(CLAIM_NAME);
const userId = Cookies.get(AUTH_USER_ID);
@@ -87,3 +102,16 @@ class Client {
export type { ClientConfig };
export { Client };
// 기기 식별을 위한 안전한 v4 UUID 생성 폴백 헬퍼 함수
function generateUUID(): string {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
const r = (Math.random() * 16) | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
+5171 -7713
View File
File diff suppressed because it is too large Load Diff