From 0d1f774f2153451c5a98fcb5462096bd5373982c Mon Sep 17 00:00:00 2001 From: Yonghun Park Date: Fri, 20 Feb 2026 17:13:38 +0900 Subject: [PATCH 1/2] feat(auth): integrate lockout, setup-code onboarding, and scoped cookie auth --- .env.example | 2 + .github/workflows/deploy-backend-aws.yml | 29 ++++ .github/workflows/deploy-web-vercel.yml | 47 ++++++ apps/admin-web/src/app/admin/login/page.tsx | 26 ++- .../[projectId]/settings/members/page.tsx | 142 ++++++++++++++-- .../src/app/admin/tenants/[tenantId]/page.tsx | 5 +- apps/admin-web/src/app/admin/tenants/page.tsx | 7 +- .../src/app/admin/users/[userId]/page.tsx | 67 ++++++++ apps/admin-web/src/lib/api.ts | 91 ++++------ apps/admin-web/src/lib/auth.ts | 58 +------ apps/admin-web/src/proxy.ts | 7 +- .../src/app/first-password/page.tsx | 158 ++++++++++++++++++ apps/client-web/src/app/login/page.tsx | 40 ++++- .../src/components/ui/NotificationCenter.tsx | 16 +- apps/client-web/src/lib/api.ts | 104 ++++++------ apps/client-web/src/lib/auth.ts | 58 +------ apps/client-web/src/proxy.ts | 7 +- apps/pm-web/src/app/login/page.tsx | 26 ++- .../contracts/[contractId]/page.tsx | 5 +- .../projects/[projectId]/contracts/page.tsx | 19 ++- .../[projectId]/settings/members/page.tsx | 139 +++++++++++++-- .../src/components/ui/NotificationCenter.tsx | 16 +- apps/pm-web/src/lib/api.ts | 91 ++++------ apps/pm-web/src/lib/auth.ts | 58 +------ apps/pm-web/src/proxy.ts | 5 +- .../common/security/AuthCookieService.java | 142 ++++++++++++++++ .../security/JwtAuthenticationFilter.java | 35 +--- .../bridge/backend/config/SecurityConfig.java | 5 +- .../backend/config/SecurityProperties.java | 1 + .../backend/domain/admin/AdminController.java | 6 + .../backend/domain/admin/AdminService.java | 22 ++- .../backend/domain/auth/AuthController.java | 65 +++++-- .../backend/domain/auth/AuthService.java | 109 ++++++++++-- .../backend/domain/auth/UserEntity.java | 12 ++ .../domain/project/ProjectController.java | 8 +- .../domain/project/ProjectService.java | 113 +++++++++++-- .../src/main/resources/application.properties | 2 + .../db/migration/V11__upload_tickets.sql | 19 +++ .../migration/V12__auth_hardening_fields.sql | 11 ++ .../domain/admin/AdminServiceTest.java | 54 ++++++ .../backend/domain/auth/AuthServiceTest.java | 86 ++++++++++ docs/Plan/PLAN.md | 24 +-- docs/Plan/PLAN_PROGRESS.md | 13 +- docs/Plan/PROJECT.md | 34 ++-- docs/Test/PLAYWRIGHT_MCP_E2E.md | 6 +- scripts/run-dod-demo.ps1 | 152 +++++++++-------- 46 files changed, 1513 insertions(+), 629 deletions(-) create mode 100644 apps/client-web/src/app/first-password/page.tsx create mode 100644 backend/src/main/java/com/bridge/backend/common/security/AuthCookieService.java create mode 100644 backend/src/main/resources/db/migration/V11__upload_tickets.sql create mode 100644 backend/src/main/resources/db/migration/V12__auth_hardening_fields.sql create mode 100644 backend/src/test/java/com/bridge/backend/domain/admin/AdminServiceTest.java create mode 100644 backend/src/test/java/com/bridge/backend/domain/auth/AuthServiceTest.java diff --git a/.env.example b/.env.example index b257807..207897b 100644 --- a/.env.example +++ b/.env.example @@ -4,11 +4,13 @@ JAVA_TOOL_OPTIONS=-Xms256m -Xmx1536m -XX:+UseG1GC -XX:MaxGCPauseMillis=200 DB_URL=jdbc:postgresql://localhost:5432/bridge DB_USERNAME=bridge DB_PASSWORD=bridge +MAX_HTTP_REQUEST_HEADER_SIZE=64KB JWT_SECRET=change-this-dev-secret-change-this-dev-secret STORAGE_PRESIGN_SECRET=change-this-storage-presign-secret VAULT_MASTER_KEY=0123456789abcdef0123456789abcdef ALLOWED_ORIGINS=http://localhost:3000,http://localhost:3001,http://localhost:3002 +AUTH_COOKIE_DOMAIN= MINIO_ENDPOINT=http://localhost:9000 MINIO_BUCKET=bridge diff --git a/.github/workflows/deploy-backend-aws.yml b/.github/workflows/deploy-backend-aws.yml index 3a91bac..3451042 100644 --- a/.github/workflows/deploy-backend-aws.yml +++ b/.github/workflows/deploy-backend-aws.yml @@ -14,7 +14,36 @@ concurrency: cancel-in-progress: false jobs: + quality: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + + - name: Lint (placeholder) + run: echo "backend: no lint step configured" + + - name: Test backend + shell: bash + run: | + chmod +x backend/gradlew + cd backend + ./gradlew test + + - name: Build backend + shell: bash + run: | + cd backend + ./gradlew build -x test + deploy: + needs: quality runs-on: ubuntu-latest permissions: id-token: write diff --git a/.github/workflows/deploy-web-vercel.yml b/.github/workflows/deploy-web-vercel.yml index eb89c31..7052185 100644 --- a/.github/workflows/deploy-web-vercel.yml +++ b/.github/workflows/deploy-web-vercel.yml @@ -28,8 +28,55 @@ concurrency: cancel-in-progress: false jobs: + quality: + name: Quality Gates + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Lint web apps + run: | + pnpm --filter pm-web lint + pnpm --filter client-web lint + pnpm --filter admin-web lint + + - name: Test backend + shell: bash + run: | + chmod +x backend/gradlew + cd backend + ./gradlew test + + - name: Build web apps + run: | + pnpm --filter pm-web build + pnpm --filter client-web build + pnpm --filter admin-web build + deploy: name: Deploy ${{ matrix.app.name }} + needs: quality runs-on: ubuntu-latest strategy: fail-fast: false diff --git a/apps/admin-web/src/app/admin/login/page.tsx b/apps/admin-web/src/app/admin/login/page.tsx index 2e438a1..fd9a50a 100644 --- a/apps/admin-web/src/app/admin/login/page.tsx +++ b/apps/admin-web/src/app/admin/login/page.tsx @@ -1,9 +1,9 @@ -"use client"; +"use client"; import { FormEvent, Suspense, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { API_BASE } from "@/lib/api"; -import { sanitizeNextPath, setAuthCookies } from "@/lib/auth"; +import { sanitizeNextPath } from "@/lib/auth"; type TenantOption = { tenantId: string; @@ -30,6 +30,14 @@ function AdminLoginForm() { const [error, setError] = useState(null); const [submitting, setSubmitting] = useState(false); + function resolveLoginErrorMessage(payload: unknown): string { + const code = (payload as { error?: { code?: string } })?.error?.code; + if (code === "LOGIN_BLOCKED") { + return "로그인 시도 횟수를 초과해 계정이 잠겼습니다. 로그인 잠금 해제를 진행하세요."; + } + return "로그인에 실패했습니다."; + } + async function onSubmit(event: FormEvent) { event.preventDefault(); setSubmitting(true); @@ -38,7 +46,11 @@ function AdminLoginForm() { try { const response = await fetch(`${API_BASE}/api/auth/login`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + "X-Bridge-App": "admin", + }, + credentials: "include", body: JSON.stringify({ email, password, @@ -47,7 +59,7 @@ function AdminLoginForm() { }); const json = await response.json(); if (!response.ok) { - throw new Error(json?.error?.message ?? "로그인에 실패했습니다."); + throw new Error(resolveLoginErrorMessage(json)); } const data = json?.data; @@ -61,11 +73,10 @@ function AdminLoginForm() { return; } - if (!data?.accessToken || !data?.refreshToken) { + if (!data || !data.userId) { throw new Error("로그인 응답이 올바르지 않습니다."); } - setAuthCookies(data.accessToken, data.refreshToken); router.replace(sanitizeNextPath(params.get("next"), "/admin/tenants")); } catch (e) { setError(e instanceof Error ? e.message : "요청 처리 중 오류가 발생했습니다."); @@ -184,3 +195,6 @@ function LoginPageFallback() { return
; } + + + diff --git a/apps/admin-web/src/app/admin/projects/[projectId]/settings/members/page.tsx b/apps/admin-web/src/app/admin/projects/[projectId]/settings/members/page.tsx index 389522b..b97cfff 100644 --- a/apps/admin-web/src/app/admin/projects/[projectId]/settings/members/page.tsx +++ b/apps/admin-web/src/app/admin/projects/[projectId]/settings/members/page.tsx @@ -1,4 +1,4 @@ -"use client"; +"use client"; import { FormEvent, useEffect, useState } from "react"; import { apiFetch, handleAuthError } from "@/lib/api"; @@ -14,6 +14,9 @@ type ProjectMember = { role: MemberRole; loginId: string; passwordMask: string; + passwordInitialized: boolean; + setupCode?: string | null; + setupCodeExpiresAt?: string | null; }; type AccountDraft = { @@ -37,11 +40,11 @@ export default function ProjectMemberSettingsPage() { const [createOpen, setCreateOpen] = useState(false); const [loginId, setLoginId] = useState(""); - const [initialPassword, setInitialPassword] = useState(""); const [displayName, setDisplayName] = useState(""); const [inviteRole, setInviteRole] = useState("CLIENT_MEMBER"); const [createNotice, setCreateNotice] = useState(null); + const [setupCodeInfo, setSetupCodeInfo] = useState<{ loginId: string; setupCode: string; expiresAt?: string | null } | null>(null); const [error, setError] = useState(null); const load = async () => { @@ -84,14 +87,24 @@ export default function ProjectMemberSettingsPage() { body: JSON.stringify({ role: inviteRole, loginId, - password: initialPassword, name: displayName, }), }); - setCreateNotice(`계정이 생성되었습니다. 로그인 ID: ${created.loginId}`); + if (created.setupCode) { + setCreateNotice(`계정이 생성되었습니다. 클라이언트 앱의 /first-password 에서 최초 비밀번호를 설정하세요. 로그인 ID: ${created.loginId}`); + setSetupCodeInfo({ + loginId: created.loginId, + setupCode: created.setupCode, + expiresAt: created.setupCodeExpiresAt, + }); + } else if (created.passwordInitialized) { + setCreateNotice(`이미 비밀번호가 설정된 기존 계정입니다. 클라이언트 로그인 페이지에서 기존 비밀번호로 로그인하세요. 로그인 ID: ${created.loginId}`); + } else { + setCreateNotice(`계정이 생성되었습니다. 설정 코드를 확인하지 못했습니다. 멤버 행의 '설정코드 재발급'으로 코드를 발급한 뒤 /first-password 에서 설정하세요. 로그인 ID: ${created.loginId}`); + setSetupCodeInfo(null); + } setCreateOpen(false); setLoginId(""); - setInitialPassword(""); setDisplayName(""); setInviteRole("CLIENT_MEMBER"); await load(); @@ -102,6 +115,29 @@ export default function ProjectMemberSettingsPage() { } } + async function resetSetupCode(memberId: string, memberLoginId: string) { + setError(null); + try { + const reset = await apiFetch(`/api/projects/${projectId}/members/${memberId}/setup-code/reset`, { + method: "POST", + }); + if (!reset.setupCode) { + throw new Error("설정 코드 발급에 실패했습니다."); + } + setSetupCodeInfo({ + loginId: memberLoginId, + setupCode: reset.setupCode, + expiresAt: reset.setupCodeExpiresAt, + }); + setCreateNotice(`설정 코드를 재발급했습니다. 클라이언트 앱 /first-password 에서 사용하세요. 로그인 ID: ${memberLoginId}`); + await load(); + } catch (e) { + if (!handleAuthError(e, "/admin/login")) { + setError(e instanceof Error ? e.message : "설정 코드 재발급에 실패했습니다."); + } + } + } + async function saveRole(memberId: string) { const nextRole = roleDrafts[memberId]; if (!nextRole) return; @@ -160,6 +196,54 @@ export default function ProjectMemberSettingsPage() { } } + const formatExpiry = (value?: string | null) => { + if (!value) { + return "-"; + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return "-"; + } + return new Intl.DateTimeFormat("ko-KR", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }).format(date); + }; + + const copySetupCode = async () => { + if (!setupCodeInfo?.setupCode || typeof navigator === "undefined" || !navigator.clipboard) { + return; + } + try { + await navigator.clipboard.writeText(setupCodeInfo.setupCode); + setCreateNotice("설정 코드를 클립보드에 복사했습니다."); + } catch { + setCreateNotice("클립보드 복사에 실패했습니다."); + } + }; + + const copySetupGuide = async () => { + if (!setupCodeInfo?.setupCode || typeof navigator === "undefined" || !navigator.clipboard) { + return; + } + const guide = [ + "[Bridge 최초 비밀번호 설정 안내]", + `로그인 ID: ${setupCodeInfo.loginId}`, + `설정 코드: ${setupCodeInfo.setupCode}`, + `만료시각: ${formatExpiry(setupCodeInfo.expiresAt)}`, + "접속 경로: 클라이언트 앱 /first-password", + ].join("\n"); + try { + await navigator.clipboard.writeText(guide); + setCreateNotice("설정 안내문을 클립보드에 복사했습니다."); + } catch { + setCreateNotice("설정 안내문 복사에 실패했습니다."); + } + }; + return (
@@ -177,6 +261,33 @@ export default function ProjectMemberSettingsPage() {
{createNotice ?

{createNotice}

: null} + {setupCodeInfo ? ( +
+

최초 비밀번호 설정 코드

+

로그인 ID: {setupCodeInfo.loginId}

+

설정 코드: {setupCodeInfo.setupCode}

+

만료시각: {formatExpiry(setupCodeInfo.expiresAt)}

+

+ 사용 방법: 클라이언트 앱의 /first-password 페이지에서 로그인 ID, 설정 코드, 새 비밀번호를 입력합니다. +

+
+ + +
+
+ ) : null}
@@ -207,6 +318,7 @@ export default function ProjectMemberSettingsPage() { })) } /> +

{member.passwordInitialized ? "비밀번호 설정 완료" : "최초 비밀번호 설정 필요"}

역할 저장 + {!member.passwordInitialized ? ( + + ) : null} setCreateOpen(false)} title="계정 생성" - description="로그인 ID/비밀번호를 포함해 계정을 생성하고 프로젝트에 추가합니다." + description="로그인 ID를 생성하고 최초 비밀번호 설정 코드를 발급합니다." >
setLoginId(e.target.value)} required /> - setInitialPassword(e.target.value)} - required - /> ); } + diff --git a/apps/admin-web/src/app/admin/tenants/[tenantId]/page.tsx b/apps/admin-web/src/app/admin/tenants/[tenantId]/page.tsx index 59c6b33..394f424 100644 --- a/apps/admin-web/src/app/admin/tenants/[tenantId]/page.tsx +++ b/apps/admin-web/src/app/admin/tenants/[tenantId]/page.tsx @@ -236,6 +236,9 @@ export default function TenantDetailPage() {
+
+

테넌트 사용자

+
@@ -280,7 +283,7 @@ export default function TenantDetailPage() { {!loading && pmUsers.length === 0 ? ( ) : null} diff --git a/apps/admin-web/src/app/admin/tenants/page.tsx b/apps/admin-web/src/app/admin/tenants/page.tsx index ed528e0..745b355 100644 --- a/apps/admin-web/src/app/admin/tenants/page.tsx +++ b/apps/admin-web/src/app/admin/tenants/page.tsx @@ -7,7 +7,6 @@ import { StatusBadge } from "@/components/ui/StatusBadge"; import { Modal } from "@/components/ui/modal"; import { apiFetch, handleAuthError } from "@/lib/api"; import { Skeleton } from "@/components/ui/skeleton"; -import { setAuthCookies } from "@/lib/auth"; type Tenant = { id: string; @@ -84,14 +83,10 @@ export default function TenantsPage() { setSwitchingTenantId(tenant.id); setError(null); try { - const switched = await apiFetch<{ accessToken: string; refreshToken: string }>("/api/auth/switch-tenant", { + await apiFetch("/api/auth/switch-tenant", { method: "POST", body: JSON.stringify({ tenantId: tenant.id }), }); - if (!switched?.accessToken || !switched?.refreshToken) { - throw new Error("테넌트 전환 응답이 올바르지 않습니다."); - } - setAuthCookies(switched.accessToken, switched.refreshToken); router.push(`/admin/tenants/${tenant.id}`); } catch (e) { if (!handleAuthError(e, "/admin/login")) { diff --git a/apps/admin-web/src/app/admin/users/[userId]/page.tsx b/apps/admin-web/src/app/admin/users/[userId]/page.tsx index e8cf865..68c1bba 100644 --- a/apps/admin-web/src/app/admin/users/[userId]/page.tsx +++ b/apps/admin-web/src/app/admin/users/[userId]/page.tsx @@ -15,6 +15,9 @@ type UserDetail = { name: string; status: UserStatus; isPlatformAdmin: boolean; + failedLoginAttempts: number; + loginBlocked: boolean; + passwordInitialized: boolean; lastLoginAt?: string | null; memberships: Array<{ tenantId: string; @@ -52,8 +55,10 @@ export default function UserDetailPage() { const [status, setStatus] = useState("ACTIVE"); const [loading, setLoading] = useState(true); const [updating, setUpdating] = useState(false); + const [unlocking, setUnlocking] = useState(false); const [result, setResult] = useState(null); const [error, setError] = useState(null); + const canUnlockLogin = Boolean(detail?.loginBlocked) && !loading && !unlocking; async function load() { setLoading(true); @@ -96,6 +101,28 @@ export default function UserDetailPage() { } } + async function unlockLogin() { + if (!detail?.loginBlocked || loading || unlocking) { + return; + } + setUnlocking(true); + setError(null); + setResult(null); + try { + await apiFetch<{ userId: string; loginBlocked: boolean; failedLoginAttempts: number }>(`/api/admin/users/${userId}/unlock-login`, { + method: "POST", + }); + await load(); + setResult("로그인 잠금을 해제했습니다."); + } catch (e) { + if (!handleAuthError(e, "/admin/login")) { + setError(e instanceof Error ? e.message : "잠금 해제에 실패했습니다."); + } + } finally { + setUnlocking(false); + } + } + if (loading && !detail) { return ( @@ -149,6 +176,15 @@ export default function UserDetailPage() {

최근 로그인: {formatDateTime(detail?.lastLoginAt)}

+

+ 실패 횟수: {detail?.failedLoginAttempts ?? 0} +

+

+ 로그인 잠금: {detail?.loginBlocked ? "예" : "아니오"} +

+

+ 비밀번호 초기화: {detail?.passwordInitialized ? "완료" : "미완료"} +

@@ -171,6 +207,37 @@ export default function UserDetailPage() { +
+
+

로그인 잠금 해제

+ + {detail?.loginBlocked ? "해제 가능" : "비활성"} + +
+

+ {detail?.loginBlocked + ? "로그인 잠금 상태입니다. 버튼을 눌러 즉시 해제할 수 있습니다." + : "현재 로그인 잠금 상태가 아니어서 이 기능은 비활성화되었습니다."} +

+ +
+
- 등록된 PM 사용자가 없습니다. + 등록된 테넌트 사용자가 없습니다.
diff --git a/apps/admin-web/src/lib/api.ts b/apps/admin-web/src/lib/api.ts index 0a91361..0dc00c0 100644 --- a/apps/admin-web/src/lib/api.ts +++ b/apps/admin-web/src/lib/api.ts @@ -1,10 +1,11 @@ -import { clearAuthCookies, getAccessToken, getRefreshToken, redirectToLogin, setAuthCookies } from "./auth"; +import { redirectToLogin } from "./auth"; function normalizeApiBase(base: string): string { return base.replace(/\/+$/, ""); } export const API_BASE = normalizeApiBase(process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:8080"); +const APP_HEADER_VALUE = "admin"; const REQUEST_TIMEOUT_MS = 15000; const REFRESH_TIMEOUT_MS = 10000; @@ -27,7 +28,7 @@ export class ApiAuthError extends Error { } } -let refreshPromise: Promise | null = null; +let refreshPromise: Promise | null = null; function normalizePath(path: string): string { return path.startsWith("/") ? path : `/${path}`; @@ -74,73 +75,47 @@ function buildErrorMessage(response: Response, payload: ApiEnvelope | unde return payload?.error?.message ?? (textFallback || fallback); } -async function rawFetch(path: string, init?: RequestInit, token?: string | null) { +async function rawFetch(path: string, init?: RequestInit) { const headers = new Headers(init?.headers ?? {}); - - if (token) { - headers.set("Authorization", `Bearer ${token}`); + if (!headers.has("X-Bridge-App")) { + headers.set("X-Bridge-App", APP_HEADER_VALUE); } - if (!headers.has("Content-Type") && init?.body && !(init.body instanceof FormData)) { headers.set("Content-Type", "application/json"); } const { signal, cleanup } = withTimeout(init?.signal, REQUEST_TIMEOUT_MS); - try { return await fetch(`${API_BASE}${normalizePath(path)}`, { ...init, headers, signal, cache: "no-store", + credentials: "include", }); } finally { cleanup(); } } -async function refreshAccessToken(): Promise { +async function refreshAccessToken(): Promise { if (refreshPromise) { return refreshPromise; } refreshPromise = (async () => { - const refreshToken = getRefreshToken(); - if (!refreshToken) { - return null; - } - const { signal, cleanup } = withTimeout(undefined, REFRESH_TIMEOUT_MS); - try { const response = await fetch(`${API_BASE}/api/auth/refresh`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ refreshToken }), + headers: { "X-Bridge-App": APP_HEADER_VALUE }, signal, cache: "no-store", + credentials: "include", }); - - if (!response.ok) { - clearAuthCookies(); - return null; - } - - const raw = await response.text(); - const envelope = parseEnvelope<{ accessToken: string; refreshToken: string }>(raw); - const nextAccess = envelope?.data?.accessToken; - const nextRefresh = envelope?.data?.refreshToken; - - if (!nextAccess || !nextRefresh) { - clearAuthCookies(); - return null; - } - - setAuthCookies(nextAccess, nextRefresh); - return nextAccess; + return response.ok; } catch { - clearAuthCookies(); - return null; + return false; } finally { cleanup(); } @@ -152,13 +127,12 @@ async function refreshAccessToken(): Promise { } export async function apiFetch(path: string, init?: RequestInit): Promise { - let accessToken = getAccessToken(); - let response = await rawFetch(path, init, accessToken); + let response = await rawFetch(path, init); if (response.status === 401) { - accessToken = await refreshAccessToken(); - if (accessToken) { - response = await rawFetch(path, init, accessToken); + const refreshed = await refreshAccessToken(); + if (refreshed) { + response = await rawFetch(path, init); } } @@ -168,7 +142,6 @@ export async function apiFetch(path: string, init?: RequestInit): Promise if (!response.ok) { const message = buildErrorMessage(response, payload, raw); if (response.status === 401) { - clearAuthCookies(); throw new ApiAuthError(message); } throw new Error(message); @@ -186,13 +159,12 @@ export async function apiFetch(path: string, init?: RequestInit): Promise } export async function apiFetchResponse(path: string, init?: RequestInit): Promise { - let accessToken = getAccessToken(); - let response = await rawFetch(path, init, accessToken); + let response = await rawFetch(path, init); if (response.status === 401) { - accessToken = await refreshAccessToken(); - if (accessToken) { - response = await rawFetch(path, init, accessToken); + const refreshed = await refreshAccessToken(); + if (refreshed) { + response = await rawFetch(path, init); } } @@ -201,7 +173,6 @@ export async function apiFetchResponse(path: string, init?: RequestInit): Promis const payload = parseEnvelope(raw); const message = buildErrorMessage(response, payload, raw); if (response.status === 401) { - clearAuthCookies(); throw new ApiAuthError(message); } throw new Error(message); @@ -219,27 +190,21 @@ export function handleAuthError(error: unknown, loginPath: string) { } export async function logout(loginPath: string) { - const refreshToken = getRefreshToken(); const { signal, cleanup } = withTimeout(undefined, LOGOUT_TIMEOUT_MS); - try { - if (refreshToken) { - await fetch(`${API_BASE}/api/auth/logout`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ refreshToken }), - signal, - cache: "no-store", - }); - } + await fetch(`${API_BASE}/api/auth/logout`, { + method: "POST", + headers: { "X-Bridge-App": APP_HEADER_VALUE }, + signal, + cache: "no-store", + credentials: "include", + }); } catch { - // ignore logout request failures and clear local session anyway + // ignore logout request failures and redirect anyway } finally { cleanup(); - clearAuthCookies(); if (typeof window !== "undefined") { window.location.href = loginPath; } } } - diff --git a/apps/admin-web/src/lib/auth.ts b/apps/admin-web/src/lib/auth.ts index 44aca0d..9374aa0 100644 --- a/apps/admin-web/src/lib/auth.ts +++ b/apps/admin-web/src/lib/auth.ts @@ -1,63 +1,7 @@ export const ACCESS_COOKIE = "bridge_admin_access_token"; -export const REFRESH_COOKIE = "bridge_admin_refresh_token"; function isBrowser(): boolean { - return typeof window !== "undefined" && typeof document !== "undefined"; -} - -function secureAttr(): string { - if (!isBrowser()) { - return ""; - } - return window.location.protocol === "https:" ? "; Secure" : ""; -} - -export function setAuthCookies(accessToken: string, refreshToken: string) { - if (!isBrowser()) { - return; - } - const secure = secureAttr(); - document.cookie = `${ACCESS_COOKIE}=${encodeURIComponent(accessToken)}; Path=/; Max-Age=900; SameSite=Lax${secure}`; - document.cookie = `${REFRESH_COOKIE}=${encodeURIComponent(refreshToken)}; Path=/; Max-Age=2592000; SameSite=Lax${secure}`; -} - -export function clearAuthCookies() { - if (!isBrowser()) { - return; - } - const secure = secureAttr(); - document.cookie = `${ACCESS_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax${secure}`; - document.cookie = `${REFRESH_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax${secure}`; -} - -export function getCookieValue(name: string): string | null { - if (!isBrowser()) { - return null; - } - - const raw = - document.cookie - .split(";") - .map((value) => value.trim()) - .find((value) => value.startsWith(`${name}=`)) - ?.slice(name.length + 1) ?? null; - if (!raw) { - return null; - } - - try { - return decodeURIComponent(raw); - } catch { - return null; - } -} - -export function getAccessToken(): string | null { - return getCookieValue(ACCESS_COOKIE); -} - -export function getRefreshToken(): string | null { - return getCookieValue(REFRESH_COOKIE); + return typeof window !== "undefined"; } export function redirectToLogin(loginPath: string) { diff --git a/apps/admin-web/src/proxy.ts b/apps/admin-web/src/proxy.ts index 65751bd..73e4965 100644 --- a/apps/admin-web/src/proxy.ts +++ b/apps/admin-web/src/proxy.ts @@ -1,11 +1,11 @@ -import { NextResponse } from "next/server"; +import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; -import { sanitizeNextPath } from "./lib/auth"; +import { ACCESS_COOKIE, sanitizeNextPath } from "./lib/auth"; export function proxy(request: NextRequest) { const path = request.nextUrl.pathname; const isPublic = path === "/admin/login" || path.startsWith("/_next") || path.startsWith("/favicon"); - const token = request.cookies.get("bridge_admin_access_token")?.value; + const token = request.cookies.get(ACCESS_COOKIE)?.value; if (!isPublic && !token) { const url = request.nextUrl.clone(); @@ -26,3 +26,4 @@ export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"], }; + diff --git a/apps/client-web/src/app/first-password/page.tsx b/apps/client-web/src/app/first-password/page.tsx new file mode 100644 index 0000000..bf0e5e5 --- /dev/null +++ b/apps/client-web/src/app/first-password/page.tsx @@ -0,0 +1,158 @@ +"use client"; + +import { FormEvent, Suspense, useEffect, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { API_BASE } from "@/lib/api"; + +export default function FirstPasswordPage() { + return ( + }> + + + ); +} + +function FirstPasswordForm() { + const router = useRouter(); + const params = useSearchParams(); + const [email, setEmail] = useState(""); + const [setupCode, setSetupCode] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + const queryEmail = params.get("email"); + if (queryEmail) { + setEmail(queryEmail); + } + }, [params]); + + async function onSubmit(event: FormEvent) { + event.preventDefault(); + setSubmitting(true); + setError(null); + setSuccess(null); + + try { + if (newPassword !== confirmPassword) { + throw new Error("비밀번호 확인이 일치하지 않습니다."); + } + + const response = await fetch(`${API_BASE}/api/auth/first-password`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Bridge-App": "client", + }, + credentials: "include", + body: JSON.stringify({ + email, + setupCode, + newPassword, + }), + }); + const json = await response.json(); + if (!response.ok) { + throw new Error(json?.error?.message ?? "최초 비밀번호 설정에 실패했습니다."); + } + + setSuccess("비밀번호 설정이 완료되었습니다. 로그인 페이지로 이동합니다."); + window.setTimeout(() => { + router.replace("/login"); + }, 1000); + } catch (e) { + setError(e instanceof Error ? e.message : "요청 처리 중 오류가 발생했습니다."); + } finally { + setSubmitting(false); + } + } + + return ( +
+
+
+

최초 비밀번호 설정

+

PM에게 받은 설정 코드로 비밀번호를 설정하세요.

+ + +
+ + setEmail(e.target.value)} + required + /> +
+ +
+ + setSetupCode(e.target.value)} + placeholder="8자리 숫자" + required + /> +
+ +
+ + setNewPassword(e.target.value)} + required + /> +

10~72자, 대문자/소문자/숫자를 포함해야 합니다.

+
+ +
+ + setConfirmPassword(e.target.value)} + required + /> +
+ + {error ?

{error}

: null} + {success ?

{success}

: null} + + + +
+
+ ); +} diff --git a/apps/client-web/src/app/login/page.tsx b/apps/client-web/src/app/login/page.tsx index abddc8b..60a1fb0 100644 --- a/apps/client-web/src/app/login/page.tsx +++ b/apps/client-web/src/app/login/page.tsx @@ -1,9 +1,10 @@ -"use client"; +"use client"; +import Link from "next/link"; import { FormEvent, Suspense, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { API_BASE } from "@/lib/api"; -import { sanitizeNextPath, setAuthCookies } from "@/lib/auth"; +import { sanitizeNextPath } from "@/lib/auth"; type TenantOption = { tenantId: string; @@ -30,6 +31,14 @@ function LoginForm() { const [error, setError] = useState(null); const [submitting, setSubmitting] = useState(false); + function resolveLoginErrorMessage(payload: unknown): string { + const code = (payload as { error?: { code?: string } })?.error?.code; + if (code === "LOGIN_BLOCKED") { + return "로그인 시도 횟수를 초과해 계정이 잠겼습니다. 관리자에게 잠금 해제를 요청하세요."; + } + return "로그인에 실패했습니다."; + } + async function onSubmit(event: FormEvent) { event.preventDefault(); setSubmitting(true); @@ -38,7 +47,11 @@ function LoginForm() { try { const response = await fetch(`${API_BASE}/api/auth/login`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + "X-Bridge-App": "client", + }, + credentials: "include", body: JSON.stringify({ email, password, @@ -47,7 +60,12 @@ function LoginForm() { }); const json = await response.json(); if (!response.ok) { - throw new Error(json?.error?.message ?? "로그인에 실패했습니다."); + if (json?.error?.code === "PASSWORD_SETUP_REQUIRED") { + const encodedEmail = encodeURIComponent(email.trim().toLowerCase()); + router.replace(`/first-password?email=${encodedEmail}`); + return; + } + throw new Error(resolveLoginErrorMessage(json)); } const data = json?.data; @@ -61,11 +79,10 @@ function LoginForm() { return; } - if (!data?.accessToken || !data?.refreshToken) { + if (!data || !data.userId) { throw new Error("로그인 응답이 올바르지 않습니다."); } - setAuthCookies(data.accessToken, data.refreshToken); router.replace(sanitizeNextPath(params.get("next"), "/client/projects")); } catch (e) { setError(e instanceof Error ? e.message : "요청 처리 중 오류가 발생했습니다."); @@ -131,6 +148,14 @@ function LoginForm() { disabled={Boolean(tenantOptions)} required /> +
+ + 설정 코드로 최초 비밀번호 설정 + +
{tenantOptions ? (
@@ -184,3 +209,6 @@ function LoginPageFallback() { return
; } + + + diff --git a/apps/client-web/src/components/ui/NotificationCenter.tsx b/apps/client-web/src/components/ui/NotificationCenter.tsx index 46c396c..9dba9ba 100644 --- a/apps/client-web/src/components/ui/NotificationCenter.tsx +++ b/apps/client-web/src/components/ui/NotificationCenter.tsx @@ -1,9 +1,8 @@ -"use client"; +"use client"; import { Bell } from "lucide-react"; import { useEffect, useId, useMemo, useRef, useState } from "react"; import { AnimatePresence, motion } from "motion/react"; -import { getAccessToken } from "@/lib/auth"; import { API_BASE, apiFetch, handleAuthError } from "@/lib/api"; type Notice = { @@ -44,15 +43,9 @@ export function NotificationCenter() { }; const buildStreamRequest = () => { - const token = getAccessToken(); - const apiOrigin = new URL(API_BASE, window.location.origin).origin; - const sameOriginApi = apiOrigin === window.location.origin; - const useCookieAuth = sameOriginApi || !token; return { - url: useCookieAuth - ? `${API_BASE}/api/notifications/stream` - : `${API_BASE}/api/notifications/stream?accessToken=${encodeURIComponent(token)}`, - withCredentials: useCookieAuth, + url: `${API_BASE}/api/notifications/stream?app=client`, + withCredentials: true, }; }; @@ -234,3 +227,6 @@ export function NotificationCenter() {
); } + + + diff --git a/apps/client-web/src/lib/api.ts b/apps/client-web/src/lib/api.ts index 3ae0314..d4e6f24 100644 --- a/apps/client-web/src/lib/api.ts +++ b/apps/client-web/src/lib/api.ts @@ -1,10 +1,11 @@ -import { clearAuthCookies, getAccessToken, getRefreshToken, redirectToLogin, setAuthCookies } from "./auth"; +import { redirectToLogin } from "./auth"; function normalizeApiBase(base: string): string { return base.replace(/\/+$/, ""); } export const API_BASE = normalizeApiBase(process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:8080"); +const APP_HEADER_VALUE = "client"; const REQUEST_TIMEOUT_MS = 15000; const REFRESH_TIMEOUT_MS = 10000; @@ -27,7 +28,7 @@ export class ApiAuthError extends Error { } } -let refreshPromise: Promise | null = null; +let refreshPromise: Promise | null = null; function normalizePath(path: string): string { return path.startsWith("/") ? path : `/${path}`; @@ -74,73 +75,47 @@ function buildErrorMessage(response: Response, payload: ApiEnvelope | unde return payload?.error?.message ?? (textFallback || fallback); } -async function rawFetch(path: string, init?: RequestInit, token?: string | null) { +async function rawFetch(path: string, init?: RequestInit) { const headers = new Headers(init?.headers ?? {}); - - if (token) { - headers.set("Authorization", `Bearer ${token}`); + if (!headers.has("X-Bridge-App")) { + headers.set("X-Bridge-App", APP_HEADER_VALUE); } - if (!headers.has("Content-Type") && init?.body && !(init.body instanceof FormData)) { headers.set("Content-Type", "application/json"); } const { signal, cleanup } = withTimeout(init?.signal, REQUEST_TIMEOUT_MS); - try { return await fetch(`${API_BASE}${normalizePath(path)}`, { ...init, headers, signal, cache: "no-store", + credentials: "include", }); } finally { cleanup(); } } -async function refreshAccessToken(): Promise { +async function refreshAccessToken(): Promise { if (refreshPromise) { return refreshPromise; } refreshPromise = (async () => { - const refreshToken = getRefreshToken(); - if (!refreshToken) { - return null; - } - const { signal, cleanup } = withTimeout(undefined, REFRESH_TIMEOUT_MS); - try { const response = await fetch(`${API_BASE}/api/auth/refresh`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ refreshToken }), + headers: { "X-Bridge-App": APP_HEADER_VALUE }, signal, cache: "no-store", + credentials: "include", }); - - if (!response.ok) { - clearAuthCookies(); - return null; - } - - const raw = await response.text(); - const envelope = parseEnvelope<{ accessToken: string; refreshToken: string }>(raw); - const nextAccess = envelope?.data?.accessToken; - const nextRefresh = envelope?.data?.refreshToken; - - if (!nextAccess || !nextRefresh) { - clearAuthCookies(); - return null; - } - - setAuthCookies(nextAccess, nextRefresh); - return nextAccess; + return response.ok; } catch { - clearAuthCookies(); - return null; + return false; } finally { cleanup(); } @@ -152,13 +127,12 @@ async function refreshAccessToken(): Promise { } export async function apiFetch(path: string, init?: RequestInit): Promise { - let accessToken = getAccessToken(); - let response = await rawFetch(path, init, accessToken); + let response = await rawFetch(path, init); if (response.status === 401) { - accessToken = await refreshAccessToken(); - if (accessToken) { - response = await rawFetch(path, init, accessToken); + const refreshed = await refreshAccessToken(); + if (refreshed) { + response = await rawFetch(path, init); } } @@ -168,7 +142,6 @@ export async function apiFetch(path: string, init?: RequestInit): Promise if (!response.ok) { const message = buildErrorMessage(response, payload, raw); if (response.status === 401) { - clearAuthCookies(); throw new ApiAuthError(message); } throw new Error(message); @@ -185,6 +158,29 @@ export async function apiFetch(path: string, init?: RequestInit): Promise throw new Error("Unexpected API response format."); } +export async function apiFetchResponse(path: string, init?: RequestInit): Promise { + let response = await rawFetch(path, init); + + if (response.status === 401) { + const refreshed = await refreshAccessToken(); + if (refreshed) { + response = await rawFetch(path, init); + } + } + + if (!response.ok) { + const raw = await response.clone().text(); + const payload = parseEnvelope(raw); + const message = buildErrorMessage(response, payload, raw); + if (response.status === 401) { + throw new ApiAuthError(message); + } + throw new Error(message); + } + + return response; +} + export function handleAuthError(error: unknown, loginPath: string) { if (error instanceof ApiAuthError) { redirectToLogin(loginPath); @@ -194,27 +190,21 @@ export function handleAuthError(error: unknown, loginPath: string) { } export async function logout(loginPath: string) { - const refreshToken = getRefreshToken(); const { signal, cleanup } = withTimeout(undefined, LOGOUT_TIMEOUT_MS); - try { - if (refreshToken) { - await fetch(`${API_BASE}/api/auth/logout`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ refreshToken }), - signal, - cache: "no-store", - }); - } + await fetch(`${API_BASE}/api/auth/logout`, { + method: "POST", + headers: { "X-Bridge-App": APP_HEADER_VALUE }, + signal, + cache: "no-store", + credentials: "include", + }); } catch { - // ignore logout request failures and clear local session anyway + // ignore logout request failures and redirect anyway } finally { cleanup(); - clearAuthCookies(); if (typeof window !== "undefined") { window.location.href = loginPath; } } } - diff --git a/apps/client-web/src/lib/auth.ts b/apps/client-web/src/lib/auth.ts index f0ff997..50a7405 100644 --- a/apps/client-web/src/lib/auth.ts +++ b/apps/client-web/src/lib/auth.ts @@ -1,63 +1,7 @@ export const ACCESS_COOKIE = "bridge_client_access_token"; -export const REFRESH_COOKIE = "bridge_client_refresh_token"; function isBrowser(): boolean { - return typeof window !== "undefined" && typeof document !== "undefined"; -} - -function secureAttr(): string { - if (!isBrowser()) { - return ""; - } - return window.location.protocol === "https:" ? "; Secure" : ""; -} - -export function setAuthCookies(accessToken: string, refreshToken: string) { - if (!isBrowser()) { - return; - } - const secure = secureAttr(); - document.cookie = `${ACCESS_COOKIE}=${encodeURIComponent(accessToken)}; Path=/; Max-Age=900; SameSite=Lax${secure}`; - document.cookie = `${REFRESH_COOKIE}=${encodeURIComponent(refreshToken)}; Path=/; Max-Age=2592000; SameSite=Lax${secure}`; -} - -export function clearAuthCookies() { - if (!isBrowser()) { - return; - } - const secure = secureAttr(); - document.cookie = `${ACCESS_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax${secure}`; - document.cookie = `${REFRESH_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax${secure}`; -} - -export function getCookieValue(name: string): string | null { - if (!isBrowser()) { - return null; - } - - const raw = - document.cookie - .split(";") - .map((value) => value.trim()) - .find((value) => value.startsWith(`${name}=`)) - ?.slice(name.length + 1) ?? null; - if (!raw) { - return null; - } - - try { - return decodeURIComponent(raw); - } catch { - return null; - } -} - -export function getAccessToken(): string | null { - return getCookieValue(ACCESS_COOKIE); -} - -export function getRefreshToken(): string | null { - return getCookieValue(REFRESH_COOKIE); + return typeof window !== "undefined"; } export function redirectToLogin(loginPath: string) { diff --git a/apps/client-web/src/proxy.ts b/apps/client-web/src/proxy.ts index a1423e7..cfbe986 100644 --- a/apps/client-web/src/proxy.ts +++ b/apps/client-web/src/proxy.ts @@ -1,14 +1,14 @@ import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; -import { sanitizeNextPath } from "./lib/auth"; +import { ACCESS_COOKIE, sanitizeNextPath } from "./lib/auth"; export function proxy(request: NextRequest) { const path = request.nextUrl.pathname; // Allow entering signing pages directly so users can see guidance even without a session. // Actual signing APIs still enforce authentication/authorization. const isPublic = - path === "/login" || path.startsWith("/_next") || path.startsWith("/favicon") || path === "/sign" || path.startsWith("/sign/"); - const token = request.cookies.get("bridge_client_access_token")?.value; + path === "/login" || path === "/first-password" || path.startsWith("/_next") || path.startsWith("/favicon") || path === "/sign" || path.startsWith("/sign/"); + const token = request.cookies.get(ACCESS_COOKIE)?.value; if (!isPublic && !token) { const url = request.nextUrl.clone(); @@ -29,3 +29,4 @@ export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"], }; + diff --git a/apps/pm-web/src/app/login/page.tsx b/apps/pm-web/src/app/login/page.tsx index c0a21dc..a4f540a 100644 --- a/apps/pm-web/src/app/login/page.tsx +++ b/apps/pm-web/src/app/login/page.tsx @@ -1,8 +1,8 @@ -"use client"; +"use client"; import { FormEvent, Suspense, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; -import { sanitizeNextPath, setAuthCookies } from "../../lib/auth"; +import { sanitizeNextPath } from "../../lib/auth"; import { API_BASE } from "../../lib/api"; type TenantOption = { @@ -30,6 +30,14 @@ function LoginForm() { const [error, setError] = useState(null); const [submitting, setSubmitting] = useState(false); + function resolveLoginErrorMessage(payload: unknown): string { + const code = (payload as { error?: { code?: string } })?.error?.code; + if (code === "LOGIN_BLOCKED") { + return "로그인 시도 횟수를 초과해 계정이 잠겼습니다. 관리자에게 잠금 해제를 요청하세요."; + } + return "로그인에 실패했습니다."; + } + async function onSubmit(event: FormEvent) { event.preventDefault(); setSubmitting(true); @@ -38,7 +46,11 @@ function LoginForm() { try { const response = await fetch(`${API_BASE}/api/auth/login`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + "X-Bridge-App": "pm", + }, + credentials: "include", body: JSON.stringify({ email, password, @@ -47,7 +59,7 @@ function LoginForm() { }); const json = await response.json(); if (!response.ok) { - throw new Error(json?.error?.message ?? "로그인에 실패했습니다."); + throw new Error(resolveLoginErrorMessage(json)); } const data = json?.data; @@ -61,11 +73,10 @@ function LoginForm() { return; } - if (!data?.accessToken || !data?.refreshToken) { + if (!data || !data.userId) { throw new Error("로그인 응답이 올바르지 않습니다."); } - setAuthCookies(data.accessToken, data.refreshToken); const next = sanitizeNextPath(params.get("next"), "/pm/projects"); router.replace(next); } catch (e) { @@ -185,3 +196,6 @@ function LoginPageFallback() { return
; } + + + diff --git a/apps/pm-web/src/app/pm/projects/[projectId]/contracts/[contractId]/page.tsx b/apps/pm-web/src/app/pm/projects/[projectId]/contracts/[contractId]/page.tsx index 212f44d..eaeb342 100644 --- a/apps/pm-web/src/app/pm/projects/[projectId]/contracts/[contractId]/page.tsx +++ b/apps/pm-web/src/app/pm/projects/[projectId]/contracts/[contractId]/page.tsx @@ -1,4 +1,4 @@ -"use client"; +"use client"; import { useEffect, useMemo, useState } from "react"; import { useParams, useRouter } from "next/navigation"; @@ -114,7 +114,7 @@ export default function PmContractSigningStatusPage() { setPdfUrl(downloadUrl); } catch (e) { if (!active) return; - if (!handleAuthError(e, "/pm/login")) { + if (!handleAuthError(e, "/login")) { setError(e instanceof Error ? e.message : "서명 상태를 불러오지 못했습니다."); } } finally { @@ -219,3 +219,4 @@ export default function PmContractSigningStatusPage() { ); } + diff --git a/apps/pm-web/src/app/pm/projects/[projectId]/contracts/page.tsx b/apps/pm-web/src/app/pm/projects/[projectId]/contracts/page.tsx index 44cb663..f06ce18 100644 --- a/apps/pm-web/src/app/pm/projects/[projectId]/contracts/page.tsx +++ b/apps/pm-web/src/app/pm/projects/[projectId]/contracts/page.tsx @@ -1,4 +1,4 @@ -"use client"; +"use client"; import { ChangeEvent, FormEvent, MouseEvent, useEffect, useMemo, useRef, useState } from "react"; import { useRouter } from "next/navigation"; @@ -460,7 +460,7 @@ export default function ProjectContractsPage() { ); setSignersByContract(Object.fromEntries(signerEntries)); } catch (e) { - if (!handleAuthError(e, "/pm/login")) { + if (!handleAuthError(e, "/login")) { setError(e instanceof Error ? e.message : "계약 목록을 불러오지 못했습니다."); } } finally { @@ -489,7 +489,7 @@ export default function ProjectContractsPage() { } catch (e) { const renderCancelled = e instanceof Error && e.name === "RenderingCancelledException"; if (!cancelled && !renderCancelled) { - if (!handleAuthError(e, "/pm/login")) { + if (!handleAuthError(e, "/login")) { setPreviewError(e instanceof Error ? e.message : "PDF 미리보기를 불러오지 못했습니다."); } } @@ -583,7 +583,7 @@ export default function ProjectContractsPage() { setCreatePdf(null); await load(); } catch (e) { - if (!handleAuthError(e, "/pm/login")) { + if (!handleAuthError(e, "/login")) { setError(e instanceof Error ? e.message : "계약 생성에 실패했습니다."); } } @@ -639,7 +639,7 @@ export default function ProjectContractsPage() { setEditingId(null); await load(); } catch (e) { - if (!handleAuthError(e, "/pm/login")) { + if (!handleAuthError(e, "/login")) { setError(e instanceof Error ? e.message : "계약 수정에 실패했습니다."); } } @@ -654,7 +654,7 @@ export default function ProjectContractsPage() { } await load(); } catch (e) { - if (!handleAuthError(e, "/pm/login")) { + if (!handleAuthError(e, "/login")) { setError(e instanceof Error ? e.message : "계약 삭제에 실패했습니다."); } } @@ -666,7 +666,7 @@ export default function ProjectContractsPage() { const result = await apiFetch<{ downloadUrl: string }>(`/api/file-versions/${fileVersionId}/download-url`); window.open(result.downloadUrl, "_blank", "noopener,noreferrer"); } catch (e) { - if (!handleAuthError(e, "/pm/login")) { + if (!handleAuthError(e, "/login")) { setError(e instanceof Error ? e.message : "계약서 열기에 실패했습니다."); } } @@ -696,7 +696,7 @@ export default function ProjectContractsPage() { setSigningTargetUserId(assignedUserId || clientMembers[0]?.userId || ""); applyFieldDefaults(signer); } catch (e) { - if (!handleAuthError(e, "/pm/login")) { + if (!handleAuthError(e, "/login")) { setError(e instanceof Error ? e.message : "서명자 정보를 불러오지 못했습니다."); } } finally { @@ -740,7 +740,7 @@ export default function ProjectContractsPage() { const refreshed = await loadSigner(signingContractId); applyFieldDefaults(refreshed); } catch (e) { - if (!handleAuthError(e, "/pm/login")) { + if (!handleAuthError(e, "/login")) { setError(e instanceof Error ? e.message : "서명자 지정에 실패했습니다."); } } finally { @@ -1159,3 +1159,4 @@ function resolveDisplayStatus(contract: Contract, signer?: SignerInfo): Contract } + diff --git a/apps/pm-web/src/app/pm/projects/[projectId]/settings/members/page.tsx b/apps/pm-web/src/app/pm/projects/[projectId]/settings/members/page.tsx index 82e697a..54d048d 100644 --- a/apps/pm-web/src/app/pm/projects/[projectId]/settings/members/page.tsx +++ b/apps/pm-web/src/app/pm/projects/[projectId]/settings/members/page.tsx @@ -14,6 +14,9 @@ type ProjectMember = { role: MemberRole; loginId: string; passwordMask: string; + passwordInitialized: boolean; + setupCode?: string | null; + setupCodeExpiresAt?: string | null; }; type AccountDraft = { @@ -37,11 +40,11 @@ export default function ProjectMemberSettingsPage() { const [createOpen, setCreateOpen] = useState(false); const [loginId, setLoginId] = useState(""); - const [initialPassword, setInitialPassword] = useState(""); const [displayName, setDisplayName] = useState(""); const [inviteRole, setInviteRole] = useState("CLIENT_MEMBER"); const [createNotice, setCreateNotice] = useState(null); + const [setupCodeInfo, setSetupCodeInfo] = useState<{ loginId: string; setupCode: string; expiresAt?: string | null } | null>(null); const [error, setError] = useState(null); const load = async () => { @@ -84,14 +87,24 @@ export default function ProjectMemberSettingsPage() { body: JSON.stringify({ role: inviteRole, loginId, - password: initialPassword, name: displayName, }), }); - setCreateNotice(`계정이 생성되었습니다. 로그인 ID: ${created.loginId}`); + if (created.setupCode) { + setCreateNotice(`계정이 생성되었습니다. 클라이언트 앱의 /first-password 에서 최초 비밀번호를 설정하세요. 로그인 ID: ${created.loginId}`); + setSetupCodeInfo({ + loginId: created.loginId, + setupCode: created.setupCode, + expiresAt: created.setupCodeExpiresAt, + }); + } else if (created.passwordInitialized) { + setCreateNotice(`이미 비밀번호가 설정된 기존 계정입니다. 클라이언트 로그인 페이지에서 기존 비밀번호로 로그인하세요. 로그인 ID: ${created.loginId}`); + } else { + setCreateNotice(`계정이 생성되었습니다. 설정 코드를 확인하지 못했습니다. 멤버 행의 '설정코드 재발급'으로 코드를 발급한 뒤 /first-password 에서 설정하세요. 로그인 ID: ${created.loginId}`); + setSetupCodeInfo(null); + } setCreateOpen(false); setLoginId(""); - setInitialPassword(""); setDisplayName(""); setInviteRole("CLIENT_MEMBER"); await load(); @@ -102,6 +115,29 @@ export default function ProjectMemberSettingsPage() { } } + async function resetSetupCode(memberId: string, memberLoginId: string) { + setError(null); + try { + const reset = await apiFetch(`/api/projects/${projectId}/members/${memberId}/setup-code/reset`, { + method: "POST", + }); + if (!reset.setupCode) { + throw new Error("설정 코드 발급에 실패했습니다."); + } + setSetupCodeInfo({ + loginId: memberLoginId, + setupCode: reset.setupCode, + expiresAt: reset.setupCodeExpiresAt, + }); + setCreateNotice(`설정 코드를 재발급했습니다. 클라이언트 앱 /first-password 에서 사용하세요. 로그인 ID: ${memberLoginId}`); + await load(); + } catch (e) { + if (!handleAuthError(e, "/login")) { + setError(e instanceof Error ? e.message : "설정 코드 재발급에 실패했습니다."); + } + } + } + async function saveRole(memberId: string) { const nextRole = roleDrafts[memberId]; if (!nextRole) return; @@ -160,6 +196,54 @@ export default function ProjectMemberSettingsPage() { } } + const formatExpiry = (value?: string | null) => { + if (!value) { + return "-"; + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return "-"; + } + return new Intl.DateTimeFormat("ko-KR", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }).format(date); + }; + + const copySetupCode = async () => { + if (!setupCodeInfo?.setupCode || typeof navigator === "undefined" || !navigator.clipboard) { + return; + } + try { + await navigator.clipboard.writeText(setupCodeInfo.setupCode); + setCreateNotice("설정 코드를 클립보드에 복사했습니다."); + } catch { + setCreateNotice("클립보드 복사에 실패했습니다."); + } + }; + + const copySetupGuide = async () => { + if (!setupCodeInfo?.setupCode || typeof navigator === "undefined" || !navigator.clipboard) { + return; + } + const guide = [ + "[Bridge 최초 비밀번호 설정 안내]", + `로그인 ID: ${setupCodeInfo.loginId}`, + `설정 코드: ${setupCodeInfo.setupCode}`, + `만료시각: ${formatExpiry(setupCodeInfo.expiresAt)}`, + "접속 경로: 클라이언트 앱 /first-password", + ].join("\n"); + try { + await navigator.clipboard.writeText(guide); + setCreateNotice("설정 안내문을 클립보드에 복사했습니다."); + } catch { + setCreateNotice("설정 안내문 복사에 실패했습니다."); + } + }; + return (
@@ -177,6 +261,33 @@ export default function ProjectMemberSettingsPage() {
{createNotice ?

{createNotice}

: null} + {setupCodeInfo ? ( +
+

최초 비밀번호 설정 코드

+

로그인 ID: {setupCodeInfo.loginId}

+

설정 코드: {setupCodeInfo.setupCode}

+

만료시각: {formatExpiry(setupCodeInfo.expiresAt)}

+

+ 사용 방법: 클라이언트 앱의 /first-password 페이지에서 로그인 ID, 설정 코드, 새 비밀번호를 입력합니다. +

+
+ + +
+
+ ) : null}
@@ -207,6 +318,7 @@ export default function ProjectMemberSettingsPage() { })) } /> +

{member.passwordInitialized ? "비밀번호 설정 완료" : "최초 비밀번호 설정 필요"}

역할 저장 + {!member.passwordInitialized ? ( + + ) : null} setCreateOpen(false)} title="계정 생성" - description="로그인 ID/비밀번호를 포함해 계정을 생성하고 프로젝트에 추가합니다." + description="로그인 ID를 생성하고 최초 비밀번호 설정 코드를 발급합니다." >
setLoginId(e.target.value)} required /> - setInitialPassword(e.target.value)} - required - /> { - const token = getAccessToken(); - const apiOrigin = new URL(API_BASE, window.location.origin).origin; - const sameOriginApi = apiOrigin === window.location.origin; - const useCookieAuth = sameOriginApi || !token; return { - url: useCookieAuth - ? `${API_BASE}/api/notifications/stream` - : `${API_BASE}/api/notifications/stream?accessToken=${encodeURIComponent(token)}`, - withCredentials: useCookieAuth, + url: `${API_BASE}/api/notifications/stream?app=pm`, + withCredentials: true, }; }; @@ -234,3 +227,6 @@ export function NotificationCenter() { ); } + + + diff --git a/apps/pm-web/src/lib/api.ts b/apps/pm-web/src/lib/api.ts index 0a91361..862a653 100644 --- a/apps/pm-web/src/lib/api.ts +++ b/apps/pm-web/src/lib/api.ts @@ -1,10 +1,11 @@ -import { clearAuthCookies, getAccessToken, getRefreshToken, redirectToLogin, setAuthCookies } from "./auth"; +import { redirectToLogin } from "./auth"; function normalizeApiBase(base: string): string { return base.replace(/\/+$/, ""); } export const API_BASE = normalizeApiBase(process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:8080"); +const APP_HEADER_VALUE = "pm"; const REQUEST_TIMEOUT_MS = 15000; const REFRESH_TIMEOUT_MS = 10000; @@ -27,7 +28,7 @@ export class ApiAuthError extends Error { } } -let refreshPromise: Promise | null = null; +let refreshPromise: Promise | null = null; function normalizePath(path: string): string { return path.startsWith("/") ? path : `/${path}`; @@ -74,73 +75,47 @@ function buildErrorMessage(response: Response, payload: ApiEnvelope | unde return payload?.error?.message ?? (textFallback || fallback); } -async function rawFetch(path: string, init?: RequestInit, token?: string | null) { +async function rawFetch(path: string, init?: RequestInit) { const headers = new Headers(init?.headers ?? {}); - - if (token) { - headers.set("Authorization", `Bearer ${token}`); + if (!headers.has("X-Bridge-App")) { + headers.set("X-Bridge-App", APP_HEADER_VALUE); } - if (!headers.has("Content-Type") && init?.body && !(init.body instanceof FormData)) { headers.set("Content-Type", "application/json"); } const { signal, cleanup } = withTimeout(init?.signal, REQUEST_TIMEOUT_MS); - try { return await fetch(`${API_BASE}${normalizePath(path)}`, { ...init, headers, signal, cache: "no-store", + credentials: "include", }); } finally { cleanup(); } } -async function refreshAccessToken(): Promise { +async function refreshAccessToken(): Promise { if (refreshPromise) { return refreshPromise; } refreshPromise = (async () => { - const refreshToken = getRefreshToken(); - if (!refreshToken) { - return null; - } - const { signal, cleanup } = withTimeout(undefined, REFRESH_TIMEOUT_MS); - try { const response = await fetch(`${API_BASE}/api/auth/refresh`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ refreshToken }), + headers: { "X-Bridge-App": APP_HEADER_VALUE }, signal, cache: "no-store", + credentials: "include", }); - - if (!response.ok) { - clearAuthCookies(); - return null; - } - - const raw = await response.text(); - const envelope = parseEnvelope<{ accessToken: string; refreshToken: string }>(raw); - const nextAccess = envelope?.data?.accessToken; - const nextRefresh = envelope?.data?.refreshToken; - - if (!nextAccess || !nextRefresh) { - clearAuthCookies(); - return null; - } - - setAuthCookies(nextAccess, nextRefresh); - return nextAccess; + return response.ok; } catch { - clearAuthCookies(); - return null; + return false; } finally { cleanup(); } @@ -152,13 +127,12 @@ async function refreshAccessToken(): Promise { } export async function apiFetch(path: string, init?: RequestInit): Promise { - let accessToken = getAccessToken(); - let response = await rawFetch(path, init, accessToken); + let response = await rawFetch(path, init); if (response.status === 401) { - accessToken = await refreshAccessToken(); - if (accessToken) { - response = await rawFetch(path, init, accessToken); + const refreshed = await refreshAccessToken(); + if (refreshed) { + response = await rawFetch(path, init); } } @@ -168,7 +142,6 @@ export async function apiFetch(path: string, init?: RequestInit): Promise if (!response.ok) { const message = buildErrorMessage(response, payload, raw); if (response.status === 401) { - clearAuthCookies(); throw new ApiAuthError(message); } throw new Error(message); @@ -186,13 +159,12 @@ export async function apiFetch(path: string, init?: RequestInit): Promise } export async function apiFetchResponse(path: string, init?: RequestInit): Promise { - let accessToken = getAccessToken(); - let response = await rawFetch(path, init, accessToken); + let response = await rawFetch(path, init); if (response.status === 401) { - accessToken = await refreshAccessToken(); - if (accessToken) { - response = await rawFetch(path, init, accessToken); + const refreshed = await refreshAccessToken(); + if (refreshed) { + response = await rawFetch(path, init); } } @@ -201,7 +173,6 @@ export async function apiFetchResponse(path: string, init?: RequestInit): Promis const payload = parseEnvelope(raw); const message = buildErrorMessage(response, payload, raw); if (response.status === 401) { - clearAuthCookies(); throw new ApiAuthError(message); } throw new Error(message); @@ -219,27 +190,21 @@ export function handleAuthError(error: unknown, loginPath: string) { } export async function logout(loginPath: string) { - const refreshToken = getRefreshToken(); const { signal, cleanup } = withTimeout(undefined, LOGOUT_TIMEOUT_MS); - try { - if (refreshToken) { - await fetch(`${API_BASE}/api/auth/logout`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ refreshToken }), - signal, - cache: "no-store", - }); - } + await fetch(`${API_BASE}/api/auth/logout`, { + method: "POST", + headers: { "X-Bridge-App": APP_HEADER_VALUE }, + signal, + cache: "no-store", + credentials: "include", + }); } catch { - // ignore logout request failures and clear local session anyway + // ignore logout request failures and redirect anyway } finally { cleanup(); - clearAuthCookies(); if (typeof window !== "undefined") { window.location.href = loginPath; } } } - diff --git a/apps/pm-web/src/lib/auth.ts b/apps/pm-web/src/lib/auth.ts index 383b986..d476d97 100644 --- a/apps/pm-web/src/lib/auth.ts +++ b/apps/pm-web/src/lib/auth.ts @@ -1,63 +1,7 @@ export const ACCESS_COOKIE = "bridge_pm_access_token"; -export const REFRESH_COOKIE = "bridge_pm_refresh_token"; function isBrowser(): boolean { - return typeof window !== "undefined" && typeof document !== "undefined"; -} - -function secureAttr(): string { - if (!isBrowser()) { - return ""; - } - return window.location.protocol === "https:" ? "; Secure" : ""; -} - -export function setAuthCookies(accessToken: string, refreshToken: string) { - if (!isBrowser()) { - return; - } - const secure = secureAttr(); - document.cookie = `${ACCESS_COOKIE}=${encodeURIComponent(accessToken)}; Path=/; Max-Age=900; SameSite=Lax${secure}`; - document.cookie = `${REFRESH_COOKIE}=${encodeURIComponent(refreshToken)}; Path=/; Max-Age=2592000; SameSite=Lax${secure}`; -} - -export function clearAuthCookies() { - if (!isBrowser()) { - return; - } - const secure = secureAttr(); - document.cookie = `${ACCESS_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax${secure}`; - document.cookie = `${REFRESH_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax${secure}`; -} - -export function getCookieValue(name: string): string | null { - if (!isBrowser()) { - return null; - } - - const raw = - document.cookie - .split(";") - .map((value) => value.trim()) - .find((value) => value.startsWith(`${name}=`)) - ?.slice(name.length + 1) ?? null; - if (!raw) { - return null; - } - - try { - return decodeURIComponent(raw); - } catch { - return null; - } -} - -export function getAccessToken(): string | null { - return getCookieValue(ACCESS_COOKIE); -} - -export function getRefreshToken(): string | null { - return getCookieValue(REFRESH_COOKIE); + return typeof window !== "undefined"; } export function redirectToLogin(loginPath: string) { diff --git a/apps/pm-web/src/proxy.ts b/apps/pm-web/src/proxy.ts index ec1f84e..59e6c92 100644 --- a/apps/pm-web/src/proxy.ts +++ b/apps/pm-web/src/proxy.ts @@ -1,11 +1,11 @@ import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; -import { sanitizeNextPath } from "./lib/auth"; +import { ACCESS_COOKIE, sanitizeNextPath } from "./lib/auth"; export function proxy(request: NextRequest) { const path = request.nextUrl.pathname; const isPublic = path === "/login" || path.startsWith("/_next") || path.startsWith("/favicon"); - const token = request.cookies.get("bridge_pm_access_token")?.value; + const token = request.cookies.get(ACCESS_COOKIE)?.value; if (!isPublic && !token) { const url = request.nextUrl.clone(); @@ -25,3 +25,4 @@ export function proxy(request: NextRequest) { export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"], }; + diff --git a/backend/src/main/java/com/bridge/backend/common/security/AuthCookieService.java b/backend/src/main/java/com/bridge/backend/common/security/AuthCookieService.java new file mode 100644 index 0000000..66f7cd4 --- /dev/null +++ b/backend/src/main/java/com/bridge/backend/common/security/AuthCookieService.java @@ -0,0 +1,142 @@ +package com.bridge.backend.common.security; + +import com.bridge.backend.config.SecurityProperties; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.Locale; +import java.util.Optional; + +@Service +public class AuthCookieService { + public static final String APP_HEADER_NAME = "X-Bridge-App"; + private static final String APP_QUERY_PARAM = "app"; + + private static final String APP_PM = "pm"; + private static final String APP_CLIENT = "client"; + private static final String APP_ADMIN = "admin"; + + public static final String LEGACY_ACCESS_COOKIE_NAME = "bridge_access_token"; + public static final String LEGACY_REFRESH_COOKIE_NAME = "bridge_refresh_token"; + public static final String PM_ACCESS_COOKIE_NAME = "bridge_pm_access_token"; + public static final String PM_REFRESH_COOKIE_NAME = "bridge_pm_refresh_token"; + public static final String CLIENT_ACCESS_COOKIE_NAME = "bridge_client_access_token"; + public static final String CLIENT_REFRESH_COOKIE_NAME = "bridge_client_refresh_token"; + public static final String ADMIN_ACCESS_COOKIE_NAME = "bridge_admin_access_token"; + public static final String ADMIN_REFRESH_COOKIE_NAME = "bridge_admin_refresh_token"; + + private final JwtProperties jwtProperties; + private final SecurityProperties securityProperties; + + public AuthCookieService(JwtProperties jwtProperties, SecurityProperties securityProperties) { + this.jwtProperties = jwtProperties; + this.securityProperties = securityProperties; + } + + public void writeAuthCookies(HttpServletRequest request, HttpServletResponse response, String accessToken, String refreshToken) { + CookieNames cookieNames = resolveCookieNames(request); + response.addHeader(HttpHeaders.SET_COOKIE, buildCookie(cookieNames.accessCookieName(), accessToken, + Duration.ofMinutes(jwtProperties.getAccessExpirationMinutes()), request).toString()); + response.addHeader(HttpHeaders.SET_COOKIE, buildCookie(cookieNames.refreshCookieName(), refreshToken, + Duration.ofDays(jwtProperties.getRefreshExpirationDays()), request).toString()); + } + + public void clearAuthCookies(HttpServletRequest request, HttpServletResponse response) { + CookieNames cookieNames = resolveCookieNames(request); + response.addHeader(HttpHeaders.SET_COOKIE, buildCookie(cookieNames.accessCookieName(), "", + Duration.ZERO, request).toString()); + response.addHeader(HttpHeaders.SET_COOKIE, buildCookie(cookieNames.refreshCookieName(), "", + Duration.ZERO, request).toString()); + } + + public Optional readAccessToken(HttpServletRequest request) { + CookieNames cookieNames = resolveCookieNames(request); + Optional scoped = readCookie(request, cookieNames.accessCookieName()); + if (scoped.isPresent()) { + return scoped; + } + if (hasScopedHint(request)) { + return Optional.empty(); + } + return readCookie(request, LEGACY_ACCESS_COOKIE_NAME); + } + + public Optional readRefreshToken(HttpServletRequest request) { + CookieNames cookieNames = resolveCookieNames(request); + Optional scoped = readCookie(request, cookieNames.refreshCookieName()); + if (scoped.isPresent()) { + return scoped; + } + if (hasScopedHint(request)) { + return Optional.empty(); + } + return readCookie(request, LEGACY_REFRESH_COOKIE_NAME); + } + + private Optional readCookie(HttpServletRequest request, String name) { + Cookie[] cookies = request.getCookies(); + if (cookies == null) { + return Optional.empty(); + } + for (Cookie cookie : cookies) { + if (name.equals(cookie.getName()) && cookie.getValue() != null && !cookie.getValue().isBlank()) { + return Optional.of(cookie.getValue()); + } + } + return Optional.empty(); + } + + private ResponseCookie buildCookie(String name, String value, Duration maxAge, HttpServletRequest request) { + ResponseCookie.ResponseCookieBuilder builder = ResponseCookie.from(name, value) + .path("/") + .httpOnly(true) + .secure(resolveSecure(request)) + .sameSite("Lax") + .maxAge(maxAge); + String domain = securityProperties.getAuthCookieDomain(); + if (domain != null && !domain.isBlank()) { + builder.domain(domain); + } + return builder.build(); + } + + private boolean resolveSecure(HttpServletRequest request) { + if (request.isSecure()) { + return true; + } + String forwardedProto = request.getHeader("X-Forwarded-Proto"); + return forwardedProto != null && forwardedProto.equalsIgnoreCase("https"); + } + + private boolean hasScopedHint(HttpServletRequest request) { + return normalizeScope(request.getHeader(APP_HEADER_NAME)).isPresent() + || normalizeScope(request.getParameter(APP_QUERY_PARAM)).isPresent(); + } + + private CookieNames resolveCookieNames(HttpServletRequest request) { + String appScope = normalizeScope(request.getHeader(APP_HEADER_NAME)) + .or(() -> normalizeScope(request.getParameter(APP_QUERY_PARAM))) + .orElse(""); + return switch (appScope) { + case APP_PM -> new CookieNames(PM_ACCESS_COOKIE_NAME, PM_REFRESH_COOKIE_NAME); + case APP_CLIENT -> new CookieNames(CLIENT_ACCESS_COOKIE_NAME, CLIENT_REFRESH_COOKIE_NAME); + case APP_ADMIN -> new CookieNames(ADMIN_ACCESS_COOKIE_NAME, ADMIN_REFRESH_COOKIE_NAME); + default -> new CookieNames(LEGACY_ACCESS_COOKIE_NAME, LEGACY_REFRESH_COOKIE_NAME); + }; + } + + private Optional normalizeScope(String value) { + if (value == null || value.isBlank()) { + return Optional.empty(); + } + return Optional.of(value.trim().toLowerCase(Locale.ROOT)); + } + + private record CookieNames(String accessCookieName, String refreshCookieName) { + } +} diff --git a/backend/src/main/java/com/bridge/backend/common/security/JwtAuthenticationFilter.java b/backend/src/main/java/com/bridge/backend/common/security/JwtAuthenticationFilter.java index 68965f8..4f75463 100644 --- a/backend/src/main/java/com/bridge/backend/common/security/JwtAuthenticationFilter.java +++ b/backend/src/main/java/com/bridge/backend/common/security/JwtAuthenticationFilter.java @@ -3,7 +3,6 @@ import io.jsonwebtoken.Claims; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; -import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.http.HttpHeaders; @@ -17,17 +16,12 @@ @Component public class JwtAuthenticationFilter extends OncePerRequestFilter { - private static final String NOTIFICATION_STREAM_PATH = "/api/notifications/stream"; - private static final Set ACCESS_COOKIE_NAMES = Set.of( - "bridge_access_token", - "bridge_pm_access_token", - "bridge_client_access_token", - "bridge_admin_access_token" - ); private final JwtService jwtService; + private final AuthCookieService authCookieService; - public JwtAuthenticationFilter(JwtService jwtService) { + public JwtAuthenticationFilter(JwtService jwtService, AuthCookieService authCookieService) { this.jwtService = jwtService; + this.authCookieService = authCookieService; } @Override @@ -35,6 +29,7 @@ protected boolean shouldNotFilter(HttpServletRequest request) { String path = normalizePath(request); return path.startsWith("/api/auth/login") || path.startsWith("/api/auth/refresh") + || path.startsWith("/api/auth/first-password") || path.startsWith("/v3/api-docs") || path.startsWith("/swagger-ui"); } @@ -66,27 +61,7 @@ private String resolveToken(HttpServletRequest request) { return authHeader.substring(7); } - Cookie[] cookies = request.getCookies(); - if (cookies != null) { - for (Cookie cookie : cookies) { - if (ACCESS_COOKIE_NAMES.contains(cookie.getName()) && cookie.getValue() != null && !cookie.getValue().isBlank()) { - return cookie.getValue(); - } - } - } - - if (isNotificationStreamRequest(request)) { - String queryToken = request.getParameter("accessToken"); - if (queryToken != null && !queryToken.isBlank()) { - return queryToken; - } - } - - return null; - } - - private boolean isNotificationStreamRequest(HttpServletRequest request) { - return NOTIFICATION_STREAM_PATH.equals(normalizePath(request)); + return authCookieService.readAccessToken(request).orElse(null); } private String normalizePath(HttpServletRequest request) { diff --git a/backend/src/main/java/com/bridge/backend/config/SecurityConfig.java b/backend/src/main/java/com/bridge/backend/config/SecurityConfig.java index 8600a2c..d4e1ebc 100644 --- a/backend/src/main/java/com/bridge/backend/config/SecurityConfig.java +++ b/backend/src/main/java/com/bridge/backend/config/SecurityConfig.java @@ -1,6 +1,7 @@ package com.bridge.backend.config; import com.bridge.backend.common.security.JwtAuthenticationFilter; +import com.bridge.backend.common.security.AuthCookieService; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; @@ -32,7 +33,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .cors(cors -> cors.configurationSource(corsConfigurationSource())) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth - .requestMatchers("/api/auth/login", "/api/auth/refresh").permitAll() + .requestMatchers("/api/auth/login", "/api/auth/refresh", "/api/auth/first-password").permitAll() .requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll() .requestMatchers(HttpMethod.GET, "/actuator/health").permitAll() .anyRequest().authenticated()) @@ -45,7 +46,7 @@ public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); configuration.setAllowedOrigins(securityProperties.getAllowedOrigins()); configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PATCH", "DELETE", "OPTIONS")); - configuration.setAllowedHeaders(Arrays.asList("Authorization", "Content-Type", "X-Tenant-Id")); + configuration.setAllowedHeaders(Arrays.asList("Authorization", "Content-Type", "X-Tenant-Id", AuthCookieService.APP_HEADER_NAME)); configuration.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", configuration); diff --git a/backend/src/main/java/com/bridge/backend/config/SecurityProperties.java b/backend/src/main/java/com/bridge/backend/config/SecurityProperties.java index 874919c..d4d656a 100644 --- a/backend/src/main/java/com/bridge/backend/config/SecurityProperties.java +++ b/backend/src/main/java/com/bridge/backend/config/SecurityProperties.java @@ -11,4 +11,5 @@ @ConfigurationProperties(prefix = "bridge.security") public class SecurityProperties { private List allowedOrigins; + private String authCookieDomain; } diff --git a/backend/src/main/java/com/bridge/backend/domain/admin/AdminController.java b/backend/src/main/java/com/bridge/backend/domain/admin/AdminController.java index 82be576..551f879 100644 --- a/backend/src/main/java/com/bridge/backend/domain/admin/AdminController.java +++ b/backend/src/main/java/com/bridge/backend/domain/admin/AdminController.java @@ -90,6 +90,12 @@ public ApiSuccess> getUser(@PathVariable UUID userId) { return ApiSuccess.of(adminService.getUserDetail(userId)); } + @PostMapping("/users/{userId}/unlock-login") + public ApiSuccess> unlockLogin(@PathVariable UUID userId) { + accessGuardService.requirePlatformAdmin(SecurityUtils.currentUserId()); + return ApiSuccess.of(adminService.unlockLogin(userId)); + } + public record CreateTenantRequest(@NotBlank String name, @NotBlank String slug) { } diff --git a/backend/src/main/java/com/bridge/backend/domain/admin/AdminService.java b/backend/src/main/java/com/bridge/backend/domain/admin/AdminService.java index 633a3aa..c5a64e3 100644 --- a/backend/src/main/java/com/bridge/backend/domain/admin/AdminService.java +++ b/backend/src/main/java/com/bridge/backend/domain/admin/AdminService.java @@ -15,6 +15,7 @@ import java.util.Comparator; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.UUID; import java.util.function.Function; @@ -71,10 +72,11 @@ public TenantEntity getTenant(UUID tenantId) { @Transactional public UserEntity createPmUser(UUID tenantId, String email, String name, UUID actorId) { getTenant(tenantId); + String normalizedEmail = email.trim().toLowerCase(Locale.ROOT); - UserEntity user = userRepository.findByEmailAndDeletedAtIsNull(email).orElseGet(() -> { + UserEntity user = userRepository.findByEmailAndDeletedAtIsNull(normalizedEmail).orElseGet(() -> { UserEntity created = new UserEntity(); - created.setEmail(email); + created.setEmail(normalizedEmail); created.setName(name); created.setPasswordHash(passwordEncoder.encode("TempPassword!123")); created.setStatus(UserStatus.INVITED); @@ -102,7 +104,6 @@ public List> listPmUsers(UUID tenantId) { getTenant(tenantId); return tenantMemberRepository.findByTenantIdAndDeletedAtIsNull(tenantId).stream() - .filter(member -> member.getRole() == MemberRole.PM_OWNER || member.getRole() == MemberRole.PM_MEMBER) .map(member -> { UserEntity user = requireActiveUser(member.getUserId()); Map row = new HashMap<>(); @@ -158,6 +159,9 @@ public Map getUserDetail(UUID userId) { "status", user.getStatus(), "isPlatformAdmin", user.isPlatformAdmin(), "lastLoginAt", user.getLastLoginAt(), + "failedLoginAttempts", user.getFailedLoginAttempts(), + "loginBlocked", user.getFailedLoginAttempts() >= 5, + "passwordInitialized", user.isPasswordInitialized(), "memberships", tenantMemberships ); } @@ -169,6 +173,18 @@ public UserEntity updateUserStatus(UUID userId, UserStatus status) { return userRepository.save(user); } + @Transactional + public Map unlockLogin(UUID userId) { + UserEntity user = requireActiveUser(userId); + user.setFailedLoginAttempts(0); + userRepository.save(user); + return Map.of( + "userId", user.getId(), + "loginBlocked", false, + "failedLoginAttempts", 0 + ); + } + private UserEntity requireActiveUser(UUID userId) { return userRepository.findByIdAndDeletedAtIsNull(userId) .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "USER_NOT_FOUND", "사용자를 찾을 수 없습니다.")); diff --git a/backend/src/main/java/com/bridge/backend/domain/auth/AuthController.java b/backend/src/main/java/com/bridge/backend/domain/auth/AuthController.java index 65c4439..058abe5 100644 --- a/backend/src/main/java/com/bridge/backend/domain/auth/AuthController.java +++ b/backend/src/main/java/com/bridge/backend/domain/auth/AuthController.java @@ -1,7 +1,10 @@ package com.bridge.backend.domain.auth; import com.bridge.backend.common.api.ApiSuccess; +import com.bridge.backend.common.security.AuthCookieService; import com.bridge.backend.common.security.SecurityUtils; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; @@ -9,6 +12,7 @@ import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; +import java.util.HashMap; import java.util.Map; import java.util.UUID; @@ -17,24 +21,47 @@ @Validated public class AuthController { private final AuthService authService; + private final AuthCookieService authCookieService; - public AuthController(AuthService authService) { + public AuthController(AuthService authService, AuthCookieService authCookieService) { this.authService = authService; + this.authCookieService = authCookieService; } @PostMapping("/login") - public ApiSuccess> login(@RequestBody @Valid LoginRequest request) { - return ApiSuccess.of(authService.login(request.email(), request.password(), request.tenantSlug())); + public ApiSuccess> login(@RequestBody @Valid LoginRequest request, + HttpServletRequest httpRequest, + HttpServletResponse httpResponse) { + Map result = authService.login(request.email(), request.password(), request.tenantSlug()); + if (Boolean.TRUE.equals(result.get("requiresTenantSelection"))) { + return ApiSuccess.of(result); + } + + String accessToken = (String) result.get("accessToken"); + String refreshToken = (String) result.get("refreshToken"); + authCookieService.writeAuthCookies(httpRequest, httpResponse, accessToken, refreshToken); + + Map response = new HashMap<>(result); + response.remove("accessToken"); + response.remove("refreshToken"); + return ApiSuccess.of(response); } @PostMapping("/refresh") - public ApiSuccess> refresh(@RequestBody @Valid RefreshRequest request) { - return ApiSuccess.of(authService.refresh(request.refreshToken())); + public ApiSuccess> refresh(HttpServletRequest httpRequest, + HttpServletResponse httpResponse) { + String refreshToken = authCookieService.readRefreshToken(httpRequest).orElse(null); + Map refreshed = authService.refresh(refreshToken); + authCookieService.writeAuthCookies(httpRequest, httpResponse, + String.valueOf(refreshed.get("accessToken")), String.valueOf(refreshed.get("refreshToken"))); + return ApiSuccess.of(Map.of("refreshed", true)); } @PostMapping("/logout") - public ApiSuccess> logout(@RequestBody @Valid LogoutRequest request) { - authService.logout(request.refreshToken()); + public ApiSuccess> logout(HttpServletRequest httpRequest, + HttpServletResponse httpResponse) { + authCookieService.readRefreshToken(httpRequest).ifPresent(authService::logout); + authCookieService.clearAuthCookies(httpRequest, httpResponse); return ApiSuccess.of(Map.of("loggedOut", true)); } @@ -44,19 +71,31 @@ public ApiSuccess> me() { } @PostMapping("/switch-tenant") - public ApiSuccess> switchTenant(@RequestBody @Valid SwitchTenantRequest request) { - return ApiSuccess.of(authService.switchTenant(SecurityUtils.currentUserId(), request.tenantId())); + public ApiSuccess> switchTenant(@RequestBody @Valid SwitchTenantRequest request, + HttpServletRequest httpRequest, + HttpServletResponse httpResponse) { + Map result = authService.switchTenant(SecurityUtils.currentUserId(), request.tenantId()); + authCookieService.writeAuthCookies(httpRequest, httpResponse, + String.valueOf(result.get("accessToken")), String.valueOf(result.get("refreshToken"))); + Map response = new HashMap<>(result); + response.remove("accessToken"); + response.remove("refreshToken"); + return ApiSuccess.of(response); } - public record LoginRequest(@Email @NotBlank String email, @NotBlank String password, String tenantSlug) { + @PostMapping("/first-password") + public ApiSuccess> firstPassword(@RequestBody @Valid FirstPasswordRequest request) { + return ApiSuccess.of(authService.setupFirstPassword(request.email(), request.setupCode(), request.newPassword())); } - public record RefreshRequest(@NotBlank String refreshToken) { + public record LoginRequest(@Email @NotBlank String email, @NotBlank String password, String tenantSlug) { } - public record LogoutRequest(@NotBlank String refreshToken) { + public record SwitchTenantRequest(@NotNull UUID tenantId) { } - public record SwitchTenantRequest(@NotNull UUID tenantId) { + public record FirstPasswordRequest(@Email @NotBlank String email, + @NotBlank String setupCode, + @NotBlank String newPassword) { } } diff --git a/backend/src/main/java/com/bridge/backend/domain/auth/AuthService.java b/backend/src/main/java/com/bridge/backend/domain/auth/AuthService.java index c8718b8..ecbe9e1 100644 --- a/backend/src/main/java/com/bridge/backend/domain/auth/AuthService.java +++ b/backend/src/main/java/com/bridge/backend/domain/auth/AuthService.java @@ -25,13 +25,14 @@ import java.util.Map; import java.util.Set; import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; import java.util.function.Function; import java.util.stream.Collectors; @Service public class AuthService { private static final int MAX_FAILED_ATTEMPTS = 5; + private static final int MIN_PASSWORD_LENGTH = 10; + private static final int MAX_PASSWORD_LENGTH = 72; private final UserRepository userRepository; private final TenantRepository tenantRepository; private final TenantMemberRepository tenantMemberRepository; @@ -39,7 +40,6 @@ public class AuthService { private final RefreshTokenRepository refreshTokenRepository; private final PasswordEncoder passwordEncoder; private final JwtService jwtService; - private final Map failedLoginAttempts = new ConcurrentHashMap<>(); public AuthService(UserRepository userRepository, TenantRepository tenantRepository, @@ -57,25 +57,37 @@ public AuthService(UserRepository userRepository, this.jwtService = jwtService; } - @Transactional public Map login(String email, String password, String tenantSlug) { - String key = email.toLowerCase(Locale.ROOT); - int failCount = failedLoginAttempts.getOrDefault(key, 0); - if (failCount >= MAX_FAILED_ATTEMPTS) { - throw new AppException(HttpStatus.TOO_MANY_REQUESTS, "LOGIN_BLOCKED", "Too many login attempts."); - } + String normalizedEmail = normalizeEmail(email); - UserEntity user = userRepository.findByEmailAndDeletedAtIsNull(email) - .orElseThrow(() -> invalidCredentials(key)); + UserEntity user = userRepository.findByEmailAndDeletedAtIsNull(normalizedEmail) + .orElseThrow(this::invalidCredentials); - if (!passwordEncoder.matches(password, user.getPasswordHash())) { - throw invalidCredentials(key); + if (user.getFailedLoginAttempts() >= MAX_FAILED_ATTEMPTS) { + throw new AppException(HttpStatus.TOO_MANY_REQUESTS, "LOGIN_BLOCKED", "Too many login attempts."); } if (user.getStatus() == UserStatus.SUSPENDED || user.getStatus() == UserStatus.DEACTIVATED) { throw new AppException(HttpStatus.FORBIDDEN, "USER_BLOCKED", "User is blocked."); } + if (!user.isPasswordInitialized()) { + throw new AppException( + HttpStatus.FORBIDDEN, + "PASSWORD_SETUP_REQUIRED", + "First password setup is required.", + Map.of("email", user.getEmail()) + ); + } + if (!passwordEncoder.matches(password, user.getPasswordHash())) { + int nextAttempts = user.getFailedLoginAttempts() + 1; + user.setFailedLoginAttempts(nextAttempts); + userRepository.save(user); + if (nextAttempts >= MAX_FAILED_ATTEMPTS) { + throw new AppException(HttpStatus.TOO_MANY_REQUESTS, "LOGIN_BLOCKED", "Too many login attempts."); + } + throw invalidCredentials(); + } - failedLoginAttempts.remove(key); + user.setFailedLoginAttempts(0); if (user.isPlatformAdmin()) { return loginAsPlatformAdmin(user, tenantSlug); @@ -207,6 +219,7 @@ private Map issueTokensWithRoles(UserEntity user, UUID tenantId, user.setLastLoginAt(OffsetDateTime.now()); user.setStatus(UserStatus.ACTIVE); + user.setFailedLoginAttempts(0); userRepository.save(user); return Map.of( @@ -220,6 +233,9 @@ private Map issueTokensWithRoles(UserEntity user, UUID tenantId, @Transactional public Map refresh(String refreshToken) { + if (refreshToken == null || refreshToken.isBlank()) { + throw new AppException(HttpStatus.UNAUTHORIZED, "REFRESH_MISSING", "Refresh token missing."); + } Claims claims = jwtService.parse(refreshToken); if (!jwtService.isRefreshToken(claims)) { throw new AppException(HttpStatus.UNAUTHORIZED, "INVALID_TOKEN", "Invalid refresh token."); @@ -239,6 +255,9 @@ public Map refresh(String refreshToken) { .collect(Collectors.toSet()); UserEntity user = userRepository.findByIdAndDeletedAtIsNull(userId) .orElseThrow(() -> new AppException(HttpStatus.UNAUTHORIZED, "USER_NOT_FOUND", "User not found.")); + if (user.getStatus() == UserStatus.SUSPENDED || user.getStatus() == UserStatus.DEACTIVATED) { + throw new AppException(HttpStatus.FORBIDDEN, "USER_BLOCKED", "User is blocked."); + } tenantMemberRepository.findByTenantIdAndUserIdAndDeletedAtIsNull(tenantId, userId) .ifPresent(member -> roles.add("TENANT_" + member.getRole().name())); if (user.isPlatformAdmin()) { @@ -299,11 +318,71 @@ public Map me(UUID userId) { ); } - private AppException invalidCredentials(String key) { - failedLoginAttempts.put(key, failedLoginAttempts.getOrDefault(key, 0) + 1); + @Transactional + public Map setupFirstPassword(String email, String setupCode, String newPassword) { + String normalizedEmail = normalizeEmail(email); + UserEntity user = userRepository.findByEmailAndDeletedAtIsNull(normalizedEmail) + .orElseThrow(() -> new AppException(HttpStatus.BAD_REQUEST, "PASSWORD_SETUP_CODE_INVALID", "Invalid password setup code.")); + if (user.isPasswordInitialized()) { + throw new AppException(HttpStatus.BAD_REQUEST, "PASSWORD_ALREADY_INITIALIZED", "Password already initialized."); + } + if (setupCode == null || setupCode.isBlank()) { + throw new AppException(HttpStatus.BAD_REQUEST, "PASSWORD_SETUP_CODE_INVALID", "Invalid password setup code."); + } + if (!isSetupCodeValid(user, setupCode)) { + throw new AppException(HttpStatus.BAD_REQUEST, "PASSWORD_SETUP_CODE_INVALID", "Invalid password setup code."); + } + validatePasswordPolicy(newPassword); + + user.setPasswordHash(passwordEncoder.encode(newPassword)); + user.setPasswordInitialized(true); + user.setPasswordSetupCodeHash(null); + user.setPasswordSetupCodeExpiresAt(null); + user.setFailedLoginAttempts(0); + user.setStatus(UserStatus.ACTIVE); + userRepository.save(user); + + return Map.of("passwordInitialized", true); + } + + private boolean isSetupCodeValid(UserEntity user, String setupCode) { + if (user.getPasswordSetupCodeHash() == null || user.getPasswordSetupCodeExpiresAt() == null) { + return false; + } + if (user.getPasswordSetupCodeExpiresAt().isBefore(OffsetDateTime.now())) { + return false; + } + return sha256(setupCode).equals(user.getPasswordSetupCodeHash()); + } + + private void validatePasswordPolicy(String password) { + if (password == null || password.length() < MIN_PASSWORD_LENGTH || password.length() > MAX_PASSWORD_LENGTH) { + throw new AppException( + HttpStatus.BAD_REQUEST, + "PASSWORD_POLICY_VIOLATION", + "Password must be 10-72 characters and include upper/lowercase letters and a number." + ); + } + boolean hasUpper = password.chars().anyMatch(Character::isUpperCase); + boolean hasLower = password.chars().anyMatch(Character::isLowerCase); + boolean hasDigit = password.chars().anyMatch(Character::isDigit); + if (!hasUpper || !hasLower || !hasDigit) { + throw new AppException( + HttpStatus.BAD_REQUEST, + "PASSWORD_POLICY_VIOLATION", + "Password must be 10-72 characters and include upper/lowercase letters and a number." + ); + } + } + + private AppException invalidCredentials() { return new AppException(HttpStatus.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid email or password."); } + private String normalizeEmail(String email) { + return email == null ? "" : email.trim().toLowerCase(Locale.ROOT); + } + private String sha256(String value) { try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); diff --git a/backend/src/main/java/com/bridge/backend/domain/auth/UserEntity.java b/backend/src/main/java/com/bridge/backend/domain/auth/UserEntity.java index 42a8971..c3a8265 100644 --- a/backend/src/main/java/com/bridge/backend/domain/auth/UserEntity.java +++ b/backend/src/main/java/com/bridge/backend/domain/auth/UserEntity.java @@ -31,4 +31,16 @@ public class UserEntity extends BaseEntity { @Column(name = "last_login_at") private OffsetDateTime lastLoginAt; + + @Column(name = "failed_login_attempts", nullable = false) + private int failedLoginAttempts; + + @Column(name = "password_initialized", nullable = false) + private boolean passwordInitialized = true; + + @Column(name = "password_setup_code_hash", length = 255) + private String passwordSetupCodeHash; + + @Column(name = "password_setup_code_expires_at") + private OffsetDateTime passwordSetupCodeExpiresAt; } diff --git a/backend/src/main/java/com/bridge/backend/domain/project/ProjectController.java b/backend/src/main/java/com/bridge/backend/domain/project/ProjectController.java index 8cc59ea..3d20c2a 100644 --- a/backend/src/main/java/com/bridge/backend/domain/project/ProjectController.java +++ b/backend/src/main/java/com/bridge/backend/domain/project/ProjectController.java @@ -54,12 +54,16 @@ public ApiSuccess invite(@PathVariable UUID SecurityUtils.requirePrincipal(), projectId, request.loginId(), - request.password(), request.name(), request.role() )); } + @PostMapping("/api/projects/{projectId}/members/{memberId}/setup-code/reset") + public ApiSuccess resetSetupCode(@PathVariable UUID projectId, @PathVariable UUID memberId) { + return ApiSuccess.of(projectService.resetSetupCode(SecurityUtils.requirePrincipal(), projectId, memberId)); + } + @PatchMapping("/api/projects/{projectId}/members/{memberId}") public ApiSuccess updateMemberRole(@PathVariable UUID projectId, @PathVariable UUID memberId, @@ -91,7 +95,7 @@ public record CreateProjectRequest(@NotBlank String name, String description) { public record UpdateProjectRequest(String name, String description, ProjectStatus status) { } - public record InviteRequest(@Email @NotBlank String loginId, @NotBlank String password, String name, MemberRole role) { + public record InviteRequest(@Email @NotBlank String loginId, String name, MemberRole role) { } public record UpdateMemberRequest(MemberRole role) { diff --git a/backend/src/main/java/com/bridge/backend/domain/project/ProjectService.java b/backend/src/main/java/com/bridge/backend/domain/project/ProjectService.java index 545aa3b..18116b4 100644 --- a/backend/src/main/java/com/bridge/backend/domain/project/ProjectService.java +++ b/backend/src/main/java/com/bridge/backend/domain/project/ProjectService.java @@ -15,6 +15,9 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; import java.time.OffsetDateTime; import java.util.*; import java.util.regex.Pattern; @@ -23,8 +26,12 @@ public class ProjectService { private static final String PASSWORD_MASK = "********"; private static final Pattern SIMPLE_EMAIL_PATTERN = Pattern.compile("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$"); + private static final String SETUP_CODE_DIGITS = "0123456789"; + private static final int SETUP_CODE_LENGTH = 8; + private static final int SETUP_CODE_TTL_HOURS = 24; private static final int MIN_PASSWORD_LENGTH = 10; private static final int MAX_PASSWORD_LENGTH = 72; + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); private final ProjectRepository projectRepository; private final ProjectMemberRepository projectMemberRepository; @@ -47,7 +54,14 @@ public ProjectService(ProjectRepository projectRepository, this.accessGuardService = accessGuardService; } - public record ProjectMemberAccount(UUID id, UUID userId, MemberRole role, String loginId, String passwordMask) { + public record ProjectMemberAccount(UUID id, + UUID userId, + MemberRole role, + String loginId, + String passwordMask, + boolean passwordInitialized, + String setupCode, + OffsetDateTime setupCodeExpiresAt) { } @Transactional(readOnly = true) @@ -119,7 +133,7 @@ public List members(AuthPrincipal principal, UUID projectI List accounts = new ArrayList<>(); for (ProjectMemberEntity member : projectMembers) { - accounts.add(toProjectMemberAccount(member, usersById.get(member.getUserId()))); + accounts.add(toProjectMemberAccount(member, usersById.get(member.getUserId()), null, null)); } return accounts; } @@ -128,30 +142,40 @@ public List members(AuthPrincipal principal, UUID projectI public ProjectMemberAccount invite(AuthPrincipal principal, UUID projectId, String loginId, - String password, String name, MemberRole role) { accessGuardService.requireProjectMemberRole(projectId, principal.getUserId(), principal.getTenantId(), Set.of(MemberRole.PM_OWNER, MemberRole.PM_MEMBER)); MemberRole resolvedRole = role == null ? MemberRole.CLIENT_MEMBER : role; - if (loginId == null || loginId.isBlank() || password == null || password.isBlank()) { - throw new AppException(HttpStatus.BAD_REQUEST, "MEMBER_ACCOUNT_REQUIRED", "Login id and password are required."); + if (loginId == null || loginId.isBlank()) { + throw new AppException(HttpStatus.BAD_REQUEST, "MEMBER_ACCOUNT_REQUIRED", "Login id is required."); } String normalizedLoginId = loginId.trim().toLowerCase(Locale.ROOT); validateLoginId(normalizedLoginId); + String setupCode = null; + OffsetDateTime setupCodeExpiresAt = null; Optional existingUserOpt = userRepository.findByEmailAndDeletedAtIsNull(normalizedLoginId); UserEntity savedUser = existingUserOpt.orElseGet(() -> { - validatePasswordPolicy(password); UserEntity entity = new UserEntity(); entity.setEmail(normalizedLoginId); entity.setName((name == null || name.isBlank()) ? normalizedLoginId.split("@")[0] : name.trim()); - entity.setPasswordHash(passwordEncoder.encode(password)); - entity.setStatus(UserStatus.ACTIVE); + entity.setPasswordHash(passwordEncoder.encode(UUID.randomUUID().toString())); + entity.setStatus(UserStatus.INVITED); + entity.setPasswordInitialized(false); + entity.setFailedLoginAttempts(0); entity.setCreatedBy(principal.getUserId()); entity.setUpdatedBy(principal.getUserId()); return userRepository.save(entity); }); + if (!savedUser.isPasswordInitialized()) { + setupCode = generateSetupCode(); + setupCodeExpiresAt = OffsetDateTime.now().plusHours(SETUP_CODE_TTL_HOURS); + savedUser.setPasswordSetupCodeHash(sha256(setupCode)); + savedUser.setPasswordSetupCodeExpiresAt(setupCodeExpiresAt); + savedUser.setUpdatedBy(principal.getUserId()); + savedUser = userRepository.save(savedUser); + } if (tenantMemberRepository.findByTenantIdAndUserIdAndDeletedAtIsNull(principal.getTenantId(), savedUser.getId()).isEmpty()) { TenantMemberEntity tenantMember = new TenantMemberEntity(); @@ -163,12 +187,13 @@ public ProjectMemberAccount invite(AuthPrincipal principal, tenantMemberRepository.save(tenantMember); } - ProjectMemberEntity member = projectMemberRepository.findByProjectIdAndUserIdAndDeletedAtIsNull(projectId, savedUser.getId()) + UUID savedUserId = savedUser.getId(); + ProjectMemberEntity member = projectMemberRepository.findByProjectIdAndUserIdAndDeletedAtIsNull(projectId, savedUserId) .orElseGet(() -> { ProjectMemberEntity created = new ProjectMemberEntity(); created.setTenantId(principal.getTenantId()); created.setProjectId(projectId); - created.setUserId(savedUser.getId()); + created.setUserId(savedUserId); created.setRole(resolvedRole); created.setCreatedBy(principal.getUserId()); created.setUpdatedBy(principal.getUserId()); @@ -182,7 +207,32 @@ public ProjectMemberAccount invite(AuthPrincipal principal, savedMember.setUpdatedBy(principal.getUserId()); savedMember = projectMemberRepository.save(savedMember); } - return toProjectMemberAccount(savedMember, savedUser); + return toProjectMemberAccount(savedMember, savedUser, setupCode, setupCodeExpiresAt); + } + + @Transactional + public ProjectMemberAccount resetSetupCode(AuthPrincipal principal, UUID projectId, UUID memberId) { + accessGuardService.requireProjectMemberRole(projectId, principal.getUserId(), principal.getTenantId(), + Set.of(MemberRole.PM_OWNER, MemberRole.PM_MEMBER)); + ProjectMemberEntity member = projectMemberRepository.findById(memberId) + .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "MEMBER_NOT_FOUND", "프로젝트 멤버를 찾을 수 없습니다.")); + if (member.getDeletedAt() != null || !member.getProjectId().equals(projectId)) { + throw new AppException(HttpStatus.NOT_FOUND, "MEMBER_NOT_FOUND", "프로젝트 멤버를 찾을 수 없습니다."); + } + + UserEntity user = userRepository.findByIdAndDeletedAtIsNull(member.getUserId()) + .orElseThrow(() -> new AppException(HttpStatus.NOT_FOUND, "USER_NOT_FOUND", "사용자를 찾을 수 없습니다.")); + if (user.isPasswordInitialized()) { + throw new AppException(HttpStatus.BAD_REQUEST, "PASSWORD_ALREADY_INITIALIZED", "Password already initialized."); + } + + String setupCode = generateSetupCode(); + OffsetDateTime setupCodeExpiresAt = OffsetDateTime.now().plusHours(SETUP_CODE_TTL_HOURS); + user.setPasswordSetupCodeHash(sha256(setupCode)); + user.setPasswordSetupCodeExpiresAt(setupCodeExpiresAt); + user.setUpdatedBy(principal.getUserId()); + UserEntity updated = userRepository.save(user); + return toProjectMemberAccount(member, updated, setupCode, setupCodeExpiresAt); } @Transactional @@ -238,11 +288,15 @@ public ProjectMemberAccount updateMemberAccount(AuthPrincipal principal, validatePasswordPolicy(password); user.setPasswordHash(passwordEncoder.encode(password)); user.setStatus(UserStatus.ACTIVE); + user.setPasswordInitialized(true); + user.setPasswordSetupCodeHash(null); + user.setPasswordSetupCodeExpiresAt(null); + user.setFailedLoginAttempts(0); } user.setUpdatedBy(principal.getUserId()); UserEntity updatedUser = userRepository.save(user); - return toProjectMemberAccount(member, updatedUser); + return toProjectMemberAccount(member, updatedUser, null, null); } @Transactional @@ -268,6 +322,15 @@ private void validateLoginId(String loginId) { } } + private String generateSetupCode() { + StringBuilder builder = new StringBuilder(SETUP_CODE_LENGTH); + for (int i = 0; i < SETUP_CODE_LENGTH; i++) { + int index = SECURE_RANDOM.nextInt(SETUP_CODE_DIGITS.length()); + builder.append(SETUP_CODE_DIGITS.charAt(index)); + } + return builder.toString(); + } + private void validatePasswordPolicy(String password) { if (password.length() < MIN_PASSWORD_LENGTH || password.length() > MAX_PASSWORD_LENGTH) { throw new AppException( @@ -288,9 +351,31 @@ private void validatePasswordPolicy(String password) { } } - private ProjectMemberAccount toProjectMemberAccount(ProjectMemberEntity member, UserEntity user) { + private String sha256(String value) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return Base64.getEncoder().encodeToString(digest.digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (Exception ex) { + throw new IllegalStateException(ex); + } + } + + private ProjectMemberAccount toProjectMemberAccount(ProjectMemberEntity member, + UserEntity user, + String setupCode, + OffsetDateTime setupCodeExpiresAt) { String loginId = user == null ? "" : user.getEmail(); - return new ProjectMemberAccount(member.getId(), member.getUserId(), member.getRole(), loginId, PASSWORD_MASK); + boolean passwordInitialized = user != null && user.isPasswordInitialized(); + return new ProjectMemberAccount( + member.getId(), + member.getUserId(), + member.getRole(), + loginId, + PASSWORD_MASK, + passwordInitialized, + setupCode, + setupCodeExpiresAt + ); } } diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 2a3ced6..3b106fd 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -2,6 +2,7 @@ spring.application.name=backend server.port=8080 server.servlet.context-path= +server.max-http-request-header-size=${MAX_HTTP_REQUEST_HEADER_SIZE:64KB} spring.datasource.url=${DB_URL:jdbc:postgresql://localhost:5432/bridge} spring.datasource.username=${DB_USERNAME:bridge} @@ -25,6 +26,7 @@ bridge.jwt.access-expiration-minutes=15 bridge.jwt.refresh-expiration-days=30 bridge.security.allowed-origins=${ALLOWED_ORIGINS:http://localhost:3000,http://localhost:3001,http://localhost:3002} +bridge.security.auth-cookie-domain=${AUTH_COOKIE_DOMAIN:} bridge.storage.bucket=${MINIO_BUCKET:bridge} bridge.storage.endpoint=${MINIO_ENDPOINT:http://localhost:9000} diff --git a/backend/src/main/resources/db/migration/V11__upload_tickets.sql b/backend/src/main/resources/db/migration/V11__upload_tickets.sql new file mode 100644 index 0000000..fefa569 --- /dev/null +++ b/backend/src/main/resources/db/migration/V11__upload_tickets.sql @@ -0,0 +1,19 @@ +CREATE TABLE IF NOT EXISTS upload_tickets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id), + aggregate_type VARCHAR(80) NOT NULL, + aggregate_id UUID NOT NULL, + object_key VARCHAR(400) NOT NULL, + content_type VARCHAR(120) NOT NULL, + expected_version INTEGER, + expires_at TIMESTAMPTZ NOT NULL, + consumed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_by UUID, + updated_at TIMESTAMPTZ, + updated_by UUID, + deleted_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_upload_tickets_lookup + ON upload_tickets (tenant_id, aggregate_type, aggregate_id, created_at DESC); diff --git a/backend/src/main/resources/db/migration/V12__auth_hardening_fields.sql b/backend/src/main/resources/db/migration/V12__auth_hardening_fields.sql new file mode 100644 index 0000000..2329704 --- /dev/null +++ b/backend/src/main/resources/db/migration/V12__auth_hardening_fields.sql @@ -0,0 +1,11 @@ +ALTER TABLE users + ADD COLUMN failed_login_attempts INTEGER NOT NULL DEFAULT 0; + +ALTER TABLE users + ADD COLUMN password_initialized BOOLEAN NOT NULL DEFAULT TRUE; + +ALTER TABLE users + ADD COLUMN password_setup_code_hash VARCHAR(255); + +ALTER TABLE users + ADD COLUMN password_setup_code_expires_at TIMESTAMPTZ; diff --git a/backend/src/test/java/com/bridge/backend/domain/admin/AdminServiceTest.java b/backend/src/test/java/com/bridge/backend/domain/admin/AdminServiceTest.java new file mode 100644 index 0000000..8e612ac --- /dev/null +++ b/backend/src/test/java/com/bridge/backend/domain/admin/AdminServiceTest.java @@ -0,0 +1,54 @@ +package com.bridge.backend.domain.admin; + +import com.bridge.backend.domain.auth.UserEntity; +import com.bridge.backend.domain.auth.UserRepository; +import com.bridge.backend.domain.project.ProjectRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AdminServiceTest { + + @Mock + private TenantRepository tenantRepository; + @Mock + private TenantMemberRepository tenantMemberRepository; + @Mock + private UserRepository userRepository; + @Mock + private ProjectRepository projectRepository; + @Mock + private PasswordEncoder passwordEncoder; + + @InjectMocks + private AdminService adminService; + + @Test + void unlockLoginResetsFailedAttempts() { + UUID userId = UUID.randomUUID(); + UserEntity user = new UserEntity(); + user.setId(userId); + user.setFailedLoginAttempts(5); + + when(userRepository.findByIdAndDeletedAtIsNull(userId)).thenReturn(Optional.of(user)); + + Map result = adminService.unlockLogin(userId); + + assertThat(user.getFailedLoginAttempts()).isEqualTo(0); + assertThat(result.get("loginBlocked")).isEqualTo(false); + assertThat(result.get("failedLoginAttempts")).isEqualTo(0); + verify(userRepository).save(user); + } +} diff --git a/backend/src/test/java/com/bridge/backend/domain/auth/AuthServiceTest.java b/backend/src/test/java/com/bridge/backend/domain/auth/AuthServiceTest.java new file mode 100644 index 0000000..041e40f --- /dev/null +++ b/backend/src/test/java/com/bridge/backend/domain/auth/AuthServiceTest.java @@ -0,0 +1,86 @@ +package com.bridge.backend.domain.auth; + +import com.bridge.backend.common.api.AppException; +import com.bridge.backend.common.model.enums.UserStatus; +import com.bridge.backend.common.security.JwtService; +import com.bridge.backend.domain.admin.TenantMemberRepository; +import com.bridge.backend.domain.admin.TenantRepository; +import com.bridge.backend.domain.project.ProjectMemberRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AuthServiceTest { + + @Mock + private UserRepository userRepository; + @Mock + private TenantRepository tenantRepository; + @Mock + private TenantMemberRepository tenantMemberRepository; + @Mock + private ProjectMemberRepository projectMemberRepository; + @Mock + private RefreshTokenRepository refreshTokenRepository; + @Mock + private PasswordEncoder passwordEncoder; + @Mock + private JwtService jwtService; + + @InjectMocks + private AuthService authService; + + @Test + void loginBlocksAfterFifthFailure() { + UserEntity user = new UserEntity(); + user.setEmail("client@bridge.local"); + user.setPasswordHash("hashed-password"); + user.setStatus(UserStatus.ACTIVE); + user.setPasswordInitialized(true); + user.setFailedLoginAttempts(4); + + when(userRepository.findByEmailAndDeletedAtIsNull("client@bridge.local")) + .thenReturn(Optional.of(user)); + when(passwordEncoder.matches("wrong-password", "hashed-password")).thenReturn(false); + + AppException ex = assertThrows( + AppException.class, + () -> authService.login("client@bridge.local", "wrong-password", "bridge") + ); + + assertThat(ex.getCode()).isEqualTo("LOGIN_BLOCKED"); + assertThat(user.getFailedLoginAttempts()).isEqualTo(5); + verify(userRepository).save(user); + } + + @Test + void blockedUserCannotLoginEvenWithCorrectPassword() { + UserEntity user = new UserEntity(); + user.setEmail("client@bridge.local"); + user.setPasswordHash("hashed-password"); + user.setStatus(UserStatus.ACTIVE); + user.setPasswordInitialized(true); + user.setFailedLoginAttempts(5); + + when(userRepository.findByEmailAndDeletedAtIsNull("client@bridge.local")) + .thenReturn(Optional.of(user)); + + AppException ex = assertThrows( + AppException.class, + () -> authService.login("client@bridge.local", "correct-password", "bridge") + ); + + assertThat(ex.getCode()).isEqualTo("LOGIN_BLOCKED"); + } +} diff --git a/docs/Plan/PLAN.md b/docs/Plan/PLAN.md index 4052b63..f795672 100644 --- a/docs/Plan/PLAN.md +++ b/docs/Plan/PLAN.md @@ -13,7 +13,7 @@ 2. 모노레포는 `pnpm + Turborepo`로 구성하고, 서비스 의존성은 Docker(`postgres`, `minio`, `mailhog`) 기준으로 운영한다. 3. 개발 원칙은 `플랜 우선`, `PROJECT.md 주기 점검`, `Playwright MCP 프론트 검증`, `MVP 금지`를 강제한다. 4. 인증은 전면 강제한다: 모든 앱 최초 진입 로그인, 미인증 사용 불가, 세션 만료 즉시 로그인 리다이렉트. -5. `/sign/[recipientToken]`도 로그인 필수이며 로그인 후 recipient token 소유권 검증을 통과해야 사용 가능하다. +5. `/sign/[contractId]`도 로그인 필수이며 로그인 후 recipient token 소유권 검증을 통과해야 사용 가능하다. 6. 프론트 품질은 `frontend-design + ui-ux-pro-max` 기준으로 운영한다. 7. 백엔드 영속성은 `Spring Data JPA(Hibernate)`를 표준으로 강제한다. 8. 구현 안정성은 `무한루프 방지 + 메모리 상한 관리`를 기본 게이트로 강제한다. @@ -28,7 +28,7 @@ 6. 배포 산출물은 로컬+프로덕션 구성(runbook/env)까지 포함한다. 7. 운영원칙은 플랜 우선/PROJECT.md 주기 점검/Playwright MCP 테스트/Docker 필수다. 8. 인증원칙은 최초 로그인, 미인증 차단, 세션 만료 즉시 리다이렉트다. -9. `/sign/[recipientToken]`는 로그인 필수 + 토큰 소유권 검증이다. +9. `/sign/[contractId]`는 로그인 필수 + 토큰 소유권 검증이다. 10. 프론트 품질 스킬은 `frontend-design + ui-ux-pro-max`를 강제 적용한다. 11. 백엔드 CRUD/조회 기본 구현은 `JPA Repository`로 통일한다. 12. 각 프레임워크 생성은 수동 파일 작성 금지, 공식 설치/생성 명령어 사용을 강제한다. @@ -85,14 +85,14 @@ ## 6. 공용 API/인터페이스/타입 계약 (변경/추가 포함) 1. 응답 계약: `ApiSuccess`, `ApiError`. -2. 인증 계약: `/auth/login`, `/auth/refresh`, `/auth/logout`, `/auth/me`, `/auth/set-password`. +2. 인증 계약: `/auth/login`, `/auth/refresh`, `/auth/logout`, `/auth/me`, `/auth/first-password`. 3. 인증 컨텍스트: `AuthContext { userId, tenantId, projectRoles }`. 4. 상태 enum 단일 소스: 백엔드 enum + DB 제약 + OpenAPI + 프론트 공유타입 일치. 5. SSE 타입: `notification.created`, `notification.read`, `system.ping`. 6. 파일 주석 좌표: 정규화 좌표 스키마(해상도 독립). 7. 옵션 인터페이스: `GoogleCalendarProvider`, `EmailNotificationSender` + 기본 `FEATURE_DISABLED`. 8. 로그인 선행 유틸: `next` 복귀 포함 공통 라우트 가드. -9. 서명 계약: `/sign/[recipientToken]`는 인증 후 토큰 소유권 검증 API 필수. +9. 서명 계약: `/sign/[contractId]`는 인증 후 토큰 소유권 검증 API 필수. 10. UI/UX 품질 계약: 릴리즈 산출물에 `UI/UX Quality Gate` 리포트 필수. 11. 영속성 구현 계약: 도메인 엔티티는 JPA 매핑 기준, 저장소는 `JpaRepository` 기준으로 통일. 12. `ref` 참조 계약: UI 표현 규칙만 사용하고 도메인 타입/상태/비즈니스 흐름 계약의 근거로 사용하지 않는다. @@ -100,11 +100,11 @@ ## 7. REST API 구현 범위 (전부) 1. AUTH -`POST /api/auth/login`, `POST /api/auth/refresh`, `POST /api/auth/logout`, `GET /api/auth/me`, `POST /api/auth/set-password` +`POST /api/auth/login`, `POST /api/auth/refresh`, `POST /api/auth/logout`, `GET /api/auth/me`, `POST /api/auth/first-password` 2. ADMIN `POST /api/admin/tenants`, `GET /api/admin/tenants`, `GET /api/admin/tenants/{tenantId}`, `POST /api/admin/tenants/{tenantId}/pm-users`, `GET /api/admin/tenants/{tenantId}/pm-users`, `PATCH /api/admin/users/{userId}/status` 3. PROJECTS -`GET /api/projects`, `POST /api/projects`, `GET /api/projects/{projectId}`, `PATCH /api/projects/{projectId}`, `GET /api/projects/{projectId}/members`, `POST /api/projects/{projectId}/members/invite`, `POST /api/invitations/{invitationToken}/accept` +`GET /api/projects`, `POST /api/projects`, `GET /api/projects/{projectId}`, `PATCH /api/projects/{projectId}`, `GET /api/projects/{projectId}/members`, `POST /api/projects/{projectId}/members/invite`, `POST /api/projects/{projectId}/members/{memberId}/setup-code/reset` 4. POSTS `GET /api/projects/{projectId}/posts`, `POST /api/projects/{projectId}/posts`, `GET /api/posts/{postId}`, `PATCH /api/posts/{postId}`, `DELETE /api/posts/{postId}`, `GET /api/posts/{postId}/comments`, `POST /api/posts/{postId}/comments` 5. REQUESTS @@ -120,7 +120,7 @@ 10. CONTRACTS & E-SIGN `GET /api/projects/{projectId}/contracts`, `POST /api/projects/{projectId}/contracts`, `POST /api/contracts/{contractId}/envelopes`, `POST /api/envelopes/{envelopeId}/recipients`, `POST /api/envelopes/{envelopeId}/fields`, `POST /api/envelopes/{envelopeId}/send`, `GET /api/envelopes/{envelopeId}`, `GET /api/envelopes/{envelopeId}/events`, `POST /api/envelopes/{envelopeId}/void` 11. SIGNING -`GET /api/signing/{recipientToken}`, `POST /api/signing/{recipientToken}/viewed`, `POST /api/signing/{recipientToken}/submit` +`GET /api/signing/contracts/{contractId}`, `POST /api/signing/contracts/{contractId}/viewed`, `POST /api/signing/contracts/{contractId}/submit` 12. BILLING `GET /api/projects/{projectId}/invoices`, `POST /api/projects/{projectId}/invoices`, `PATCH /api/invoices/{invoiceId}/status`, `POST /api/invoices/{invoiceId}/attachments/presign`, `POST /api/invoices/{invoiceId}/attachments/complete` 13. VAULT @@ -135,9 +135,9 @@ 2. pm-web `/login`, `/pm/projects`, `/pm/projects/new`, `/pm/projects/[projectId]/dashboard`, `/pm/projects/[projectId]/posts`, `/pm/projects/[projectId]/requests`, `/pm/projects/[projectId]/decisions`, `/pm/projects/[projectId]/files`, `/pm/projects/[projectId]/meetings`, `/pm/projects/[projectId]/contracts`, `/pm/projects/[projectId]/billing`, `/pm/projects/[projectId]/vault`, `/pm/projects/[projectId]/settings/members`, `/pm/profile/integrations/google` 3. client-web -`/login`, `/client/projects`, `/client/projects/[projectId]/home`, `/client/projects/[projectId]/requests`, `/client/projects/[projectId]/posts`, `/client/projects/[projectId]/files`, `/client/projects/[projectId]/meetings`, `/client/projects/[projectId]/contracts`, `/client/projects/[projectId]/billing`, `/client/projects/[projectId]/vault`, `/sign/[recipientToken]` +`/login`, `/client/projects`, `/client/projects/[projectId]/home`, `/client/projects/[projectId]/requests`, `/client/projects/[projectId]/posts`, `/client/projects/[projectId]/files`, `/client/projects/[projectId]/meetings`, `/client/projects/[projectId]/contracts`, `/client/projects/[projectId]/billing`, `/client/projects/[projectId]/vault`, `/sign/[contractId]` 4. 인증 규칙 -최초 진입 로그인, 미인증 보호경로 즉시 `/login?next=...`, 로그인 후 원경로 복귀, `/sign/[recipientToken]` 동일 적용. +최초 진입 로그인, 미인증 보호경로 즉시 `/login?next=...`, 로그인 후 원경로 복귀, `/sign/[contractId]` 동일 적용. ## 9. 도메인 구현 범위 (누락 금지) @@ -196,7 +196,7 @@ Posts/Requests/Decisions/Files/Meetings 완성. 6. Phase 5 Billing & Vault 인보이스/증빙, Vault 암호화/정책강제/reveal 이벤트 완성. 7. Phase 6 프론트 3앱 -인증/권한가드/토큰갱신, 프로젝트 룸 UI, SSE 알림, 로그인 선행 `/sign/[recipientToken]`, UX/UI 게이트, 루프/메모리 안정성 게이트. +인증/권한가드/토큰갱신, 프로젝트 룸 UI, SSE 알림, 로그인 선행 `/sign/[contractId]`, UX/UI 게이트, 루프/메모리 안정성 게이트. 8. Phase 7 하드닝/문서화 OpenAPI, 보안 헤더/CORS/CSP, 시드, README E2E 1~9, UI/UX 게이트 최종 통과, DoD 100%. @@ -272,7 +272,7 @@ MCP 검증 시 `ref` 화면을 디자인 벤치마크로 비교하되 기능 동 6. Playwright MCP가 Phase 0에서 미인식이면 구현 착수 보류. 7. 로그인 없이는 서비스 사용 불가, 예외는 계정 부팅용 인증 엔드포인트로 한정. 8. 영속성 기본은 Spring Data JPA(Hibernate)이며, 네이티브 SQL은 제한적 예외로만 허용. -9. `PROJECT.md` 내 `/sign/[recipientToken]` 무인증 가능 문구가 있더라도 본 계획의 인증 강제 규칙을 우선 적용한다. +9. `PROJECT.md` 내 `/sign/[contractId]` 무인증 가능 문구가 있더라도 본 계획의 인증 강제 규칙을 우선 적용한다. 10. 메모리 프로파일 기본은 `보수형`으로 고정한다. 11. `ref`는 디자인 참조 전용이며 기능 명세 출처가 아니다. 12. 프로젝트 프레임워크 초기화는 반드시 프레임워크 공식 설치/생성 명령어로 수행한다. @@ -298,3 +298,5 @@ MCP 검증 시 `ref` 화면을 디자인 벤치마크로 비교하되 기능 동 `useEffect` cleanup 강제, SSE 단일 연결 보장, 무한 polling 금지, 상태 업데이트는 조건 가드 후 수행. 7. 루프/메모리 실패 처리 규칙 경고 발견 시 기능 개발을 중단하고 재현 스크립트 작성 후 수정, 재검증 통과 전 병합 금지. + + diff --git a/docs/Plan/PLAN_PROGRESS.md b/docs/Plan/PLAN_PROGRESS.md index b71ea56..142e41c 100644 --- a/docs/Plan/PLAN_PROGRESS.md +++ b/docs/Plan/PLAN_PROGRESS.md @@ -10,7 +10,7 @@ - 모노레포: `pnpm + Turborepo` - Docker 필수: `postgres`, `minio`, `mailhog` - 인증 전면 강제: 최초 진입 로그인, 미인증 차단, 세션 만료 리다이렉트 -- `/sign/[recipientToken]` 로그인 필수 + 토큰 소유권 검증 필수 +- `/sign/[contractId]` 로그인 필수 + 토큰 소유권 검증 필수 - 백엔드 영속성: `Spring Data JPA(Hibernate)` 강제 - 무한루프/메모리 폭주 방지: 보수형 메모리 프로파일 고정 - 프레임워크 생성: 수동 뼈대 금지, 공식 설치/스캐폴드 명령만 사용 @@ -43,7 +43,7 @@ ### 1.5 프론트 요구(요약) - admin/pm/client 라우트 전량 구현 - 로그인 선행, `next` 복귀 -- `/sign/[recipientToken]` 로그인 후 소유권 검증 +- `/sign/[contractId]` 로그인 후 소유권 검증 - 디자인 기준: `frontend-design + ui-ux-pro-max`, `ref`는 디자인만 참고 ### 1.6 테스트/완료 기준(요약) @@ -121,10 +121,10 @@ - `admin-web` 운영 라우트 전량 생성 (`/admin/login`, `/admin/tenants`, `/admin/tenants/new`, `/admin/tenants/[tenantId]`, `/admin/tenants/[tenantId]/pm-users`, `/admin/users/[userId]`) - `client-web` 라우트 전량 생성 - (`/login`, `/client/projects`, `/client/projects/[projectId]/home/requests/posts/files/meetings/contracts/billing/vault`, `/sign/[recipientToken]`) + (`/login`, `/client/projects`, `/client/projects/[projectId]/home/requests/posts/files/meetings/contracts/billing/vault`, `/sign/[contractId]`) - 앱별 로그인 가드(`middleware.ts`) + `next` 복귀 흐름 추가 - 앱별 인증 유틸(`lib/auth.ts`) + Bearer 헤더 기반 API 유틸(`lib/api.ts`) 추가 - - `/sign/[recipientToken]` 페이지에서 `GET /api/signing/{recipientToken}` 호출로 소유권 검증 결과를 표시하고, `viewed/submit` 액션 버튼 연결 + - `/sign/[contractId]` 페이지에서 `GET /api/signing/contracts/{contractId}` 호출로 소유권 검증 결과를 표시하고, `viewed/submit` 액션 버튼 연결 - `pm-web`, `admin-web`, `client-web` lint 통과 - 미완료/잔여 - 없음 (DoD 1~9 실증 완료, 배포 파이프라인 구현 완료) @@ -148,7 +148,7 @@ - [x] admin-web 라우트 전량 - [x] pm-web 라우트 전량 - [x] client-web 라우트 전량 -- [x] `/sign/[recipientToken]` 로그인 강제 + 소유권 검증 UI 흐름 +- [x] `/sign/[contractId]` 로그인 강제 + 소유권 검증 UI 흐름 - [x] Playwright MCP E2E - [x] UI/UX Quality Gate(Phase 6/7) - [x] 최종 DoD 데모 시나리오 1~9 @@ -364,7 +364,7 @@ ### 11.2 Playwright MCP E2E 수행 - PM/Client/Admin 보호 라우트 로그인 강제 + 로그인 후 복귀 확인 -- `/sign/[recipientToken]` 토큰 검증 동작 확인 +- `/sign/[contractId]` 토큰 검증 동작 확인 - 근거 문서: - `docs/Test/PLAYWRIGHT_MCP_E2E.md` @@ -409,3 +409,4 @@ ### 12.3 현재 잔여 - 기능 구현 잔여 없음 - 운영 시크릿 주입 및 실배포 검증만 남음 + diff --git a/docs/Plan/PROJECT.md b/docs/Plan/PROJECT.md index d6aacfa..442376c 100644 --- a/docs/Plan/PROJECT.md +++ b/docs/Plan/PROJECT.md @@ -721,27 +721,34 @@ TABLE notification_preferences (권장) 공통: - Base: /api -- Auth header: Authorization: Bearer +- 인증: HttpOnly 쿠키(bridge_access_token, bridge_refresh_token) - 성공 응답: { "data": ..., "meta": ... } - 에러 응답: { "error": { "code": "...", "message": "...", "details": {...} } } AUTH POST /api/auth/login - req { email, password } - resp { data: { accessToken, refreshToken, user } } + req { email, password, tenantSlug? } + resp { data: { userId, tenantId, roles, ... } } + Set-Cookie POST /api/auth/refresh + req body 없음 (쿠키 기반) + resp { data: { refreshed: true } } + Set-Cookie 회전 + POST /api/auth/logout + req body 없음 (쿠키 기반) + resp { data: { loggedOut: true } } + 쿠키 만료 + GET /api/auth/me -POST /api/auth/set-password # 초대 수락/최초 비번설정: inviteToken + password + name +POST /api/auth/first-password # 최초 비번설정: email + setupCode + newPassword ADMIN (PLATFORM_ADMIN) POST /api/admin/tenants GET /api/admin/tenants GET /api/admin/tenants/{tenantId} -POST /api/admin/tenants/{tenantId}/pm-users # PM 계정 생성(초대메일) +POST /api/admin/tenants/{tenantId}/pm-users # PM 계정 생성 GET /api/admin/tenants/{tenantId}/pm-users PATCH /api/admin/users/{userId}/status +POST /api/admin/users/{userId}/unlock-login PROJECTS (멤버십 기반) GET /api/projects @@ -749,8 +756,8 @@ POST /api/projects # PM_OWNER/PM_MEMBER GET /api/projects/{projectId} PATCH /api/projects/{projectId} # PM_OWNER GET /api/projects/{projectId}/members -POST /api/projects/{projectId}/members/invite # PM이 클라이언트 초대 -POST /api/invitations/{invitationToken}/accept +POST /api/projects/{projectId}/members/invite # PM이 클라이언트 ID 생성 + setupCode 발급 +POST /api/projects/{projectId}/members/{memberId}/setup-code/reset POSTS GET /api/projects/{projectId}/posts @@ -777,7 +784,7 @@ FILES (Presigned) GET /api/projects/{projectId}/files?folder=/design POST /api/projects/{projectId}/files POST /api/files/{fileId}/versions/presign -POST /api/files/{fileId}/versions/complete +POST /api/files/{fileId}/versions/complete # presign 응답의 uploadTicket 필수 GET /api/file-versions/{fileVersionId}/download-url POST /api/file-versions/{fileVersionId}/comments PATCH /api/file-comments/{commentId}/resolve @@ -808,9 +815,9 @@ GET /api/envelopes/{envelopeId}/events POST /api/envelopes/{envelopeId}/void SIGNING (토큰 기반) -GET /api/signing/{recipientToken} # 서명 페이지 데이터 + 원본 PDF URL -POST /api/signing/{recipientToken}/viewed # VIEWED 이벤트 + 알림 -POST /api/signing/{recipientToken}/submit # SIGNED(+완료시 COMPLETED) + 완료본 생성/저장 + 알림 +GET /api/signing/contracts/{contractId} # 서명 페이지 데이터 + 원본 PDF URL +POST /api/signing/contracts/{contractId}/viewed # VIEWED 이벤트 + 알림 +POST /api/signing/contracts/{contractId}/submit # SIGNED(+완료시 COMPLETED) + 완료본 생성/저장 + 알림 BILLING (상태관리만) GET /api/projects/{projectId}/invoices @@ -890,7 +897,7 @@ Routes: - /client/projects/[projectId]/contracts - /client/projects/[projectId]/billing - /client/projects/[projectId]/vault # 권한 있을 때만 -- /sign/[recipientToken] # 토큰 기반 서명 페이지(로그인 없이 가능) +- /sign/[contractId] # 토큰 기반 서명 페이지(로그인 없이 가능) Key Components: - ClientProjectShell - MyTasks(Requests) 중심 UI @@ -994,7 +1001,8 @@ README.md 반드시 포함: - 프로젝트 룸 탭/각 모듈 화면 - 파일 업로드/버전/주석 - 회의 캘린더/응답 - - 서명 페이지(/sign/[token]) 구현 + - 서명 페이지(/sign/[contractId]) 구현 - 알림센터 + SSE 구독 5) 로컬 실행 방법/데모 시나리오를 README로 마무리하라. 6) 요구사항을 “생략”하거나 “나중에”라고 하지 말고, 구현 가능한 범위로 전부 반영하라. + diff --git a/docs/Test/PLAYWRIGHT_MCP_E2E.md b/docs/Test/PLAYWRIGHT_MCP_E2E.md index d1c1eee..82015ae 100644 --- a/docs/Test/PLAYWRIGHT_MCP_E2E.md +++ b/docs/Test/PLAYWRIGHT_MCP_E2E.md @@ -39,9 +39,9 @@ - 결과: `/admin/tenants` 진입, 테넌트 목록 렌더링 확인 - 상태: PASS -7. 서명 경로 토큰 검증 동작 - - 접근: `/sign/test-token` - - 결과: `SIGNING_TOKEN_NOT_FOUND` 응답 노출 확인 +7. 서명 경로 계약 접근 검증 동작 + - 접근: `/sign/test-contract-id` + - 결과: 인증/권한 검증 또는 계약 미존재 에러 응답 확인 - 상태: PASS ## 메모 diff --git a/scripts/run-dod-demo.ps1 b/scripts/run-dod-demo.ps1 index 9423f7c..c97492f 100644 --- a/scripts/run-dod-demo.ps1 +++ b/scripts/run-dod-demo.ps1 @@ -6,9 +6,10 @@ param( $ErrorActionPreference = "Stop" $script:RequestLog = @() $script:SensitiveKeys = @( - "accessToken", - "refreshToken", "password", + "newPassword", + "setupCode", + "uploadTicket", "plainSecret", "secret", "secretCiphertext", @@ -27,11 +28,7 @@ function Sanitize-BridgeLogValue { foreach ($key in $Value.Keys) { $textKey = [string]$key if ($textKey -in $script:SensitiveKeys) { - if ($textKey -in @("accessToken", "refreshToken")) { - $safe[$textKey] = "__REDACTED_TOKEN__" - } else { - $safe[$textKey] = "__REDACTED_SECRET__" - } + $safe[$textKey] = "__REDACTED__" continue } $safe[$textKey] = Sanitize-BridgeLogValue -Value $Value[$key] @@ -44,11 +41,7 @@ function Sanitize-BridgeLogValue { foreach ($property in $Value.PSObject.Properties) { $textKey = [string]$property.Name if ($textKey -in $script:SensitiveKeys) { - if ($textKey -in @("accessToken", "refreshToken")) { - $safe[$textKey] = "__REDACTED_TOKEN__" - } else { - $safe[$textKey] = "__REDACTED_SECRET__" - } + $safe[$textKey] = "__REDACTED__" continue } $safe[$textKey] = Sanitize-BridgeLogValue -Value $property.Value @@ -69,16 +62,13 @@ function Invoke-BridgeApi { [string]$Method, [string]$Path, [object]$Body = $null, - [string]$AccessToken = $null + [Microsoft.PowerShell.Commands.WebRequestSession]$Session ) $uri = "$BaseUrl$Path" $headers = @{ "Accept" = "application/json" } - if ($AccessToken) { - $headers["Authorization"] = "Bearer $AccessToken" - } $bodyJson = $null if ($null -ne $Body) { @@ -88,9 +78,17 @@ function Invoke-BridgeApi { try { if ($null -ne $bodyJson) { - $parsed = Invoke-RestMethod -Uri $uri -Method $Method -Headers $headers -Body $bodyJson + if ($Session) { + $parsed = Invoke-RestMethod -Uri $uri -Method $Method -Headers $headers -Body $bodyJson -WebSession $Session + } else { + $parsed = Invoke-RestMethod -Uri $uri -Method $Method -Headers $headers -Body $bodyJson + } } else { - $parsed = Invoke-RestMethod -Uri $uri -Method $Method -Headers $headers + if ($Session) { + $parsed = Invoke-RestMethod -Uri $uri -Method $Method -Headers $headers -WebSession $Session + } else { + $parsed = Invoke-RestMethod -Uri $uri -Method $Method -Headers $headers + } } $safeRequest = Sanitize-BridgeLogValue -Value $Body @@ -147,63 +145,67 @@ $clientEmail = "client+$slugSuffix@bridge.local" $pmPassword = "TempPassword!123" $clientPassword = "Client!12345" +$adminSession = New-Object Microsoft.PowerShell.Commands.WebRequestSession +$pmSession = New-Object Microsoft.PowerShell.Commands.WebRequestSession +$clientSession = New-Object Microsoft.PowerShell.Commands.WebRequestSession + Write-Host "Running DoD scenarios against $BaseUrl" # Scenario 1: admin tenant + PM user -$adminLogin = Invoke-BridgeApi -Step "S1-admin-login" -Method "POST" -Path "/api/auth/login" -Body @{ +$null = Invoke-BridgeApi -Step "S1-admin-login" -Method "POST" -Path "/api/auth/login" -Session $adminSession -Body @{ email = "admin@bridge.local" password = "password" tenantSlug = "bridge" } -$adminToken = $adminLogin.data.accessToken -$tenant = Invoke-BridgeApi -Step "S1-create-tenant" -Method "POST" -Path "/api/admin/tenants" -AccessToken $adminToken -Body @{ +$tenant = Invoke-BridgeApi -Step "S1-create-tenant" -Method "POST" -Path "/api/admin/tenants" -Session $adminSession -Body @{ name = $tenantName slug = $tenantSlug } $tenantId = $tenant.data.id -$pmUser = Invoke-BridgeApi -Step "S1-create-pm-user" -Method "POST" -Path "/api/admin/tenants/$tenantId/pm-users" -AccessToken $adminToken -Body @{ +$pmUser = Invoke-BridgeApi -Step "S1-create-pm-user" -Method "POST" -Path "/api/admin/tenants/$tenantId/pm-users" -Session $adminSession -Body @{ email = $pmEmail name = "DoD PM $timestamp" } $pmUserId = $pmUser.data.userId # Scenario 2: pm project create + invite client -$pmLogin = Invoke-BridgeApi -Step "S2-pm-login" -Method "POST" -Path "/api/auth/login" -Body @{ +$null = Invoke-BridgeApi -Step "S2-pm-login" -Method "POST" -Path "/api/auth/login" -Session $pmSession -Body @{ email = $pmEmail password = $pmPassword tenantSlug = $tenantSlug } -$pmToken = $pmLogin.data.accessToken -$project = Invoke-BridgeApi -Step "S2-create-project" -Method "POST" -Path "/api/projects" -AccessToken $pmToken -Body @{ +$project = Invoke-BridgeApi -Step "S2-create-project" -Method "POST" -Path "/api/projects" -Session $pmSession -Body @{ name = "DoD Project $timestamp" description = "DoD scenario execution" } $projectId = $project.data.id -$invite = Invoke-BridgeApi -Step "S2-invite-client" -Method "POST" -Path "/api/projects/$projectId/members/invite" -AccessToken $pmToken -Body @{ - email = $clientEmail - role = "CLIENT_OWNER" - loginId = $clientEmail - password = $clientPassword - name = "DoD Client $timestamp" +$invite = Invoke-BridgeApi -Step "S2-invite-client" -Method "POST" -Path "/api/projects/$projectId/members/invite" -Session $pmSession -Body @{ + role = "CLIENT_OWNER" + loginId = $clientEmail + name = "DoD Client $timestamp" +} +$clientMemberId = $invite.data.id +$clientSetupCode = $invite.data.setupCode + +# Scenario 3: client first-password + login +$firstPassword = Invoke-BridgeApi -Step "S3-first-password" -Method "POST" -Path "/api/auth/first-password" -Body @{ + email = $clientEmail + setupCode = $clientSetupCode + newPassword = $clientPassword } -$invitationToken = $invite.data.invitationToken -# Scenario 3: client accept invitation -$clientLogin = Invoke-BridgeApi -Step "S3-client-login" -Method "POST" -Path "/api/auth/login" -Body @{ +$null = Invoke-BridgeApi -Step "S3-client-login" -Method "POST" -Path "/api/auth/login" -Session $clientSession -Body @{ email = $clientEmail password = $clientPassword tenantSlug = $tenantSlug } -$clientToken = $clientLogin.data.accessToken - -$acceptInvitation = Invoke-BridgeApi -Step "S3-accept-invitation" -Method "POST" -Path "/api/invitations/$invitationToken/accept" -AccessToken $clientToken # Scenario 4: post/request/decision -$post = Invoke-BridgeApi -Step "S4-create-post" -Method "POST" -Path "/api/projects/$projectId/posts" -AccessToken $pmToken -Body @{ +$post = Invoke-BridgeApi -Step "S4-create-post" -Method "POST" -Path "/api/projects/$projectId/posts" -Session $pmSession -Body @{ type = "GENERAL" title = "DoD Post $timestamp" body = "post body" @@ -211,45 +213,52 @@ $post = Invoke-BridgeApi -Step "S4-create-post" -Method "POST" -Path "/api/proje } $postId = $post.data.id -$request = Invoke-BridgeApi -Step "S4-create-request" -Method "POST" -Path "/api/projects/$projectId/requests" -AccessToken $clientToken -Body @{ +$request = Invoke-BridgeApi -Step "S4-create-request" -Method "POST" -Path "/api/projects/$projectId/requests" -Session $clientSession -Body @{ type = "FEEDBACK" title = "DoD Request $timestamp" description = "request description" } $requestId = $request.data.id -$requestStatus = Invoke-BridgeApi -Step "S4-update-request-status" -Method "PATCH" -Path "/api/requests/$requestId/status" -AccessToken $pmToken -Body @{ +$requestStatus = Invoke-BridgeApi -Step "S4-update-request-status" -Method "PATCH" -Path "/api/requests/$requestId/status" -Session $pmSession -Body @{ status = "IN_PROGRESS" } -$decision = Invoke-BridgeApi -Step "S4-create-decision" -Method "POST" -Path "/api/projects/$projectId/decisions" -AccessToken $pmToken -Body @{ +$decision = Invoke-BridgeApi -Step "S4-create-decision" -Method "POST" -Path "/api/projects/$projectId/decisions" -Session $pmSession -Body @{ title = "DoD Decision $timestamp" rationale = "decision rationale" } $decisionId = $decision.data.id -$decisionStatus = Invoke-BridgeApi -Step "S4-update-decision-status" -Method "PATCH" -Path "/api/decisions/$decisionId/status" -AccessToken $pmToken -Body @{ +$decisionStatus = Invoke-BridgeApi -Step "S4-update-decision-status" -Method "PATCH" -Path "/api/decisions/$decisionId/status" -Session $pmSession -Body @{ status = "APPROVED" } # Scenario 5: file/version/comment -$file = Invoke-BridgeApi -Step "S5-create-file" -Method "POST" -Path "/api/projects/$projectId/files" -AccessToken $pmToken -Body @{ +$file = Invoke-BridgeApi -Step "S5-create-file" -Method "POST" -Path "/api/projects/$projectId/files" -Session $pmSession -Body @{ name = "dod-spec-$slugSuffix.pdf" description = "DoD file" folder = "/docs" } $fileId = $file.data.id -$fileVersion = Invoke-BridgeApi -Step "S5-complete-version" -Method "POST" -Path "/api/files/$fileId/versions/complete" -AccessToken $pmToken -Body @{ - version = 1 - objectKey = "dod/$projectId/spec-v1.pdf" +$presign = Invoke-BridgeApi -Step "S5-presign-version" -Method "POST" -Path "/api/files/$fileId/versions/presign" -Session $pmSession -Body @{ contentType = "application/pdf" size = 1024 checksum = "sha256-$slugSuffix" } + +$fileVersion = Invoke-BridgeApi -Step "S5-complete-version" -Method "POST" -Path "/api/files/$fileId/versions/complete" -Session $pmSession -Body @{ + version = $presign.data.version + objectKey = $presign.data.objectKey + contentType = $presign.data.contentType + size = $presign.data.size + checksum = $presign.data.checksum + uploadTicket = $presign.data.uploadTicket +} $fileVersionId = $fileVersion.data.id -$fileComment = Invoke-BridgeApi -Step "S5-comment-file-version" -Method "POST" -Path "/api/file-versions/$fileVersionId/comments" -AccessToken $clientToken -Body @{ +$fileComment = Invoke-BridgeApi -Step "S5-comment-file-version" -Method "POST" -Path "/api/file-versions/$fileVersionId/comments" -Session $clientSession -Body @{ body = "please update title block" coordX = 10 coordY = 10 @@ -258,13 +267,13 @@ $fileComment = Invoke-BridgeApi -Step "S5-comment-file-version" -Method "POST" - } $fileCommentId = $fileComment.data.id -$resolvedComment = Invoke-BridgeApi -Step "S5-resolve-file-comment" -Method "PATCH" -Path "/api/file-comments/$fileCommentId/resolve" -AccessToken $pmToken +$resolvedComment = Invoke-BridgeApi -Step "S5-resolve-file-comment" -Method "PATCH" -Path "/api/file-comments/$fileCommentId/resolve" -Session $pmSession # Scenario 6: meeting + client response $startAt = (Get-Date).AddDays(1).ToUniversalTime().ToString("o") $endAt = (Get-Date).AddDays(1).AddHours(1).ToUniversalTime().ToString("o") -$meeting = Invoke-BridgeApi -Step "S6-create-meeting" -Method "POST" -Path "/api/projects/$projectId/meetings" -AccessToken $pmToken -Body @{ +$meeting = Invoke-BridgeApi -Step "S6-create-meeting" -Method "POST" -Path "/api/projects/$projectId/meetings" -Session $pmSession -Body @{ title = "DoD Weekly Sync" startAt = $startAt endAt = $endAt @@ -272,31 +281,30 @@ $meeting = Invoke-BridgeApi -Step "S6-create-meeting" -Method "POST" -Path "/api } $meetingId = $meeting.data.id -$meetingResponse = Invoke-BridgeApi -Step "S6-client-respond-meeting" -Method "POST" -Path "/api/meetings/$meetingId/respond" -AccessToken $clientToken -Body @{ +$meetingResponse = Invoke-BridgeApi -Step "S6-client-respond-meeting" -Method "POST" -Path "/api/meetings/$meetingId/respond" -Session $clientSession -Body @{ response = "ACCEPTED" } # Scenario 7: contract + signing -$contract = Invoke-BridgeApi -Step "S7-create-contract" -Method "POST" -Path "/api/projects/$projectId/contracts" -AccessToken $pmToken -Body @{ +$contract = Invoke-BridgeApi -Step "S7-create-contract" -Method "POST" -Path "/api/projects/$projectId/contracts" -Session $pmSession -Body @{ name = "DoD Contract $timestamp" fileVersionId = $fileVersionId } $contractId = $contract.data.id -$envelope = Invoke-BridgeApi -Step "S7-create-envelope" -Method "POST" -Path "/api/contracts/$contractId/envelopes" -AccessToken $pmToken -Body @{ +$envelope = Invoke-BridgeApi -Step "S7-create-envelope" -Method "POST" -Path "/api/contracts/$contractId/envelopes" -Session $pmSession -Body @{ title = "DoD Sign Envelope" } $envelopeId = $envelope.data.id -$recipient = Invoke-BridgeApi -Step "S7-add-recipient" -Method "POST" -Path "/api/envelopes/$envelopeId/recipients" -AccessToken $pmToken -Body @{ +$recipient = Invoke-BridgeApi -Step "S7-add-recipient" -Method "POST" -Path "/api/envelopes/$envelopeId/recipients" -Session $pmSession -Body @{ name = "DoD Client Signer" email = $clientEmail signingOrder = 1 } $recipientId = $recipient.data.id -$recipientToken = $recipient.data.recipientToken -$signatureField = Invoke-BridgeApi -Step "S7-add-signature-field" -Method "POST" -Path "/api/envelopes/$envelopeId/fields" -AccessToken $pmToken -Body @{ +$signatureField = Invoke-BridgeApi -Step "S7-add-signature-field" -Method "POST" -Path "/api/envelopes/$envelopeId/fields" -Session $pmSession -Body @{ recipientId = $recipientId type = "SIGNATURE" page = 1 @@ -306,13 +314,13 @@ $signatureField = Invoke-BridgeApi -Step "S7-add-signature-field" -Method "POST" coordH = 40 } -$sentEnvelope = Invoke-BridgeApi -Step "S7-send-envelope" -Method "POST" -Path "/api/envelopes/$envelopeId/send" -AccessToken $pmToken -$viewedEnvelope = Invoke-BridgeApi -Step "S7-view-signing" -Method "POST" -Path "/api/signing/$recipientToken/viewed" -AccessToken $clientToken -$submitSigning = Invoke-BridgeApi -Step "S7-submit-signing" -Method "POST" -Path "/api/signing/$recipientToken/submit" -AccessToken $clientToken -$signatureEvents = Invoke-BridgeApi -Step "S7-signature-events" -Method "GET" -Path "/api/envelopes/$envelopeId/events" -AccessToken $pmToken +$null = Invoke-BridgeApi -Step "S7-send-envelope" -Method "POST" -Path "/api/envelopes/$envelopeId/send" -Session $pmSession +$null = Invoke-BridgeApi -Step "S7-view-signing" -Method "POST" -Path "/api/signing/contracts/$contractId/viewed" -Session $clientSession +$submitSigning = Invoke-BridgeApi -Step "S7-submit-signing" -Method "POST" -Path "/api/signing/contracts/$contractId/submit" -Session $clientSession -Body @{} +$signatureEvents = Invoke-BridgeApi -Step "S7-signature-events" -Method "GET" -Path "/api/envelopes/$envelopeId/events" -Session $pmSession # Scenario 8: billing -$invoice = Invoke-BridgeApi -Step "S8-create-invoice" -Method "POST" -Path "/api/projects/$projectId/invoices" -AccessToken $pmToken -Body @{ +$invoice = Invoke-BridgeApi -Step "S8-create-invoice" -Method "POST" -Path "/api/projects/$projectId/invoices" -Session $pmSession -Body @{ invoiceNumber = "INV-$slugSuffix" amount = 1000000 currency = "KRW" @@ -321,24 +329,24 @@ $invoice = Invoke-BridgeApi -Step "S8-create-invoice" -Method "POST" -Path "/api } $invoiceId = $invoice.data.id -$invoiceStatus = Invoke-BridgeApi -Step "S8-confirm-invoice" -Method "PATCH" -Path "/api/invoices/$invoiceId/status" -AccessToken $clientToken -Body @{ +$invoiceStatus = Invoke-BridgeApi -Step "S8-confirm-invoice" -Method "PATCH" -Path "/api/invoices/$invoiceId/status" -Session $clientSession -Body @{ status = "CONFIRMED" } -$invoiceAttachment = Invoke-BridgeApi -Step "S8-add-attachment" -Method "POST" -Path "/api/invoices/$invoiceId/attachments/complete" -AccessToken $clientToken -Body @{ +$null = Invoke-BridgeApi -Step "S8-add-attachment" -Method "POST" -Path "/api/invoices/$invoiceId/attachments/complete" -Session $clientSession -Body @{ attachmentType = "PROOF" objectKey = "dod/$projectId/invoice-proof.pdf" } -$invoiceAttachments = Invoke-BridgeApi -Step "S8-list-attachments" -Method "GET" -Path "/api/invoices/$invoiceId/attachments" -AccessToken $pmToken +$invoiceAttachments = Invoke-BridgeApi -Step "S8-list-attachments" -Method "GET" -Path "/api/invoices/$invoiceId/attachments" -Session $pmSession # Scenario 9: vault -$policy = Invoke-BridgeApi -Step "S9-create-policy" -Method "POST" -Path "/api/projects/$projectId/vault/policies" -AccessToken $pmToken -Body @{ +$policy = Invoke-BridgeApi -Step "S9-create-policy" -Method "POST" -Path "/api/projects/$projectId/vault/policies" -Session $pmSession -Body @{ name = "DoD Vault Policy" ruleJson = '{"allow":["REQUEST","REVEAL"]}' } $policyId = $policy.data.id -$secret = Invoke-BridgeApi -Step "S9-create-secret" -Method "POST" -Path "/api/projects/$projectId/vault/secrets" -AccessToken $pmToken -Body @{ +$secret = Invoke-BridgeApi -Step "S9-create-secret" -Method "POST" -Path "/api/projects/$projectId/vault/secrets" -Session $pmSession -Body @{ name = "DoD Production DB" type = "DB" plainSecret = "postgres://db-user:db-pass@db.internal:5432/bridge" @@ -347,19 +355,19 @@ $secret = Invoke-BridgeApi -Step "S9-create-secret" -Method "POST" -Path "/api/p } $secretId = $secret.data.id -$accessRequest = Invoke-BridgeApi -Step "S9-request-access" -Method "POST" -Path "/api/vault/secrets/$secretId/access-requests" -AccessToken $clientToken +$accessRequest = Invoke-BridgeApi -Step "S9-request-access" -Method "POST" -Path "/api/vault/secrets/$secretId/access-requests" -Session $clientSession $accessRequestId = $accessRequest.data.id -$approveRequest = Invoke-BridgeApi -Step "S9-approve-access" -Method "PATCH" -Path "/api/vault/access-requests/$accessRequestId" -AccessToken $pmToken -Body @{ +$approveRequest = Invoke-BridgeApi -Step "S9-approve-access" -Method "PATCH" -Path "/api/vault/access-requests/$accessRequestId" -Session $pmSession -Body @{ status = "APPROVED" } -$revealSecret = Invoke-BridgeApi -Step "S9-reveal-secret" -Method "POST" -Path "/api/vault/secrets/$secretId/reveal" -AccessToken $clientToken +$revealSecret = Invoke-BridgeApi -Step "S9-reveal-secret" -Method "POST" -Path "/api/vault/secrets/$secretId/reveal" -Session $clientSession $scenarios = [ordered]@{ "1" = [ordered]@{ status = "DONE"; tenantId = $tenantId; pmUserId = $pmUserId } - "2" = [ordered]@{ status = "DONE"; projectId = $projectId; invitationToken = $invitationToken } - "3" = [ordered]@{ status = "DONE"; accepted = $acceptInvitation.data.accepted } + "2" = [ordered]@{ status = "DONE"; projectId = $projectId; clientMemberId = $clientMemberId } + "3" = [ordered]@{ status = "DONE"; passwordInitialized = $firstPassword.data.passwordInitialized } "4" = [ordered]@{ status = "DONE"; postId = $postId; requestId = $requestId; requestStatus = $requestStatus.data.status; decisionId = $decisionId; decisionStatus = $decisionStatus.data.status } "5" = [ordered]@{ status = "DONE"; fileId = $fileId; fileVersionId = $fileVersionId; commentId = $fileCommentId; commentStatus = $resolvedComment.data.status } "6" = [ordered]@{ status = "DONE"; meetingId = $meetingId; attendeeResponse = $meetingResponse.data.response } @@ -401,8 +409,8 @@ $mdLines = @( "| Scenario | Status | Evidence |", "|---|---|---|", "| 1 | DONE | tenantId=$tenantId, pmUserId=$pmUserId |", - "| 2 | DONE | projectId=$projectId, invitationToken=$invitationToken |", - "| 3 | DONE | accepted=$($acceptInvitation.data.accepted) |", + "| 2 | DONE | projectId=$projectId, clientMemberId=$clientMemberId |", + "| 3 | DONE | passwordInitialized=$($firstPassword.data.passwordInitialized) |", "| 4 | DONE | postId=$postId, requestId=$requestId, decisionId=$decisionId |", "| 5 | DONE | fileId=$fileId, fileVersionId=$fileVersionId, commentId=$fileCommentId |", "| 6 | DONE | meetingId=$meetingId, response=$($meetingResponse.data.response) |", From e3d33b5f3583482071633038673c5b95b7697898 Mon Sep 17 00:00:00 2001 From: Yonghun Park Date: Fri, 20 Feb 2026 17:31:30 +0900 Subject: [PATCH 2/2] chore: align DoD demo script with current backend flow --- .../evidence/dod-demo-20260220-171959.json | 1086 +++++++++++++++++ .../Test/evidence/dod-demo-20260220-171959.md | 26 + scripts/run-dod-demo.ps1 | 100 +- 3 files changed, 1206 insertions(+), 6 deletions(-) create mode 100644 docs/Test/evidence/dod-demo-20260220-171959.json create mode 100644 docs/Test/evidence/dod-demo-20260220-171959.md diff --git a/docs/Test/evidence/dod-demo-20260220-171959.json b/docs/Test/evidence/dod-demo-20260220-171959.json new file mode 100644 index 0000000..51dfb46 --- /dev/null +++ b/docs/Test/evidence/dod-demo-20260220-171959.json @@ -0,0 +1,1086 @@ +{ + "generatedAt": "2026-02-20T08:20:00.6986047Z", + "baseUrl": "http://localhost:8080", + "tenantSlug": "dod-20260220171959", + "users": { + "admin": "admin@bridge.local", + "pm": "pm+20260220171959@bridge.local", + "client": "client+20260220171959@bridge.local" + }, + "scenarios": { + "1": { + "status": "DONE", + "tenantId": "e7316f5d-dd5a-4bd3-91b9-05927c121975", + "pmUserId": "8fa6a0b6-1946-4906-ac9c-96d8f39607d0" + }, + "2": { + "status": "DONE", + "projectId": "da99c81b-09da-479d-88a5-87d83265f328", + "clientMemberId": "a4ad832c-33bc-484a-98ca-73472ae69dcf" + }, + "3": { + "status": "DONE", + "passwordInitialized": true + }, + "4": { + "status": "DONE", + "postId": "96a8c58f-9617-4cba-b33e-ad2155a27f43", + "requestId": "0cffdc1c-d25c-4466-8988-1170c134df36", + "requestStatus": "SENT", + "decisionId": "58f6ca73-50c4-40b4-bc74-0abd2b10cbfe", + "decisionStatus": "REJECTED" + }, + "5": { + "status": "DONE", + "fileId": "aee1be80-a342-41c1-99fe-040f003c1c8b", + "fileVersionId": "0d1a526d-b762-4a5b-b7cc-c98cb25ab6f0", + "commentId": "d1b90cff-0e66-4cfc-b9ad-1301c44b9786", + "commentStatus": "RESOLVED" + }, + "6": { + "status": "DONE", + "meetingId": "bc383656-3d28-40e0-bc82-57b8eee40033", + "attendeeResponse": "ACCEPTED" + }, + "7": { + "status": "DONE", + "contractId": "906454c2-016c-4e74-8aa2-607bdbb22854", + "envelopeId": "54911ad3-5056-4474-9aa0-271bdd301f09", + "recipientId": "9966e2f7-f28f-4ede-a1a1-4faa86194d08", + "signingCompleted": true + }, + "8": { + "status": "DONE", + "invoiceId": "64f805c5-f4cb-4a26-bab7-32ef7dc9c122", + "invoiceStatus": "CONFIRMED", + "attachmentCount": 1 + }, + "9": { + "status": "DONE", + "policyId": "f90e7e09-9895-4b8c-bdfd-0caaebf5eeae", + "secretId": "5211244f-5acb-4458-8c7d-ef325778ac62", + "accessRequestId": "09ddf478-e209-4207-b4b2-1a16b7367908", + "accessStatus": "APPROVED", + "revealedVersion": 1 + } + }, + "requests": [ + { + "step": "S1-admin-login", + "method": "POST", + "path": "/api/auth/login", + "statusCode": 200, + "request": { + "email": "admin@bridge.local", + "tenantSlug": "bridge", + "password": "__REDACTED__" + }, + "response": { + "success": true, + "data": { + "tenantId": "11111111-1111-1111-1111-111111111111", + "userId": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + "roles": { + "Length": 14 + } + } + } + }, + { + "step": "S1-create-tenant", + "method": "POST", + "path": "/api/admin/tenants", + "statusCode": 200, + "request": { + "slug": "dod-20260220171959", + "name": "DoD Tenant 20260220-171959" + }, + "response": { + "success": true, + "data": { + "id": "e7316f5d-dd5a-4bd3-91b9-05927c121975", + "createdAt": "2026-02-20T08:19:59.8578712Z", + "updatedAt": "2026-02-20T08:19:59.8578712Z", + "createdBy": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + "createdByName": null, + "updatedBy": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + "deletedAt": null, + "name": "DoD Tenant 20260220-171959", + "slug": "dod-20260220171959", + "status": "ACTIVE" + } + } + }, + { + "step": "S1-create-pm-user", + "method": "POST", + "path": "/api/admin/tenants/e7316f5d-dd5a-4bd3-91b9-05927c121975/pm-users", + "statusCode": 200, + "request": { + "name": "DoD PM 20260220-171959", + "email": "pm+20260220171959@bridge.local" + }, + "response": { + "success": true, + "data": { + "userId": "8fa6a0b6-1946-4906-ac9c-96d8f39607d0", + "status": "INVITED", + "email": "pm+20260220171959@bridge.local" + } + } + }, + { + "step": "S2-pm-login", + "method": "POST", + "path": "/api/auth/login", + "statusCode": 200, + "request": { + "email": "pm+20260220171959@bridge.local", + "tenantSlug": "dod-20260220171959", + "password": "__REDACTED__" + }, + "response": { + "success": true, + "data": { + "tenantId": "e7316f5d-dd5a-4bd3-91b9-05927c121975", + "userId": "8fa6a0b6-1946-4906-ac9c-96d8f39607d0", + "roles": { + "Length": 15 + } + } + } + }, + { + "step": "S2-create-project", + "method": "POST", + "path": "/api/projects", + "statusCode": 200, + "request": { + "name": "DoD Project 20260220-171959", + "description": "DoD scenario execution" + }, + "response": { + "success": true, + "data": { + "id": "da99c81b-09da-479d-88a5-87d83265f328", + "createdAt": "2026-02-20T08:19:59.9995236Z", + "updatedAt": "2026-02-20T08:19:59.9995236Z", + "createdBy": "8fa6a0b6-1946-4906-ac9c-96d8f39607d0", + "createdByName": null, + "updatedBy": "8fa6a0b6-1946-4906-ac9c-96d8f39607d0", + "deletedAt": null, + "tenantId": "e7316f5d-dd5a-4bd3-91b9-05927c121975", + "name": "DoD Project 20260220-171959", + "description": "DoD scenario execution", + "status": "ACTIVE" + } + } + }, + { + "step": "S2-invite-client", + "method": "POST", + "path": "/api/projects/da99c81b-09da-479d-88a5-87d83265f328/members/invite", + "statusCode": 200, + "request": { + "loginId": "client+20260220171959@bridge.local", + "name": "DoD Client 20260220-171959", + "role": "CLIENT_OWNER" + }, + "response": { + "success": true, + "data": { + "id": "a4ad832c-33bc-484a-98ca-73472ae69dcf", + "userId": "9e61f7e7-1f8c-4948-bec0-c022b22639b0", + "role": "CLIENT_OWNER", + "loginId": "client+20260220171959@bridge.local", + "passwordMask": "********", + "passwordInitialized": false, + "setupCode": "__REDACTED__", + "setupCodeExpiresAt": "2026-02-21T08:20:00.0630961Z" + } + } + }, + { + "step": "S3-first-password", + "method": "POST", + "path": "/api/auth/first-password", + "statusCode": 200, + "request": { + "email": "client+20260220171959@bridge.local", + "newPassword": "__REDACTED__", + "setupCode": "__REDACTED__" + }, + "response": { + "success": true, + "data": { + "passwordInitialized": true + } + } + }, + { + "step": "S3-client-login", + "method": "POST", + "path": "/api/auth/login", + "statusCode": 200, + "request": { + "email": "client+20260220171959@bridge.local", + "tenantSlug": "dod-20260220171959", + "password": "__REDACTED__" + }, + "response": { + "success": true, + "data": { + "tenantId": "e7316f5d-dd5a-4bd3-91b9-05927c121975", + "userId": "9e61f7e7-1f8c-4948-bec0-c022b22639b0", + "roles": [ + { + "Length": 12 + }, + { + "Length": 19 + } + ] + } + } + }, + { + "step": "S4-create-post", + "method": "POST", + "path": "/api/projects/da99c81b-09da-479d-88a5-87d83265f328/posts", + "statusCode": 200, + "request": { + "title": "DoD Post 20260220-171959", + "body": "post body", + "pinned": false, + "type": "GENERAL" + }, + "response": { + "success": true, + "data": { + "id": "96a8c58f-9617-4cba-b33e-ad2155a27f43", + "createdAt": "2026-02-20T08:20:00.2119578Z", + "updatedAt": "2026-02-20T08:20:00.2119578Z", + "createdBy": "8fa6a0b6-1946-4906-ac9c-96d8f39607d0", + "createdByName": null, + "updatedBy": "8fa6a0b6-1946-4906-ac9c-96d8f39607d0", + "deletedAt": null, + "tenantId": "e7316f5d-dd5a-4bd3-91b9-05927c121975", + "projectId": "da99c81b-09da-479d-88a5-87d83265f328", + "type": "GENERAL", + "title": "DoD Post 20260220-171959", + "body": "post body", + "pinned": false, + "visibilityScope": "SHARED", + "commentCount": 0 + } + } + }, + { + "step": "S4-create-request", + "method": "POST", + "path": "/api/projects/da99c81b-09da-479d-88a5-87d83265f328/requests", + "statusCode": 200, + "request": { + "title": "DoD Request 20260220-171959", + "description": "request description", + "type": "FEEDBACK" + }, + "response": { + "success": true, + "data": { + "id": "0cffdc1c-d25c-4466-8988-1170c134df36", + "createdAt": "2026-02-20T08:20:00.2268119Z", + "updatedAt": "2026-02-20T08:20:00.2268119Z", + "createdBy": "9e61f7e7-1f8c-4948-bec0-c022b22639b0", + "createdByName": null, + "updatedBy": "9e61f7e7-1f8c-4948-bec0-c022b22639b0", + "deletedAt": null, + "tenantId": "e7316f5d-dd5a-4bd3-91b9-05927c121975", + "projectId": "da99c81b-09da-479d-88a5-87d83265f328", + "type": "FEEDBACK", + "title": "DoD Request 20260220-171959", + "description": "request description", + "status": "DRAFT", + "assigneeUserId": null, + "dueAt": null + } + } + }, + { + "step": "S4-update-request-status", + "method": "PATCH", + "path": "/api/requests/0cffdc1c-d25c-4466-8988-1170c134df36/status", + "statusCode": 200, + "request": { + "status": "SENT" + }, + "response": { + "success": true, + "data": { + "id": "0cffdc1c-d25c-4466-8988-1170c134df36", + "createdAt": "2026-02-20T08:20:00.226812Z", + "updatedAt": "2026-02-20T08:20:00.2435837Z", + "createdBy": "9e61f7e7-1f8c-4948-bec0-c022b22639b0", + "createdByName": "DoD Client 20260220-171959", + "updatedBy": "8fa6a0b6-1946-4906-ac9c-96d8f39607d0", + "deletedAt": null, + "tenantId": "e7316f5d-dd5a-4bd3-91b9-05927c121975", + "projectId": "da99c81b-09da-479d-88a5-87d83265f328", + "type": "FEEDBACK", + "title": "DoD Request 20260220-171959", + "description": "request description", + "status": "SENT", + "assigneeUserId": null, + "dueAt": null + } + } + }, + { + "step": "S4-create-decision", + "method": "POST", + "path": "/api/projects/da99c81b-09da-479d-88a5-87d83265f328/decisions", + "statusCode": 200, + "request": { + "rationale": "decision rationale", + "title": "DoD Decision 20260220-171959" + }, + "response": { + "success": true, + "data": { + "id": "58f6ca73-50c4-40b4-bc74-0abd2b10cbfe", + "createdAt": "2026-02-20T08:20:00.2614231Z", + "updatedAt": "2026-02-20T08:20:00.2614231Z", + "createdBy": "8fa6a0b6-1946-4906-ac9c-96d8f39607d0", + "createdByName": null, + "updatedBy": "8fa6a0b6-1946-4906-ac9c-96d8f39607d0", + "deletedAt": null, + "tenantId": "e7316f5d-dd5a-4bd3-91b9-05927c121975", + "projectId": "da99c81b-09da-479d-88a5-87d83265f328", + "title": "DoD Decision 20260220-171959", + "rationale": "decision rationale", + "status": "PROPOSED", + "relatedFileVersionId": null + } + } + }, + { + "step": "S4-update-decision-status", + "method": "PATCH", + "path": "/api/decisions/58f6ca73-50c4-40b4-bc74-0abd2b10cbfe/status", + "statusCode": 200, + "request": { + "status": "REJECTED" + }, + "response": { + "success": true, + "data": { + "id": "58f6ca73-50c4-40b4-bc74-0abd2b10cbfe", + "createdAt": "2026-02-20T08:20:00.261423Z", + "updatedAt": "2026-02-20T08:20:00.2767348Z", + "createdBy": "8fa6a0b6-1946-4906-ac9c-96d8f39607d0", + "createdByName": "DoD PM 20260220-171959", + "updatedBy": "8fa6a0b6-1946-4906-ac9c-96d8f39607d0", + "deletedAt": null, + "tenantId": "e7316f5d-dd5a-4bd3-91b9-05927c121975", + "projectId": "da99c81b-09da-479d-88a5-87d83265f328", + "title": "DoD Decision 20260220-171959", + "rationale": "decision rationale", + "status": "REJECTED", + "relatedFileVersionId": null + } + } + }, + { + "step": "S5-create-file", + "method": "POST", + "path": "/api/projects/da99c81b-09da-479d-88a5-87d83265f328/files", + "statusCode": 200, + "request": { + "description": "DoD file", + "name": "dod-spec-20260220171959.pdf", + "folder": "/docs" + }, + "response": { + "success": true, + "data": { + "id": "aee1be80-a342-41c1-99fe-040f003c1c8b", + "createdAt": "2026-02-20T08:20:00.2927997Z", + "updatedAt": "2026-02-20T08:20:00.2927997Z", + "createdBy": "8fa6a0b6-1946-4906-ac9c-96d8f39607d0", + "createdByName": null, + "updatedBy": "8fa6a0b6-1946-4906-ac9c-96d8f39607d0", + "deletedAt": null, + "tenantId": "e7316f5d-dd5a-4bd3-91b9-05927c121975", + "projectId": "da99c81b-09da-479d-88a5-87d83265f328", + "name": "dod-spec-20260220171959.pdf", + "description": "DoD file", + "folder": "/docs", + "visibilityScope": "SHARED" + } + } + }, + { + "step": "S5-presign-version", + "method": "POST", + "path": "/api/files/aee1be80-a342-41c1-99fe-040f003c1c8b/versions/presign", + "statusCode": 200, + "request": { + "contentType": "application/pdf", + "checksum": "93f1e27d61eb3341c59a504f85d5a078eeb397788c8f8a37415e012b607076f1", + "size": 589 + }, + "response": { + "success": true, + "data": { + "version": 1, + "expiresAt": "2026-02-20T08:35:00.327049600Z", + "checksum": "93f1e27d61eb3341c59a504f85d5a078eeb397788c8f8a37415e012b607076f1", + "contentType": "application/pdf", + "size": 589, + "objectKey": "files/aee1be80-a342-41c1-99fe-040f003c1c8b/v1/a2d7fbaa-994e-46bf-822d-addfa919b184", + "uploadUrl": "http://localhost:9000/bridge/files/aee1be80-a342-41c1-99fe-040f003c1c8b/v1/a2d7fbaa-994e-46bf-822d-addfa919b184?x-presigned-upload=true", + "uploadTicket": "__REDACTED__" + } + } + }, + { + "step": "S5-complete-version", + "method": "POST", + "path": "/api/files/aee1be80-a342-41c1-99fe-040f003c1c8b/versions/complete", + "statusCode": 200, + "request": { + "version": 1, + "checksum": "93f1e27d61eb3341c59a504f85d5a078eeb397788c8f8a37415e012b607076f1", + "objectKey": "files/aee1be80-a342-41c1-99fe-040f003c1c8b/v1/a2d7fbaa-994e-46bf-822d-addfa919b184", + "uploadTicket": "__REDACTED__", + "contentType": "application/pdf", + "size": 589 + }, + "response": { + "success": true, + "data": { + "id": "0d1a526d-b762-4a5b-b7cc-c98cb25ab6f0", + "createdAt": "2026-02-20T08:20:00.3642761Z", + "updatedAt": "2026-02-20T08:20:00.3642761Z", + "createdBy": "8fa6a0b6-1946-4906-ac9c-96d8f39607d0", + "createdByName": null, + "updatedBy": "8fa6a0b6-1946-4906-ac9c-96d8f39607d0", + "deletedAt": null, + "tenantId": "e7316f5d-dd5a-4bd3-91b9-05927c121975", + "fileId": "aee1be80-a342-41c1-99fe-040f003c1c8b", + "version": 1, + "objectKey": "files/aee1be80-a342-41c1-99fe-040f003c1c8b/v1/a2d7fbaa-994e-46bf-822d-addfa919b184", + "contentType": "application/pdf", + "size": 589, + "checksum": "93f1e27d61eb3341c59a504f85d5a078eeb397788c8f8a37415e012b607076f1", + "latest": true + } + } + }, + { + "step": "S5-comment-file-version", + "method": "POST", + "path": "/api/file-versions/0d1a526d-b762-4a5b-b7cc-c98cb25ab6f0/comments", + "statusCode": 200, + "request": { + "coordW": 120, + "body": "please update title block", + "coordX": 10, + "coordY": 10, + "coordH": 40 + }, + "response": { + "success": true, + "data": { + "id": "d1b90cff-0e66-4cfc-b9ad-1301c44b9786", + "createdAt": "2026-02-20T08:20:00.3785305Z", + "updatedAt": "2026-02-20T08:20:00.3785305Z", + "createdBy": "9e61f7e7-1f8c-4948-bec0-c022b22639b0", + "createdByName": null, + "updatedBy": "9e61f7e7-1f8c-4948-bec0-c022b22639b0", + "deletedAt": null, + "tenantId": "e7316f5d-dd5a-4bd3-91b9-05927c121975", + "fileVersionId": "0d1a526d-b762-4a5b-b7cc-c98cb25ab6f0", + "body": "please update title block", + "coordX": 10.0, + "coordY": 10.0, + "coordW": 120.0, + "coordH": 40.0, + "status": "OPEN" + } + } + }, + { + "step": "S5-resolve-file-comment", + "method": "PATCH", + "path": "/api/file-comments/d1b90cff-0e66-4cfc-b9ad-1301c44b9786/resolve", + "statusCode": 200, + "request": null, + "response": { + "success": true, + "data": { + "id": "d1b90cff-0e66-4cfc-b9ad-1301c44b9786", + "createdAt": "2026-02-20T08:20:00.378531Z", + "updatedAt": "2026-02-20T08:20:00.3957846Z", + "createdBy": "9e61f7e7-1f8c-4948-bec0-c022b22639b0", + "createdByName": "DoD Client 20260220-171959", + "updatedBy": "8fa6a0b6-1946-4906-ac9c-96d8f39607d0", + "deletedAt": null, + "tenantId": "e7316f5d-dd5a-4bd3-91b9-05927c121975", + "fileVersionId": "0d1a526d-b762-4a5b-b7cc-c98cb25ab6f0", + "body": "please update title block", + "coordX": 10.0, + "coordY": 10.0, + "coordW": 120.0, + "coordH": 40.0, + "status": "RESOLVED" + } + } + }, + { + "step": "S6-create-meeting", + "method": "POST", + "path": "/api/projects/da99c81b-09da-479d-88a5-87d83265f328/meetings", + "statusCode": 200, + "request": { + "title": "DoD Weekly Sync", + "meetUrl": "https://meet.example.com/20260220171959", + "startAt": "2026-02-21T08:20:00.4010271Z", + "endAt": "2026-02-21T09:20:00.4041379Z" + }, + "response": { + "success": true, + "data": { + "id": "bc383656-3d28-40e0-bc82-57b8eee40033", + "createdAt": "2026-02-20T08:20:00.4137145Z", + "updatedAt": "2026-02-20T08:20:00.4137145Z", + "createdBy": "8fa6a0b6-1946-4906-ac9c-96d8f39607d0", + "createdByName": null, + "updatedBy": "8fa6a0b6-1946-4906-ac9c-96d8f39607d0", + "deletedAt": null, + "tenantId": "e7316f5d-dd5a-4bd3-91b9-05927c121975", + "projectId": "da99c81b-09da-479d-88a5-87d83265f328", + "title": "DoD Weekly Sync", + "startAt": "2026-02-21T08:20:00.4010271Z", + "endAt": "2026-02-21T09:20:00.4041379Z", + "meetUrl": "https://meet.example.com/20260220171959", + "status": "SCHEDULED" + } + } + }, + { + "step": "S6-client-respond-meeting", + "method": "POST", + "path": "/api/meetings/bc383656-3d28-40e0-bc82-57b8eee40033/respond", + "statusCode": 200, + "request": { + "response": "ACCEPTED" + }, + "response": { + "success": true, + "data": { + "id": "5036736e-86c4-4d90-a393-cbd1a9e62885", + "createdAt": "2026-02-20T08:20:00.4272836Z", + "updatedAt": "2026-02-20T08:20:00.4272836Z", + "createdBy": "9e61f7e7-1f8c-4948-bec0-c022b22639b0", + "createdByName": null, + "updatedBy": "9e61f7e7-1f8c-4948-bec0-c022b22639b0", + "deletedAt": null, + "tenantId": "e7316f5d-dd5a-4bd3-91b9-05927c121975", + "meetingId": "bc383656-3d28-40e0-bc82-57b8eee40033", + "userId": "9e61f7e7-1f8c-4948-bec0-c022b22639b0", + "response": "ACCEPTED" + } + } + }, + { + "step": "S7-create-contract", + "method": "POST", + "path": "/api/projects/da99c81b-09da-479d-88a5-87d83265f328/contracts", + "statusCode": 200, + "request": { + "name": "DoD Contract 20260220-171959", + "fileVersionId": "0d1a526d-b762-4a5b-b7cc-c98cb25ab6f0" + }, + "response": { + "success": true, + "data": { + "id": "906454c2-016c-4e74-8aa2-607bdbb22854", + "createdAt": "2026-02-20T08:20:00.440281Z", + "updatedAt": "2026-02-20T08:20:00.440281Z", + "createdBy": "8fa6a0b6-1946-4906-ac9c-96d8f39607d0", + "createdByName": null, + "updatedBy": "8fa6a0b6-1946-4906-ac9c-96d8f39607d0", + "deletedAt": null, + "tenantId": "e7316f5d-dd5a-4bd3-91b9-05927c121975", + "projectId": "da99c81b-09da-479d-88a5-87d83265f328", + "name": "DoD Contract 20260220-171959", + "fileVersionId": "0d1a526d-b762-4a5b-b7cc-c98cb25ab6f0", + "status": "DRAFT" + } + } + }, + { + "step": "S7-create-envelope", + "method": "POST", + "path": "/api/contracts/906454c2-016c-4e74-8aa2-607bdbb22854/envelopes", + "statusCode": 200, + "request": { + "title": "DoD Sign Envelope" + }, + "response": { + "success": true, + "data": { + "id": "54911ad3-5056-4474-9aa0-271bdd301f09", + "createdAt": "2026-02-20T08:20:00.4529769Z", + "updatedAt": "2026-02-20T08:20:00.4529769Z", + "createdBy": "8fa6a0b6-1946-4906-ac9c-96d8f39607d0", + "createdByName": null, + "updatedBy": "8fa6a0b6-1946-4906-ac9c-96d8f39607d0", + "deletedAt": null, + "tenantId": "e7316f5d-dd5a-4bd3-91b9-05927c121975", + "contractId": "906454c2-016c-4e74-8aa2-607bdbb22854", + "title": "DoD Sign Envelope", + "status": "DRAFT", + "sentAt": null, + "completedAt": null + } + } + }, + { + "step": "S7-add-recipient", + "method": "POST", + "path": "/api/envelopes/54911ad3-5056-4474-9aa0-271bdd301f09/recipients", + "statusCode": 200, + "request": { + "email": "client+20260220171959@bridge.local", + "name": "DoD Client Signer", + "signingOrder": 1 + }, + "response": { + "success": true, + "data": { + "id": "9966e2f7-f28f-4ede-a1a1-4faa86194d08", + "createdAt": "2026-02-20T08:20:00.4640262Z", + "updatedAt": "2026-02-20T08:20:00.4640262Z", + "createdBy": "8fa6a0b6-1946-4906-ac9c-96d8f39607d0", + "createdByName": null, + "updatedBy": "8fa6a0b6-1946-4906-ac9c-96d8f39607d0", + "deletedAt": null, + "tenantId": "e7316f5d-dd5a-4bd3-91b9-05927c121975", + "envelopeId": "54911ad3-5056-4474-9aa0-271bdd301f09", + "recipientEmail": "client+20260220171959@bridge.local", + "recipientName": "DoD Client Signer", + "recipientToken": "46bca7ad3f2e4e29a6b5d59cf0a2a044", + "signingOrder": 1, + "status": "PENDING" + } + } + }, + { + "step": "S7-add-signature-field", + "method": "POST", + "path": "/api/envelopes/54911ad3-5056-4474-9aa0-271bdd301f09/fields", + "statusCode": 200, + "request": { + "recipientId": "9966e2f7-f28f-4ede-a1a1-4faa86194d08", + "coordX": 100, + "coordH": 40, + "coordW": 120, + "type": "SIGNATURE", + "page": 1, + "coordY": 200 + }, + "response": { + "success": true, + "data": { + "id": "893ba78d-e73a-48ec-a43a-94188add401a", + "createdAt": "2026-02-20T08:20:00.4747343Z", + "updatedAt": "2026-02-20T08:20:00.4747343Z", + "createdBy": "8fa6a0b6-1946-4906-ac9c-96d8f39607d0", + "createdByName": null, + "updatedBy": "8fa6a0b6-1946-4906-ac9c-96d8f39607d0", + "deletedAt": null, + "tenantId": "e7316f5d-dd5a-4bd3-91b9-05927c121975", + "envelopeId": "54911ad3-5056-4474-9aa0-271bdd301f09", + "recipientId": "9966e2f7-f28f-4ede-a1a1-4faa86194d08", + "type": "SIGNATURE", + "page": 1, + "coordX": 100.0, + "coordY": 200.0, + "coordW": 120.0, + "coordH": 40.0 + } + } + }, + { + "step": "S7-send-envelope", + "method": "POST", + "path": "/api/envelopes/54911ad3-5056-4474-9aa0-271bdd301f09/send", + "statusCode": 200, + "request": null, + "response": { + "success": true, + "data": { + "id": "54911ad3-5056-4474-9aa0-271bdd301f09", + "createdAt": "2026-02-20T08:20:00.452977Z", + "updatedAt": "2026-02-20T08:20:00.4904751Z", + "createdBy": "8fa6a0b6-1946-4906-ac9c-96d8f39607d0", + "createdByName": "DoD PM 20260220-171959", + "updatedBy": "8fa6a0b6-1946-4906-ac9c-96d8f39607d0", + "deletedAt": null, + "tenantId": "e7316f5d-dd5a-4bd3-91b9-05927c121975", + "contractId": "906454c2-016c-4e74-8aa2-607bdbb22854", + "title": "DoD Sign Envelope", + "status": "SENT", + "sentAt": "2026-02-20T08:20:00.4894507Z", + "completedAt": null + } + } + }, + { + "step": "S7-view-signing", + "method": "POST", + "path": "/api/signing/contracts/906454c2-016c-4e74-8aa2-607bdbb22854/viewed", + "statusCode": 200, + "request": null, + "response": { + "success": true, + "data": { + "viewed": true + } + } + }, + { + "step": "S7-submit-signing", + "method": "POST", + "path": "/api/signing/contracts/906454c2-016c-4e74-8aa2-607bdbb22854/submit", + "statusCode": 200, + "request": { + "fieldValues": { + "893ba78d-e73a-48ec-a43a-94188add401a": "Signed by DoD" + } + }, + "response": { + "success": true, + "data": { + "fileVersionId": "b95cf94b-12bd-4fec-97c5-8ffb55f99721", + "alreadySigned": false, + "completed": true, + "projectId": "da99c81b-09da-479d-88a5-87d83265f328", + "signed": true + } + } + }, + { + "step": "S7-signature-events", + "method": "GET", + "path": "/api/envelopes/54911ad3-5056-4474-9aa0-271bdd301f09/events", + "statusCode": 200, + "request": null, + "response": { + "success": true, + "data": [ + { + "id": "47968832-3632-4c62-87cd-db232ae67d56", + "createdAt": "2026-02-20T08:20:00.492031Z", + "updatedAt": "2026-02-20T08:20:00.492031Z", + "createdBy": "8fa6a0b6-1946-4906-ac9c-96d8f39607d0", + "createdByName": "DoD PM 20260220-171959", + "updatedBy": "8fa6a0b6-1946-4906-ac9c-96d8f39607d0", + "deletedAt": null, + "tenantId": "e7316f5d-dd5a-4bd3-91b9-05927c121975", + "envelopeId": "54911ad3-5056-4474-9aa0-271bdd301f09", + "recipientId": null, + "eventType": "SENT", + "eventPayload": "{envelopeId=54911ad3-5056-4474-9aa0-271bdd301f09}" + }, + { + "id": "a10019ec-8cf6-4ddf-8f69-59a4762093c1", + "createdAt": "2026-02-20T08:20:00.508872Z", + "updatedAt": "2026-02-20T08:20:00.508872Z", + "createdBy": "9e61f7e7-1f8c-4948-bec0-c022b22639b0", + "createdByName": "DoD Client 20260220-171959", + "updatedBy": "9e61f7e7-1f8c-4948-bec0-c022b22639b0", + "deletedAt": null, + "tenantId": "e7316f5d-dd5a-4bd3-91b9-05927c121975", + "envelopeId": "54911ad3-5056-4474-9aa0-271bdd301f09", + "recipientId": "9966e2f7-f28f-4ede-a1a1-4faa86194d08", + "eventType": "VIEWED", + "eventPayload": "{\"recipientId\":\"9966e2f7-f28f-4ede-a1a1-4faa86194d08\"}" + }, + { + "id": "5b0b0c70-9843-4894-82c5-c69596779d1c", + "createdAt": "2026-02-20T08:20:00.543657Z", + "updatedAt": "2026-02-20T08:20:00.543657Z", + "createdBy": "9e61f7e7-1f8c-4948-bec0-c022b22639b0", + "createdByName": "DoD Client 20260220-171959", + "updatedBy": "9e61f7e7-1f8c-4948-bec0-c022b22639b0", + "deletedAt": null, + "tenantId": "e7316f5d-dd5a-4bd3-91b9-05927c121975", + "envelopeId": "54911ad3-5056-4474-9aa0-271bdd301f09", + "recipientId": "9966e2f7-f28f-4ede-a1a1-4faa86194d08", + "eventType": "SIGNED", + "eventPayload": "{\"fileVersionId\":\"b95cf94b-12bd-4fec-97c5-8ffb55f99721\",\"fieldIds\":[\"893ba78d-e73a-48ec-a43a-94188add401a\"],\"recipientId\":\"9966e2f7-f28f-4ede-a1a1-4faa86194d08\"}" + }, + { + "id": "46cc2585-2934-41fd-bbcd-a5841fde574b", + "createdAt": "2026-02-20T08:20:00.54888Z", + "updatedAt": "2026-02-20T08:20:00.54888Z", + "createdBy": "9e61f7e7-1f8c-4948-bec0-c022b22639b0", + "createdByName": "DoD Client 20260220-171959", + "updatedBy": "9e61f7e7-1f8c-4948-bec0-c022b22639b0", + "deletedAt": null, + "tenantId": "e7316f5d-dd5a-4bd3-91b9-05927c121975", + "envelopeId": "54911ad3-5056-4474-9aa0-271bdd301f09", + "recipientId": "9966e2f7-f28f-4ede-a1a1-4faa86194d08", + "eventType": "COMPLETED", + "eventPayload": "{\"fileVersionId\":\"b95cf94b-12bd-4fec-97c5-8ffb55f99721\",\"envelopeId\":\"54911ad3-5056-4474-9aa0-271bdd301f09\"}" + } + ] + } + }, + { + "step": "S8-create-invoice", + "method": "POST", + "path": "/api/projects/da99c81b-09da-479d-88a5-87d83265f328/invoices", + "statusCode": 200, + "request": { + "amount": 1000000, + "currency": "KRW", + "phase": "FINAL", + "dueAt": "2026-02-27T08:20:00.5666460Z", + "invoiceNumber": "INV-20260220171959" + }, + "response": { + "success": true, + "data": { + "id": "64f805c5-f4cb-4a26-bab7-32ef7dc9c122", + "createdAt": "2026-02-20T08:20:00.5746029Z", + "updatedAt": "2026-02-20T08:20:00.5746029Z", + "createdBy": "8fa6a0b6-1946-4906-ac9c-96d8f39607d0", + "createdByName": null, + "updatedBy": "8fa6a0b6-1946-4906-ac9c-96d8f39607d0", + "deletedAt": null, + "tenantId": "e7316f5d-dd5a-4bd3-91b9-05927c121975", + "projectId": "da99c81b-09da-479d-88a5-87d83265f328", + "invoiceNumber": "INV-20260220171959", + "phase": "FINAL", + "amount": 1000000, + "currency": "KRW", + "status": "DRAFT", + "dueAt": "2026-02-27T08:20:00.566646Z" + } + } + }, + { + "step": "S8-confirm-invoice", + "method": "PATCH", + "path": "/api/invoices/64f805c5-f4cb-4a26-bab7-32ef7dc9c122/status", + "statusCode": 200, + "request": { + "status": "CONFIRMED" + }, + "response": { + "success": true, + "data": { + "id": "64f805c5-f4cb-4a26-bab7-32ef7dc9c122", + "createdAt": "2026-02-20T08:20:00.574603Z", + "updatedAt": "2026-02-20T08:20:00.5876885Z", + "createdBy": "8fa6a0b6-1946-4906-ac9c-96d8f39607d0", + "createdByName": "DoD PM 20260220-171959", + "updatedBy": "9e61f7e7-1f8c-4948-bec0-c022b22639b0", + "deletedAt": null, + "tenantId": "e7316f5d-dd5a-4bd3-91b9-05927c121975", + "projectId": "da99c81b-09da-479d-88a5-87d83265f328", + "invoiceNumber": "INV-20260220171959", + "phase": "FINAL", + "amount": 1000000, + "currency": "KRW", + "status": "CONFIRMED", + "dueAt": "2026-02-27T08:20:00.566646Z" + } + } + }, + { + "step": "S8-add-attachment", + "method": "POST", + "path": "/api/invoices/64f805c5-f4cb-4a26-bab7-32ef7dc9c122/attachments/complete", + "statusCode": 200, + "request": { + "attachmentType": "PROOF", + "objectKey": "dod/da99c81b-09da-479d-88a5-87d83265f328/invoice-proof.pdf" + }, + "response": { + "success": true, + "data": { + "id": "08d83033-5d87-49a7-bfdb-ef0bc6e1cbf3", + "createdAt": "2026-02-20T08:20:00.6010767Z", + "updatedAt": "2026-02-20T08:20:00.6010767Z", + "createdBy": "9e61f7e7-1f8c-4948-bec0-c022b22639b0", + "createdByName": null, + "updatedBy": "9e61f7e7-1f8c-4948-bec0-c022b22639b0", + "deletedAt": null, + "tenantId": "e7316f5d-dd5a-4bd3-91b9-05927c121975", + "invoiceId": "64f805c5-f4cb-4a26-bab7-32ef7dc9c122", + "attachmentType": "PROOF", + "objectKey": "dod/da99c81b-09da-479d-88a5-87d83265f328/invoice-proof.pdf" + } + } + }, + { + "step": "S8-list-attachments", + "method": "GET", + "path": "/api/invoices/64f805c5-f4cb-4a26-bab7-32ef7dc9c122/attachments", + "statusCode": 200, + "request": null, + "response": { + "success": true, + "data": { + "id": "08d83033-5d87-49a7-bfdb-ef0bc6e1cbf3", + "createdAt": "2026-02-20T08:20:00.601077Z", + "updatedAt": "2026-02-20T08:20:00.601077Z", + "createdBy": "9e61f7e7-1f8c-4948-bec0-c022b22639b0", + "createdByName": "DoD Client 20260220-171959", + "updatedBy": "9e61f7e7-1f8c-4948-bec0-c022b22639b0", + "deletedAt": null, + "tenantId": "e7316f5d-dd5a-4bd3-91b9-05927c121975", + "invoiceId": "64f805c5-f4cb-4a26-bab7-32ef7dc9c122", + "attachmentType": "PROOF", + "objectKey": "dod/da99c81b-09da-479d-88a5-87d83265f328/invoice-proof.pdf" + } + } + }, + { + "step": "S9-create-policy", + "method": "POST", + "path": "/api/projects/da99c81b-09da-479d-88a5-87d83265f328/vault/policies", + "statusCode": 200, + "request": { + "ruleJson": "{\"allow\":[\"REQUEST\",\"REVEAL\"]}", + "name": "DoD Vault Policy" + }, + "response": { + "success": true, + "data": { + "id": "f90e7e09-9895-4b8c-bdfd-0caaebf5eeae", + "createdAt": "2026-02-20T08:20:00.6219966Z", + "updatedAt": "2026-02-20T08:20:00.6219966Z", + "createdBy": "8fa6a0b6-1946-4906-ac9c-96d8f39607d0", + "createdByName": null, + "updatedBy": "8fa6a0b6-1946-4906-ac9c-96d8f39607d0", + "deletedAt": null, + "tenantId": "e7316f5d-dd5a-4bd3-91b9-05927c121975", + "projectId": "da99c81b-09da-479d-88a5-87d83265f328", + "name": "DoD Vault Policy", + "ruleJson": "{\"allow\":[\"REQUEST\",\"REVEAL\"]}" + } + } + }, + { + "step": "S9-create-secret", + "method": "POST", + "path": "/api/projects/da99c81b-09da-479d-88a5-87d83265f328/vault/secrets", + "statusCode": 200, + "request": { + "plainSecret": "__REDACTED__", + "name": "DoD Production DB", + "requestReason": "E2E DoD validation", + "type": "DB", + "siteUrl": "https://db.internal" + }, + "response": { + "success": true, + "data": { + "id": "5211244f-5acb-4458-8c7d-ef325778ac62", + "createdAt": "2026-02-20T08:20:00.6344191Z", + "updatedAt": "2026-02-20T08:20:00.6344191Z", + "createdBy": "8fa6a0b6-1946-4906-ac9c-96d8f39607d0", + "createdByName": null, + "updatedBy": "8fa6a0b6-1946-4906-ac9c-96d8f39607d0", + "deletedAt": null, + "tenantId": "e7316f5d-dd5a-4bd3-91b9-05927c121975", + "projectId": "da99c81b-09da-479d-88a5-87d83265f328", + "name": "DoD Production DB", + "siteUrl": "https://db.internal", + "requestReason": "E2E DoD validation", + "requestedByUserId": "8fa6a0b6-1946-4906-ac9c-96d8f39607d0", + "providedByUserId": "8fa6a0b6-1946-4906-ac9c-96d8f39607d0", + "providedAt": "2026-02-20T08:20:00.6344191Z", + "credentialReady": true, + "type": "DB", + "secretCiphertext": "__REDACTED__", + "nonce": "__REDACTED__", + "version": 1, + "status": "ACTIVE" + } + } + }, + { + "step": "S9-request-access", + "method": "POST", + "path": "/api/vault/secrets/5211244f-5acb-4458-8c7d-ef325778ac62/access-requests", + "statusCode": 200, + "request": null, + "response": { + "success": true, + "data": { + "id": "09ddf478-e209-4207-b4b2-1a16b7367908", + "createdAt": "2026-02-20T08:20:00.6466143Z", + "updatedAt": "2026-02-20T08:20:00.6466143Z", + "createdBy": "9e61f7e7-1f8c-4948-bec0-c022b22639b0", + "createdByName": null, + "updatedBy": "9e61f7e7-1f8c-4948-bec0-c022b22639b0", + "deletedAt": null, + "tenantId": "e7316f5d-dd5a-4bd3-91b9-05927c121975", + "secretId": "5211244f-5acb-4458-8c7d-ef325778ac62", + "requesterUserId": "9e61f7e7-1f8c-4948-bec0-c022b22639b0", + "approverUserId": null, + "status": "REQUESTED", + "expiresAt": "2026-02-21T08:20:00.6466143Z" + } + } + }, + { + "step": "S9-approve-access", + "method": "PATCH", + "path": "/api/vault/access-requests/09ddf478-e209-4207-b4b2-1a16b7367908", + "statusCode": 200, + "request": { + "status": "APPROVED" + }, + "response": { + "success": true, + "data": { + "id": "09ddf478-e209-4207-b4b2-1a16b7367908", + "createdAt": "2026-02-20T08:20:00.646614Z", + "updatedAt": "2026-02-20T08:20:00.6666848Z", + "createdBy": "9e61f7e7-1f8c-4948-bec0-c022b22639b0", + "createdByName": "DoD Client 20260220-171959", + "updatedBy": "8fa6a0b6-1946-4906-ac9c-96d8f39607d0", + "deletedAt": null, + "tenantId": "e7316f5d-dd5a-4bd3-91b9-05927c121975", + "secretId": "5211244f-5acb-4458-8c7d-ef325778ac62", + "requesterUserId": "9e61f7e7-1f8c-4948-bec0-c022b22639b0", + "approverUserId": "8fa6a0b6-1946-4906-ac9c-96d8f39607d0", + "status": "APPROVED", + "expiresAt": "2026-02-20T08:30:00.6656488Z" + } + } + }, + { + "step": "S9-reveal-secret", + "method": "POST", + "path": "/api/vault/secrets/5211244f-5acb-4458-8c7d-ef325778ac62/reveal", + "statusCode": 200, + "request": null, + "response": { + "success": true, + "data": { + "version": 1, + "secret": "__REDACTED__" + } + } + } + ] +} diff --git a/docs/Test/evidence/dod-demo-20260220-171959.md b/docs/Test/evidence/dod-demo-20260220-171959.md new file mode 100644 index 0000000..86f6531 --- /dev/null +++ b/docs/Test/evidence/dod-demo-20260220-171959.md @@ -0,0 +1,26 @@ +# DoD Demo Scenarios 1~9 Evidence + +- Generated At (UTC): 2026-02-20T08:20:00.6986047Z +- API Base: http://localhost:8080 +- Tenant Slug: dod-20260220171959 +- PM Account: pm+20260220171959@bridge.local +- Client Account: client+20260220171959@bridge.local + +## Scenario Results + +| Scenario | Status | Evidence | +|---|---|---| +| 1 | DONE | tenantId=e7316f5d-dd5a-4bd3-91b9-05927c121975, pmUserId=8fa6a0b6-1946-4906-ac9c-96d8f39607d0 | +| 2 | DONE | projectId=da99c81b-09da-479d-88a5-87d83265f328, clientMemberId=a4ad832c-33bc-484a-98ca-73472ae69dcf | +| 3 | DONE | passwordInitialized=True | +| 4 | DONE | postId=96a8c58f-9617-4cba-b33e-ad2155a27f43, requestId=0cffdc1c-d25c-4466-8988-1170c134df36, decisionId=58f6ca73-50c4-40b4-bc74-0abd2b10cbfe | +| 5 | DONE | fileId=aee1be80-a342-41c1-99fe-040f003c1c8b, fileVersionId=0d1a526d-b762-4a5b-b7cc-c98cb25ab6f0, commentId=d1b90cff-0e66-4cfc-b9ad-1301c44b9786 | +| 6 | DONE | meetingId=bc383656-3d28-40e0-bc82-57b8eee40033, response=ACCEPTED | +| 7 | DONE | contractId=906454c2-016c-4e74-8aa2-607bdbb22854, envelopeId=54911ad3-5056-4474-9aa0-271bdd301f09, completed=True | +| 8 | DONE | invoiceId=64f805c5-f4cb-4a26-bab7-32ef7dc9c122, status=CONFIRMED, attachments=1 | +| 9 | DONE | secretId=5211244f-5acb-4458-8c7d-ef325778ac62, accessRequestId=09ddf478-e209-4207-b4b2-1a16b7367908, revealVersion=1 | + +## API Logs + +- JSON: C:\Users\yw914\Downloads\project\ProjectBridge\docs\Test\evidence\dod-demo-20260220-171959.json +- Total API calls: 37 diff --git a/scripts/run-dod-demo.ps1 b/scripts/run-dod-demo.ps1 index c97492f..6d71d25 100644 --- a/scripts/run-dod-demo.ps1 +++ b/scripts/run-dod-demo.ps1 @@ -4,6 +4,7 @@ param( ) $ErrorActionPreference = "Stop" +Add-Type -AssemblyName System.Net.Http $script:RequestLog = @() $script:SensitiveKeys = @( "password", @@ -135,6 +136,83 @@ function Invoke-BridgeApi { } } +function New-BridgeDemoPdfBytes { + $content = "BT`n/F1 18 Tf`n72 720 Td`n(Bridge DoD Demo) Tj`nET" + $contentLength = [System.Text.Encoding]::ASCII.GetByteCount($content) + $objects = @( + "1 0 obj`n<< /Type /Catalog /Pages 2 0 R >>`nendobj`n", + "2 0 obj`n<< /Type /Pages /Kids [3 0 R] /Count 1 >>`nendobj`n", + "3 0 obj`n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>`nendobj`n", + "4 0 obj`n<< /Length $contentLength >>`nstream`n$content`nendstream`nendobj`n", + "5 0 obj`n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>`nendobj`n" + ) + + $builder = New-Object System.Text.StringBuilder + $null = $builder.Append("%PDF-1.4`n") + $offsets = New-Object System.Collections.Generic.List[int] + + foreach ($obj in $objects) { + $offsets.Add([System.Text.Encoding]::ASCII.GetByteCount($builder.ToString())) + $null = $builder.Append($obj) + } + + $xrefOffset = [System.Text.Encoding]::ASCII.GetByteCount($builder.ToString()) + $null = $builder.Append("xref`n0 $($objects.Count + 1)`n") + $null = $builder.Append("0000000000 65535 f `n") + foreach ($offset in $offsets) { + $null = $builder.Append(("{0:D10} 00000 n `n" -f $offset)) + } + $null = $builder.Append("trailer`n<< /Size $($objects.Count + 1) /Root 1 0 R >>`n") + $null = $builder.Append("startxref`n$xrefOffset`n%%EOF") + + return [System.Text.Encoding]::ASCII.GetBytes($builder.ToString()) +} + +function Get-BridgeSha256Hex { + param([byte[]]$Bytes) + + $sha = [System.Security.Cryptography.SHA256]::Create() + try { + $hash = $sha.ComputeHash($Bytes) + return [System.BitConverter]::ToString($hash).Replace("-", "").ToLowerInvariant() + } finally { + $sha.Dispose() + } +} + +function Upload-BridgePresignedObject { + param( + [string]$UploadUrl, + [string]$ContentType, + [byte[]]$Bytes + ) + + if ([string]::IsNullOrWhiteSpace($UploadUrl)) { + throw "[UPLOAD] Presigned uploadUrl is empty." + } + + $handler = $null + $client = $null + $content = $null + try { + $handler = New-Object System.Net.Http.HttpClientHandler + $client = [System.Net.Http.HttpClient]::new($handler) + $content = [System.Net.Http.ByteArrayContent]::new($Bytes) + $content.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::Parse($ContentType) + $response = $client.PutAsync($UploadUrl, $content).GetAwaiter().GetResult() + if (-not $response.IsSuccessStatusCode) { + $body = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult() + throw "status=$([int]$response.StatusCode), body=$body" + } + } catch { + throw "[UPLOAD] PUT failed: $($_.Exception.Message) (url=$UploadUrl)" + } finally { + if ($null -ne $content) { $content.Dispose() } + if ($null -ne $client) { $client.Dispose() } + if ($null -ne $handler) { $handler.Dispose() } + } +} + $timestamp = Get-Date -Format "yyyyMMdd-HHmmss" $slugSuffix = Get-Date -Format "yyyyMMddHHmmss" @@ -221,7 +299,7 @@ $request = Invoke-BridgeApi -Step "S4-create-request" -Method "POST" -Path "/api $requestId = $request.data.id $requestStatus = Invoke-BridgeApi -Step "S4-update-request-status" -Method "PATCH" -Path "/api/requests/$requestId/status" -Session $pmSession -Body @{ - status = "IN_PROGRESS" + status = "SENT" } $decision = Invoke-BridgeApi -Step "S4-create-decision" -Method "POST" -Path "/api/projects/$projectId/decisions" -Session $pmSession -Body @{ @@ -231,7 +309,7 @@ $decision = Invoke-BridgeApi -Step "S4-create-decision" -Method "POST" -Path "/a $decisionId = $decision.data.id $decisionStatus = Invoke-BridgeApi -Step "S4-update-decision-status" -Method "PATCH" -Path "/api/decisions/$decisionId/status" -Session $pmSession -Body @{ - status = "APPROVED" + status = "REJECTED" } # Scenario 5: file/version/comment @@ -242,12 +320,18 @@ $file = Invoke-BridgeApi -Step "S5-create-file" -Method "POST" -Path "/api/proje } $fileId = $file.data.id +$pdfBytes = New-BridgeDemoPdfBytes +$pdfSize = [long]$pdfBytes.Length +$pdfChecksum = Get-BridgeSha256Hex -Bytes $pdfBytes + $presign = Invoke-BridgeApi -Step "S5-presign-version" -Method "POST" -Path "/api/files/$fileId/versions/presign" -Session $pmSession -Body @{ contentType = "application/pdf" - size = 1024 - checksum = "sha256-$slugSuffix" + size = $pdfSize + checksum = $pdfChecksum } +$null = Upload-BridgePresignedObject -UploadUrl $presign.data.uploadUrl -ContentType $presign.data.contentType -Bytes $pdfBytes + $fileVersion = Invoke-BridgeApi -Step "S5-complete-version" -Method "POST" -Path "/api/files/$fileId/versions/complete" -Session $pmSession -Body @{ version = $presign.data.version objectKey = $presign.data.objectKey @@ -316,7 +400,11 @@ $signatureField = Invoke-BridgeApi -Step "S7-add-signature-field" -Method "POST" $null = Invoke-BridgeApi -Step "S7-send-envelope" -Method "POST" -Path "/api/envelopes/$envelopeId/send" -Session $pmSession $null = Invoke-BridgeApi -Step "S7-view-signing" -Method "POST" -Path "/api/signing/contracts/$contractId/viewed" -Session $clientSession -$submitSigning = Invoke-BridgeApi -Step "S7-submit-signing" -Method "POST" -Path "/api/signing/contracts/$contractId/submit" -Session $clientSession -Body @{} +$submitSigning = Invoke-BridgeApi -Step "S7-submit-signing" -Method "POST" -Path "/api/signing/contracts/$contractId/submit" -Session $clientSession -Body @{ + fieldValues = @{ + "$($signatureField.data.id)" = "Signed by DoD" + } +} $signatureEvents = Invoke-BridgeApi -Step "S7-signature-events" -Method "GET" -Path "/api/envelopes/$envelopeId/events" -Session $pmSession # Scenario 8: billing @@ -362,7 +450,7 @@ $approveRequest = Invoke-BridgeApi -Step "S9-approve-access" -Method "PATCH" -Pa status = "APPROVED" } -$revealSecret = Invoke-BridgeApi -Step "S9-reveal-secret" -Method "POST" -Path "/api/vault/secrets/$secretId/reveal" -Session $clientSession +$revealSecret = Invoke-BridgeApi -Step "S9-reveal-secret" -Method "POST" -Path "/api/vault/secrets/$secretId/reveal" -Session $pmSession $scenarios = [ordered]@{ "1" = [ordered]@{ status = "DONE"; tenantId = $tenantId; pmUserId = $pmUserId }