From b204c14e958bfc157c019dd5877b1cc4db225722 Mon Sep 17 00:00:00 2001 From: jangwoong Date: Wed, 25 Mar 2026 16:51:24 +0900 Subject: [PATCH] =?UTF-8?q?[user=20service]=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=20=EB=A9=80=ED=8B=B0=20=EB=94=94=EB=B0=94=EC=9D=B4=EC=8A=A4=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=B2=98=EB=A6=AC=20deviceId=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=EA=B1=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + apps/cs/app/api/auth/logout/route.ts | 2 + apps/cs/app/api/proxy/[...url]/route.ts | 56 ++++- .../cs/src/entities/auth/api/login-service.ts | 41 +++- .../cs/src/entities/auth/lib/token-manager.ts | 40 +++- apps/cs/src/shared/config/env.ts | 1 + apps/st/app/api/auth/logout/route.ts | 22 +- apps/st/app/api/proxy/[...url]/route.ts | 206 +++++++++++------- .../st/src/entities/auth/api/login-service.ts | 161 ++++++++------ .../st/src/entities/auth/lib/token-manager.ts | 172 +++++++++------ apps/st/src/shared/config/env.ts | 10 +- 11 files changed, 455 insertions(+), 257 deletions(-) diff --git a/.gitignore b/.gitignore index ee2cfd052..45ef7b19b 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ settings.json apps/**/tsconfig.tsbuildinfo logs/ +apps/st/start_cs.log diff --git a/apps/cs/app/api/auth/logout/route.ts b/apps/cs/app/api/auth/logout/route.ts index fdcc9df7c..41cb1918a 100644 --- a/apps/cs/app/api/auth/logout/route.ts +++ b/apps/cs/app/api/auth/logout/route.ts @@ -5,6 +5,7 @@ import { AUTH_USER_ID, CLAIM_NAME, REFRESH_TOKEN, + DEVICE_ID, } from "@/shared/config"; export async function POST() { @@ -13,5 +14,6 @@ export async function POST() { cookieStore.delete(REFRESH_TOKEN); cookieStore.delete(AUTH_USER_ID); cookieStore.delete(CLAIM_NAME); + cookieStore.delete(DEVICE_ID); // 로그아웃 시 deviceId 함께 삭제 → 재로그인 시 새 세션 생성 return NextResponse.json({ message: "Logged out" }, { status: 200 }); } diff --git a/apps/cs/app/api/proxy/[...url]/route.ts b/apps/cs/app/api/proxy/[...url]/route.ts index abd3ae007..1e48ff3d7 100644 --- a/apps/cs/app/api/proxy/[...url]/route.ts +++ b/apps/cs/app/api/proxy/[...url]/route.ts @@ -5,6 +5,7 @@ import { REFRESH_TOKEN, SERVER_API_URL, SITE_ID, + DEVICE_ID, } from "@/shared/config"; import { cookies } from "next/headers"; import { NextResponse } from "next/server"; @@ -20,9 +21,16 @@ async function handler( "X-Site-Id": SITE_ID, }; let body = {}; - const isRefreshHandler = params.url.includes("refresh"); - // silent refresh + const isRefreshHandler = params.url.includes("refresh"); + const isLoginHandler = params.url.includes("login"); + + // ── Silent Refresh ────────────────────────────────────────────────────────── + // HttpOnly 쿠키에서 refresh-token 꺼내 Authorization 헤더에 주입. + // 쿠키에 저장된 device-id도 X-Device-Id 헤더로 함께 전달한다. + // 백엔드: TokenProvider.refreshToken(token, request, response) + // → userService.findByRefreshToken(refreshToken, deviceId) (Redis Hash 검증) + // → userService.updateRefreshToken(userUnqId, newToken, deviceId) (RTR 갱신) if (isRefreshHandler) { const refreshToken = cookieStore.get(REFRESH_TOKEN); headers[CLAIM_NAME] = refreshToken?.value ?? ""; @@ -30,6 +38,27 @@ async function handler( if (!headers[CLAIM_NAME]) { return NextResponse.json({ message: "Can't refresh" }, { status: 401 }); } + + // device-id 쿠키 → X-Device-Id 헤더 (Redis 다중 세션 식별) + const deviceId = cookieStore.get(DEVICE_ID); + if (deviceId?.value) { + headers["X-Device-Id"] = deviceId.value; + } + } + + // ── Login ──────────────────────────────────────────────────────────────────── + // 클라이언트(login-service.ts)가 fetch 헤더에 X-Device-Id 를 포함해서 전송한다. + // BFF는 해당 헤더를 그대로 백엔드로 포워딩한다. + // 백엔드: AuthenticationFilter.successfulAuthentication + // → TokenProvider.createTokenAndAddHeader(request, ...) + // → request.getHeader("X-Device-Id") 로 deviceId 추출 + // → userService.updateRefreshToken(userUnqId, refreshToken, deviceId) + // → Redis Hash[user:sessions:{userUnqId}][deviceId] = refreshToken (TTL 1일) + if (isLoginHandler) { + const deviceId = request.headers.get("X-Device-Id"); + if (deviceId) { + headers["X-Device-Id"] = deviceId; + } } try { @@ -62,16 +91,28 @@ async function handler( const forwardedHost = request.headers.get("x-forwarded-host"); const host = request.headers.get("host"); - const actualHost = forwardedHost || host || new URL(request.url).hostname.toString(); - - // 운영 환경에서 domain 설정 - const domain = actualHost.includes("hasuon.or.kr") ? ".hasuon.or.kr" : actualHost; + const rawHost = + forwardedHost || host || new URL(request.url).hostname.toString(); + + // 포트 번호 제거 (예: "localhost:3000" → "localhost") + const hostname = rawHost.split(":")[0]; + + // 쿠키 domain 결정 + // - localhost / 127.0.0.1: domain 미지정 (undefined) → 브라우저 기본 동작 + // - 운영(hasuon.or.kr): 서브도메인 공유를 위해 최상위 도메인 앞에 점(.) 붙임 + // - 그 외(IP, 기타 도메인): 현재 hostname 그대로 사용 + const isLocal = hostname === "localhost" || hostname === "127.0.0.1"; + const domain = isLocal + ? undefined + : hostname.includes("hasuon.or.kr") + ? ".hasuon.or.kr" + : hostname; if (refreshToken) { cookieStore.set(REFRESH_TOKEN, refreshToken, { httpOnly: true, sameSite: "strict", - domain: domain, + ...(domain !== undefined && { domain }), path: "/", }); } @@ -91,7 +132,6 @@ async function handler( } } - export async function POST( request: Request, props: { params: Promise<{ url: string[] }> }, diff --git a/apps/cs/src/entities/auth/api/login-service.ts b/apps/cs/src/entities/auth/api/login-service.ts index 671c8bb4c..9f4d422ce 100644 --- a/apps/cs/src/entities/auth/api/login-service.ts +++ b/apps/cs/src/entities/auth/api/login-service.ts @@ -5,6 +5,7 @@ import { ACCESS_TOKEN, AUTH_USER_ID, CLAIM_NAME, + DEVICE_ID, } from "@/shared/config"; import type { LoginService, ILogin } from "../model/login-interface"; import { tokenManager } from "../lib/token-manager"; @@ -23,8 +24,21 @@ export class LoginApiService implements LoginService { public async login(payload: ILogin): Promise { try { + // ✅ 로그인 요청 전에 deviceId 생성/결정 + // - 같은 브라우저 재로그인 시: 기존 쿠키 재사용 → 동일 Redis 슬롯 유지 + // - 최초 로그인 / 로그아웃 후 재로그인 시: 새 UUID 생성 → 새 Redis 슬롯 + const existingDeviceId = tokenManager.getDeviceId(); + const deviceId = existingDeviceId ?? crypto.randomUUID(); + const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, { method: "POST", + headers: { + // ✅ 로그인 요청 헤더에 X-Device-Id 포함 + // BFF route.ts → 백엔드 AuthenticationFilter → TokenProvider.createTokenAndAddHeader() + // → userService.updateRefreshToken(userUnqId, refreshToken, deviceId) + // → Redis Hash[user:sessions:{userUnqId}][deviceId] = refreshToken (TTL 1일) + "X-Device-Id": deviceId, + }, body: JSON.stringify({ userId: payload.userId, password: payload.password, @@ -32,24 +46,27 @@ export class LoginApiService implements LoginService { }); const data = await response.json(); - console.log('data ', data) + console.log("data ", data); const accessToken = data[ACCESS_TOKEN]; const tokenId = data[AUTH_USER_ID]; if (!accessToken || !tokenId) { - if (data.message == 'Invalid Credentials' ) { - throw new Error('아이디와 비밀번호가 일치하지 않습니다.'); - } else if (data.message == 'User not found') { - throw new Error('존재하지 않는 아이디입니다.'); - } else if (data.message == 'User is not active') { - throw new Error('활성화되지 않은 계정입니다.'); - } else if (data.message == 'User is not verified') { - throw new Error('이메일 인증이 필요합니다.'); + if (data.message == "Invalid Credentials") { + throw new Error("아이디와 비밀번호가 일치하지 않습니다."); + } else if (data.message == "User not found") { + throw new Error("존재하지 않는 아이디입니다."); + } else if (data.message == "User is not active") { + throw new Error("활성화되지 않은 계정입니다."); + } else if (data.message == "User is not verified") { + throw new Error("이메일 인증이 필요합니다."); } else { throw new Error(data.message); - } + } } + // ✅ 로그인 성공 후 deviceId 쿠키 저장 (maxAge=86400, Refresh Token TTL과 동기화) + tokenManager.setDeviceId(deviceId); + tokenManager.setToken(CLAIM_NAME, accessToken); tokenManager.setToken(AUTH_USER_ID, tokenId); tokenManager.setupRefreshTimer( @@ -66,6 +83,10 @@ export class LoginApiService implements LoginService { public async silentRefresh(): Promise { try { + // deviceId는 BFF(route.ts)가 쿠키(device-id)에서 읽어 X-Device-Id 헤더로 자동 전달 + // → 백엔드 TokenProvider.refreshToken(token, request, response) + // → userService.findByRefreshToken(refreshToken, deviceId) → Redis Hash 검증 + // → userService.updateRefreshToken(userUnqId, newRefreshToken, deviceId) → RTR 갱신 const response = await fetch( `/api/proxy${ENDPOINT.USER_SERVICE.SILENT_REFRESH}`, { method: "PUT" }, diff --git a/apps/cs/src/entities/auth/lib/token-manager.ts b/apps/cs/src/entities/auth/lib/token-manager.ts index dd9aea352..22faf5958 100644 --- a/apps/cs/src/entities/auth/lib/token-manager.ts +++ b/apps/cs/src/entities/auth/lib/token-manager.ts @@ -1,4 +1,4 @@ -import { AUTH_USER_ID, CLAIM_NAME } from "@/shared/config"; +import { AUTH_USER_ID, CLAIM_NAME, DEVICE_ID } from "@/shared/config"; import { getCookie, setCookie, removeCookie } from "@/shared/lib"; export class TokenManager { @@ -20,22 +20,18 @@ export class TokenManager { public setToken(key: string, value: string): void { const domain = this.getCookieDomain(); - setCookie(key, value, { secure: window.location.protocol === "https:", sameSite: "lax", domain: domain, - path: "/", // 모든 경로에서 접근 가능 + path: "/", }); } - private getCookieDomain(): string | undefined { const hostname = window.location.hostname; - - // 개발 환경에서는 localhost 도메인 사용 if (hostname === "localhost" || hostname === "127.0.0.1") { - return undefined; // localhost에서는 도메인 설정하지 않음 + return undefined; } // 운영 환경에서는 서브도메인을 포함한 최상위 도메인 사용 @@ -52,6 +48,33 @@ export class TokenManager { removeCookie(key, { domain, path: "/" }); } + /** + * deviceId 쿠키 저장 + * - httpOnly가 아닌 일반 쿠키로 저장 (BFF route.ts에서 cookieStore.get()으로 읽기 위함) + * - js-cookie는 maxAge를 지원하지 않으므로 expires(Date)로 변환하여 사용 + * - expires: Refresh Token TTL(1일)과 동일하게 설정하여 브라우저 재시작 후에도 동일 Redis 슬롯 재사용 + */ + public setDeviceId(deviceId: string): void { + const domain = this.getCookieDomain(); + // js-cookie v3는 maxAge 속성을 지원하지 않음 (number 타입에 .split() 호출로 TypeError 발생) + // maxAge(초) → expires(Date) 변환: 1일 = 86400초 + const expires = new Date(Date.now() + 86400 * 1000); + setCookie(DEVICE_ID, deviceId, { + secure: window.location.protocol === "https:", + sameSite: "lax", + domain: domain, + path: "/", + expires, // Date 객체로 전달 (js-cookie가 toUTCString()으로 직렬화) + }); + } + + /** + * deviceId 쿠키 조회 + */ + public getDeviceId(): string | undefined { + return getCookie(DEVICE_ID); + } + public setupRefreshTimer( callback: () => Promise, expirationTime: number, @@ -59,7 +82,6 @@ export class TokenManager { if (this.refreshTimer) { clearTimeout(this.refreshTimer); } - this.refreshTimer = setTimeout(() => { callback().catch((error) => { console.error("Silent refresh failed:", error); @@ -73,9 +95,9 @@ export class TokenManager { clearTimeout(this.refreshTimer); this.refreshTimer = null; } - this.removeToken(CLAIM_NAME); this.removeToken(AUTH_USER_ID); + this.removeToken(DEVICE_ID); // deviceId 함께 삭제 } } diff --git a/apps/cs/src/shared/config/env.ts b/apps/cs/src/shared/config/env.ts index c6373724f..7b742a927 100644 --- a/apps/cs/src/shared/config/env.ts +++ b/apps/cs/src/shared/config/env.ts @@ -9,3 +9,4 @@ export const ACCESS_TOKEN = export const REFRESH_TOKEN = process.env.NEXT_PUBLIC_REFRESH_TOKEN || "refresh-token"; export const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id"; +export const DEVICE_ID = process.env.NEXT_PUBLIC_DEVICE_ID || "device-id"; diff --git a/apps/st/app/api/auth/logout/route.ts b/apps/st/app/api/auth/logout/route.ts index fdcc9df7c..eb7839b6f 100644 --- a/apps/st/app/api/auth/logout/route.ts +++ b/apps/st/app/api/auth/logout/route.ts @@ -1,17 +1,19 @@ import { cookies } from "next/headers"; import { NextResponse } from "next/server"; import { - ACCESS_TOKEN, - AUTH_USER_ID, - CLAIM_NAME, - REFRESH_TOKEN, + ACCESS_TOKEN, + AUTH_USER_ID, + CLAIM_NAME, + DEVICE_ID, + REFRESH_TOKEN, } from "@/shared/config"; export async function POST() { - const cookieStore = await cookies(); - cookieStore.delete(ACCESS_TOKEN); - cookieStore.delete(REFRESH_TOKEN); - cookieStore.delete(AUTH_USER_ID); - cookieStore.delete(CLAIM_NAME); - return NextResponse.json({ message: "Logged out" }, { status: 200 }); + const cookieStore = await cookies(); + cookieStore.delete(ACCESS_TOKEN); + cookieStore.delete(REFRESH_TOKEN); + cookieStore.delete(AUTH_USER_ID); + cookieStore.delete(CLAIM_NAME); + cookieStore.delete(DEVICE_ID); + return NextResponse.json({ message: "Logged out" }, { status: 200 }); } diff --git a/apps/st/app/api/proxy/[...url]/route.ts b/apps/st/app/api/proxy/[...url]/route.ts index 7442c702f..ed133b1cb 100644 --- a/apps/st/app/api/proxy/[...url]/route.ts +++ b/apps/st/app/api/proxy/[...url]/route.ts @@ -1,104 +1,158 @@ import { - ACCESS_TOKEN, - AUTH_USER_ID, - CLAIM_NAME, - REFRESH_TOKEN, - SERVER_API_URL, - SITE_ID, + ACCESS_TOKEN, + AUTH_USER_ID, + CLAIM_NAME, + DEVICE_ID, + REFRESH_TOKEN, + SERVER_API_URL, + SITE_ID, } from "@/shared/config"; import { cookies } from "next/headers"; import { NextResponse } from "next/server"; async function handler( - request: Request, - params: { url: string[] }, - method: "GET" | "POST" | "PUT" | "DELETE", + request: Request, + params: { url: string[] }, + method: "GET" | "POST" | "PUT" | "DELETE", ) { - const cookieStore = await cookies(); - const headers: Record = { - "Content-Type": "application/json", - "X-Site-Id": SITE_ID, - }; - let body = {}; - const isRefreshHandler = params.url.includes("refresh"); + const cookieStore = await cookies(); + const headers: Record = { + "Content-Type": "application/json", + "X-Site-Id": SITE_ID, + }; + let body = {}; - // silent refresh - if (isRefreshHandler) { - const refreshToken = cookieStore.get(REFRESH_TOKEN); - headers[CLAIM_NAME] = refreshToken?.value ?? ""; + const isRefreshHandler = params.url.includes("refresh"); + const isLoginHandler = params.url.includes("login"); - if (!headers[CLAIM_NAME]) { - return NextResponse.json({ message: "Can't refresh" }, { status: 401 }); - } - } + // ── Silent Refresh ────────────────────────────────────────────────────────── + // HttpOnly 쿠키에서 refresh-token 꺼내 Authorization 헤더에 주입. + // 쿠키에 저장된 device-id도 X-Device-Id 헤더로 함께 전달한다. + // 백엔드: TokenProvider.refreshToken(token, request, response) + // → userService.findByRefreshToken(refreshToken, deviceId) (Redis Hash 검증) + // → userService.updateRefreshToken(userUnqId, newToken, deviceId) (RTR 갱신) + if (isRefreshHandler) { + const refreshToken = cookieStore.get(REFRESH_TOKEN); + headers[CLAIM_NAME] = refreshToken?.value ?? ""; - try { - if (!isRefreshHandler) { - body = await request.json(); + if (!headers[CLAIM_NAME]) { + return NextResponse.json( + { message: "Can't refresh" }, + { status: 401 }, + ); + } + + // device-id 쿠키 → X-Device-Id 헤더 (Redis 다중 세션 식별) + const deviceId = cookieStore.get(DEVICE_ID); + if (deviceId?.value) { + headers["X-Device-Id"] = deviceId.value; + } } - const result = await fetch(`${SERVER_API_URL}/${params.url.join("/")}`, { - method, - headers, - body: JSON.stringify(body), - }); - - if (!result.ok) { - return NextResponse.json( - { message: "Invalid Credentials" }, - { status: 401 }, - ); + // ── Login ──────────────────────────────────────────────────────────────────── + // 클라이언트(login-service.ts)가 fetch 헤더에 X-Device-Id 를 포함해서 전송한다. + // BFF는 해당 헤더를 그대로 백엔드로 포워딩한다. + // 백엔드: AuthenticationFilter.successfulAuthentication + // → TokenProvider.createTokenAndAddHeader(request, ...) + // → request.getHeader("X-Device-Id") 로 deviceId 추출 + // → userService.updateRefreshToken(userUnqId, refreshToken, deviceId) + // → Redis Hash[user:sessions:{userUnqId}][deviceId] = refreshToken (TTL 1일) + if (isLoginHandler) { + const deviceId = request.headers.get("X-Device-Id"); + if (deviceId) { + headers["X-Device-Id"] = deviceId; + } } - const accessToken = result.headers.get(ACCESS_TOKEN); - const refreshToken = result.headers.get(REFRESH_TOKEN); - const userId = result.headers.get(AUTH_USER_ID); + try { + if (!isRefreshHandler) { + body = await request.json(); + } - if (accessToken && userId) { - const response = NextResponse.json({ - [ACCESS_TOKEN]: accessToken, - [AUTH_USER_ID]: userId, - }); + const result = await fetch( + `${SERVER_API_URL}/${params.url.join("/")}`, + { + method, + headers, + body: JSON.stringify(body), + }, + ); - + if (!result.ok) { + return NextResponse.json( + { message: "Invalid Credentials" }, + { status: 401 }, + ); + } - if (refreshToken) { - cookieStore.set(REFRESH_TOKEN, refreshToken, { - httpOnly: true, - sameSite: "lax", -// domain: domain, - path: "/", - }); - } + const accessToken = result.headers.get(ACCESS_TOKEN); + const refreshToken = result.headers.get(REFRESH_TOKEN); + const userId = result.headers.get(AUTH_USER_ID); - return response; + if (accessToken && userId) { + const response = NextResponse.json({ + [ACCESS_TOKEN]: accessToken, + [AUTH_USER_ID]: userId, + }); + + const forwardedHost = request.headers.get("x-forwarded-host"); + const host = request.headers.get("host"); + const rawHost = + forwardedHost || + host || + new URL(request.url).hostname.toString(); + + // 포트 번호 제거 (예: "localhost:13000" → "localhost") + const hostname = rawHost.split(":")[0]; + + // 쿠키 domain 결정 + // - localhost / 127.0.0.1: domain 미지정 (undefined) → 브라우저 기본 동작 + // - 운영(hasuon.or.kr): 서브도메인 공유를 위해 최상위 도메인 앞에 점(.) 붙임 + // - 그 외(IP, 기타 도메인): 현재 hostname 그대로 사용 + const isLocal = + hostname === "localhost" || hostname === "127.0.0.1"; + const domain = isLocal + ? undefined + : hostname.includes("hasuon.or.kr") + ? ".hasuon.or.kr" + : hostname; + + if (refreshToken) { + cookieStore.set(REFRESH_TOKEN, refreshToken, { + httpOnly: true, + sameSite: "strict", + ...(domain !== undefined && { domain }), + path: "/", + }); + } + + return response; + } + + return NextResponse.json( + { message: "Invalid Credentials" }, + { status: 401 }, + ); + } catch (error) { + return NextResponse.json( + { message: "Server Error", error }, + { status: 500 }, + ); } - - return NextResponse.json( - { message: "Invalid Credentials" }, - { status: 401 }, - ); - } catch (error) { - return NextResponse.json( - { message: "Server Error", error }, - { status: 500 }, - ); - } } - export async function POST( - request: Request, - props: { params: Promise<{ url: string[] }> }, + request: Request, + props: { params: Promise<{ url: string[] }> }, ) { - const params = await props.params; - return handler(request, params, "POST"); + const params = await props.params; + return handler(request, params, "POST"); } export async function PUT( - request: Request, - props: { params: Promise<{ url: string[] }> }, + request: Request, + props: { params: Promise<{ url: string[] }> }, ) { - const params = await props.params; - return handler(request, params, "PUT"); + const params = await props.params; + return handler(request, params, "PUT"); } diff --git a/apps/st/src/entities/auth/api/login-service.ts b/apps/st/src/entities/auth/api/login-service.ts index 7fd87cc27..61c501820 100644 --- a/apps/st/src/entities/auth/api/login-service.ts +++ b/apps/st/src/entities/auth/api/login-service.ts @@ -1,95 +1,120 @@ import { AxiosError } from "axios"; import { - ACCESS_TOKEN, - AUTH_USER_ID, - CLAIM_NAME, - ENDPOINT, - JWT_EXPIRATION_TIME, + ACCESS_TOKEN, + AUTH_USER_ID, + CLAIM_NAME, + DEVICE_ID, + ENDPOINT, + JWT_EXPIRATION_TIME, } from "@/shared/config"; -import { getCookie } from "@/shared/lib"; import { tokenManager } from "../lib/token-manager"; -import type { ILogin,LoginService } from "../model/login-interface"; +import type { ILogin, LoginService } from "../model/login-interface"; import { clearTabStorePersist } from "@/store/tab-store"; export class LoginApiService implements LoginService { - private static instance: LoginApiService; + private static instance: LoginApiService; - private constructor() {} + private constructor() {} - public static getInstance(): LoginApiService { - if (!LoginApiService.instance) { - LoginApiService.instance = new LoginApiService(); + public static getInstance(): LoginApiService { + if (!LoginApiService.instance) { + LoginApiService.instance = new LoginApiService(); + } + return LoginApiService.instance; } - return LoginApiService.instance; - } - public async login(payload: ILogin): Promise { - try { - const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, { - method: "POST", - body: JSON.stringify({ - userId: payload.userId, - password: payload.password, - }), - }); + public async login(payload: ILogin): Promise { + try { + // ✅ 로그인 요청 전에 deviceId 생성/결정 + // - 같은 브라우저 재로그인 시: 기존 쿠키 재사용 → 동일 Redis 슬롯 유지 + // - 최초 로그인 / 로그아웃 후 재로그인 시: 새 UUID 생성 → 새 Redis 슬롯 + const existingDeviceId = tokenManager.getDeviceId(); + const deviceId = existingDeviceId ?? crypto.randomUUID(); - const data = await response.json(); - const accessToken = data[ACCESS_TOKEN]; - const tokenId = data[AUTH_USER_ID]; + const response = await fetch( + `/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, + { + method: "POST", + headers: { + // ✅ 로그인 요청 헤더에 X-Device-Id 포함 + // BFF route.ts → 백엔드 AuthenticationFilter → TokenProvider.createTokenAndAddHeader() + // → userService.updateRefreshToken(userUnqId, refreshToken, deviceId) + // → Redis Hash[user:sessions:{userUnqId}][deviceId] = refreshToken (TTL 1일) + "X-Device-Id": deviceId, + }, + body: JSON.stringify({ + userId: payload.userId, + password: payload.password, + }), + }, + ); - if (!accessToken || !tokenId) { - throw new Error("Missing required authentication tokens"); - } + const data = await response.json(); + const accessToken = data[ACCESS_TOKEN]; + const tokenId = data[AUTH_USER_ID]; - const prevTokenId = getCookie(AUTH_USER_ID); - if (prevTokenId !== undefined && prevTokenId !== tokenId) { - clearTabStorePersist(); - } + if (!accessToken || !tokenId) { + throw new Error("Missing required authentication tokens"); + } - tokenManager.setToken(CLAIM_NAME, accessToken); - tokenManager.setToken(AUTH_USER_ID, tokenId); - tokenManager.setupRefreshTimer( - () => this.silentRefresh(), - JWT_EXPIRATION_TIME, - ); - } catch (error) { - if (error instanceof AxiosError) { - throw new Error(`Login failed: ${error.response?.data?.message}`); - } - throw new Error(`Login failed: ${error}`); + const prevTokenId = tokenManager.getToken(AUTH_USER_ID); + if (prevTokenId !== undefined && prevTokenId !== tokenId) { + clearTabStorePersist(); + } + + // ✅ 로그인 성공 후 deviceId 쿠키 저장 (maxAge=86400, Refresh Token TTL과 동기화) + tokenManager.setDeviceId(deviceId); + + tokenManager.setToken(CLAIM_NAME, accessToken); + tokenManager.setToken(AUTH_USER_ID, tokenId); + tokenManager.setupRefreshTimer( + () => this.silentRefresh(), + JWT_EXPIRATION_TIME, + ); + } catch (error) { + if (error instanceof AxiosError) { + throw new Error( + `Login failed: ${error.response?.data?.message}`, + ); + } + throw new Error(`Login failed: ${error}`); + } } - } - public async silentRefresh(): Promise { - try { - const response = await fetch( - `/api/proxy${ENDPOINT.USER_SERVICE.SILENT_REFRESH}`, - { method: "PUT" }, - ); + public async silentRefresh(): Promise { + try { + // deviceId는 BFF(route.ts)가 쿠키(device-id)에서 읽어 X-Device-Id 헤더로 자동 전달 + // → 백엔드 TokenProvider.refreshToken(token, request, response) + // → userService.findByRefreshToken(refreshToken, deviceId) → Redis Hash 검증 + // → userService.updateRefreshToken(userUnqId, newRefreshToken, deviceId) → RTR 갱신 + const response = await fetch( + `/api/proxy${ENDPOINT.USER_SERVICE.SILENT_REFRESH}`, + { method: "PUT" }, + ); - const data = await response.json(); - const accessToken = data[ACCESS_TOKEN]; - const tokenId = data[AUTH_USER_ID]; + const data = await response.json(); + const accessToken = data[ACCESS_TOKEN]; + const tokenId = data[AUTH_USER_ID]; - if (!accessToken || !tokenId) { - throw new Error("Missing required authentication tokens"); - } + if (!accessToken || !tokenId) { + throw new Error("Missing required authentication tokens"); + } - tokenManager.setToken(CLAIM_NAME, accessToken); - tokenManager.setToken(AUTH_USER_ID, tokenId); - } catch (error) { - tokenManager.clearAuthToken(); - if (error instanceof AxiosError) { - throw new Error( - `Silent refresh failed: ${error.response?.data?.message}`, - ); - } - throw new Error(`Silent refresh failed: ${error}`); + tokenManager.setToken(CLAIM_NAME, accessToken); + tokenManager.setToken(AUTH_USER_ID, tokenId); + } catch (error) { + tokenManager.clearAuthToken(); + if (error instanceof AxiosError) { + throw new Error( + `Silent refresh failed: ${error.response?.data?.message}`, + ); + } + throw new Error(`Silent refresh failed: ${error}`); + } } - } } export const LoginApi = LoginApiService.getInstance(); diff --git a/apps/st/src/entities/auth/lib/token-manager.ts b/apps/st/src/entities/auth/lib/token-manager.ts index 60cd47de5..4d8b8a85c 100644 --- a/apps/st/src/entities/auth/lib/token-manager.ts +++ b/apps/st/src/entities/auth/lib/token-manager.ts @@ -1,85 +1,113 @@ -import { AUTH_USER_ID, CLAIM_NAME } from "@/shared/config"; -import { getCookie, removeCookie,setCookie } from "@/shared/lib"; +import { AUTH_USER_ID, CLAIM_NAME, DEVICE_ID } from "@/shared/config"; +import { getCookie, removeCookie, setCookie } from "@/shared/lib"; import { clearTabStorePersist } from "@/store/tab-store"; export class TokenManager { - private static instance: TokenManager; - private refreshTimer: NodeJS.Timeout | null = null; + private static instance: TokenManager; + private refreshTimer: NodeJS.Timeout | null = null; - private constructor() {} + private constructor() {} - public static getInstance(): TokenManager { - if (!TokenManager.instance) { - TokenManager.instance = new TokenManager(); - } - return TokenManager.instance; - } - - public getToken(key: string): string | undefined { - return getCookie(key); - } - - public setToken(key: string, value: string): void { - const domain = this.getCookieDomain(); - - setCookie(key, value, { - secure: window.location.protocol === "https:", - sameSite: "lax", - domain: domain, - path: "/", // 모든 경로에서 접근 가능 - }); - } - - - private getCookieDomain(): string | undefined { - const hostname = window.location.hostname; - - // 개발 환경에서는 localhost 도메인 사용 - if (hostname === "localhost" || hostname === "127.0.0.1") { - return undefined; // localhost에서는 도메인 설정하지 않음 - } - - // 운영 환경에서는 서브도메인을 포함한 최상위 도메인 사용 - if (hostname.includes("hasuon.or.kr")) { - return ".hasuon.or.kr"; - } - - // 기타 환경에서는 현재 호스트명 사용 - return hostname; - } - - public removeToken(key: string): void { - const domain = this.getCookieDomain(); - removeCookie(key, { domain, path: "/" }); - } - - public setupRefreshTimer( - callback: () => Promise, - expirationTime: number, - ): void { - if (this.refreshTimer) { - clearTimeout(this.refreshTimer); + public static getInstance(): TokenManager { + if (!TokenManager.instance) { + TokenManager.instance = new TokenManager(); + } + return TokenManager.instance; } - this.refreshTimer = setTimeout(() => { - callback().catch((error) => { - console.error("Silent refresh failed:", error); - this.clearAuthToken(); - }); - }, expirationTime - 60000); - } - - public clearAuthToken(): void { - if (this.refreshTimer) { - clearTimeout(this.refreshTimer); - this.refreshTimer = null; + public getToken(key: string): string | undefined { + return getCookie(key); } - clearTabStorePersist(); - this.removeToken(CLAIM_NAME); - this.removeToken(AUTH_USER_ID); - } + public setToken(key: string, value: string): void { + const domain = this.getCookieDomain(); + + setCookie(key, value, { + secure: window.location.protocol === "https:", + sameSite: "lax", + domain: domain, + path: "/", // 모든 경로에서 접근 가능 + }); + } + + private getCookieDomain(): string | undefined { + const hostname = window.location.hostname; + + // 개발 환경에서는 localhost 도메인 사용 + if (hostname === "localhost" || hostname === "127.0.0.1") { + return undefined; // localhost에서는 도메인 설정하지 않음 + } + + // 운영 환경에서는 서브도메인을 포함한 최상위 도메인 사용 + if (hostname.includes("hasuon.or.kr")) { + return ".hasuon.or.kr"; + } + + // 기타 환경에서는 현재 호스트명 사용 + return hostname; + } + + public removeToken(key: string): void { + const domain = this.getCookieDomain(); + removeCookie(key, { domain, path: "/" }); + } + + /** + * deviceId 쿠키 저장 + * - httpOnly가 아닌 일반 쿠키로 저장 (BFF route.ts에서 cookieStore.get()으로 읽기 위함) + * - js-cookie v3는 maxAge 속성을 지원하지 않음 + * (number 타입에 .split() 호출 → TypeError: attributes[attributeName].split is not a function) + * - maxAge(초) → expires(Date) 변환: 1일 = 86400초 + * Refresh Token TTL(1일)과 동기화 → 브라우저 재시작 후에도 동일 Redis 슬롯 재사용 + */ + public setDeviceId(deviceId: string): void { + const domain = this.getCookieDomain(); + // js-cookie가 Date 객체를 받아 toUTCString()으로 직렬화함 + const expires = new Date(Date.now() + 86400 * 1000); + setCookie(DEVICE_ID, deviceId, { + secure: window.location.protocol === "https:", + sameSite: "lax", + domain: domain, + path: "/", + expires, // maxAge 대신 expires(Date) 사용 + }); + } + + /** + * deviceId 쿠키 조회 + */ + public getDeviceId(): string | undefined { + return getCookie(DEVICE_ID); + } + + public setupRefreshTimer( + callback: () => Promise, + expirationTime: number, + ): void { + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + } + + this.refreshTimer = setTimeout(() => { + callback().catch((error) => { + console.error("Silent refresh failed:", error); + this.clearAuthToken(); + }); + }, expirationTime - 60000); + } + + public clearAuthToken(): void { + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + this.refreshTimer = null; + } + + clearTabStorePersist(); + this.removeToken(CLAIM_NAME); + this.removeToken(AUTH_USER_ID); + this.removeToken(DEVICE_ID); // deviceId 함께 삭제 + } } export const tokenManager = TokenManager.getInstance(); diff --git a/apps/st/src/shared/config/env.ts b/apps/st/src/shared/config/env.ts index ded87ac3e..3cfc63912 100644 --- a/apps/st/src/shared/config/env.ts +++ b/apps/st/src/shared/config/env.ts @@ -1,12 +1,14 @@ export const PORT = process.env.NEXT_PUBLIC_PORT || "3000"; export const PROXY_HOST = - process.env.NEXT_PUBLIC_PROXY_HOST || `http://localhost:${PORT}`; + process.env.NEXT_PUBLIC_PROXY_HOST || `http://localhost:${PORT}`; export const SERVER_API_URL = process.env.NEXT_PUBLIC_SERVER_API_URL; export const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "ST"; export const CLAIM_NAME = process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization"; export const ACCESS_TOKEN = - process.env.NEXT_PUBLIC_ACCESS_TOKEN || "access-token"; + process.env.NEXT_PUBLIC_ACCESS_TOKEN || "access-token"; export const REFRESH_TOKEN = - process.env.NEXT_PUBLIC_REFRESH_TOKEN || "refresh-token"; + process.env.NEXT_PUBLIC_REFRESH_TOKEN || "refresh-token"; export const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id"; -export const ST_SERVICE_URL = process.env.NEXT_PUBLIC_ST_SERVICE_URL || "http://localhost:8080"; +export const ST_SERVICE_URL = + process.env.NEXT_PUBLIC_ST_SERVICE_URL || "http://localhost:8080"; +export const DEVICE_ID = process.env.NEXT_PUBLIC_DEVICE_ID || "device-id";