Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b65c50c5f9 | ||
|
|
eb37728532 | ||
|
|
c41a7bf3d3 | ||
|
|
774f3a5507 | ||
|
|
6e95e372a8 | ||
|
|
1ccd502e3a |
Vendored
+20
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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
@@ -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",
|
||||
},
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
@@ -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]!);
|
||||
})();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Generated
+5171
-7713
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user