Compare commits

...
11 changed files with 455 additions and 257 deletions
+1
View File
@@ -30,3 +30,4 @@ settings.json
apps/**/tsconfig.tsbuildinfo apps/**/tsconfig.tsbuildinfo
logs/ logs/
apps/st/start_cs.log
+2
View File
@@ -5,6 +5,7 @@ import {
AUTH_USER_ID, AUTH_USER_ID,
CLAIM_NAME, CLAIM_NAME,
REFRESH_TOKEN, REFRESH_TOKEN,
DEVICE_ID,
} from "@/shared/config"; } from "@/shared/config";
export async function POST() { export async function POST() {
@@ -13,5 +14,6 @@ export async function POST() {
cookieStore.delete(REFRESH_TOKEN); cookieStore.delete(REFRESH_TOKEN);
cookieStore.delete(AUTH_USER_ID); cookieStore.delete(AUTH_USER_ID);
cookieStore.delete(CLAIM_NAME); cookieStore.delete(CLAIM_NAME);
cookieStore.delete(DEVICE_ID); // 로그아웃 시 deviceId 함께 삭제 → 재로그인 시 새 세션 생성
return NextResponse.json({ message: "Logged out" }, { status: 200 }); return NextResponse.json({ message: "Logged out" }, { status: 200 });
} }
+48 -8
View File
@@ -5,6 +5,7 @@ import {
REFRESH_TOKEN, REFRESH_TOKEN,
SERVER_API_URL, SERVER_API_URL,
SITE_ID, SITE_ID,
DEVICE_ID,
} from "@/shared/config"; } from "@/shared/config";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
@@ -20,9 +21,16 @@ async function handler(
"X-Site-Id": SITE_ID, "X-Site-Id": SITE_ID,
}; };
let body = {}; 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) { if (isRefreshHandler) {
const refreshToken = cookieStore.get(REFRESH_TOKEN); const refreshToken = cookieStore.get(REFRESH_TOKEN);
headers[CLAIM_NAME] = refreshToken?.value ?? ""; headers[CLAIM_NAME] = refreshToken?.value ?? "";
@@ -30,6 +38,27 @@ async function handler(
if (!headers[CLAIM_NAME]) { if (!headers[CLAIM_NAME]) {
return NextResponse.json({ message: "Can't refresh" }, { status: 401 }); 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 { try {
@@ -62,16 +91,28 @@ async function handler(
const forwardedHost = request.headers.get("x-forwarded-host"); const forwardedHost = request.headers.get("x-forwarded-host");
const host = request.headers.get("host"); const host = request.headers.get("host");
const actualHost = forwardedHost || host || new URL(request.url).hostname.toString(); const rawHost =
forwardedHost || host || new URL(request.url).hostname.toString();
// 운영 환경에서 domain 설정
const domain = actualHost.includes("hasuon.or.kr") ? ".hasuon.or.kr" : actualHost; // 포트 번호 제거 (예: "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) { if (refreshToken) {
cookieStore.set(REFRESH_TOKEN, refreshToken, { cookieStore.set(REFRESH_TOKEN, refreshToken, {
httpOnly: true, httpOnly: true,
sameSite: "strict", sameSite: "strict",
domain: domain, ...(domain !== undefined && { domain }),
path: "/", path: "/",
}); });
} }
@@ -91,7 +132,6 @@ async function handler(
} }
} }
export async function POST( export async function POST(
request: Request, request: Request,
props: { params: Promise<{ url: string[] }> }, props: { params: Promise<{ url: string[] }> },
+31 -10
View File
@@ -5,6 +5,7 @@ import {
ACCESS_TOKEN, ACCESS_TOKEN,
AUTH_USER_ID, AUTH_USER_ID,
CLAIM_NAME, CLAIM_NAME,
DEVICE_ID,
} from "@/shared/config"; } from "@/shared/config";
import type { LoginService, ILogin } from "../model/login-interface"; import type { LoginService, ILogin } from "../model/login-interface";
import { tokenManager } from "../lib/token-manager"; import { tokenManager } from "../lib/token-manager";
@@ -23,8 +24,21 @@ export class LoginApiService implements LoginService {
public async login(payload: ILogin): Promise<void> { public async login(payload: ILogin): Promise<void> {
try { try {
// ✅ 로그인 요청 전에 deviceId 생성/결정
// - 같은 브라우저 재로그인 시: 기존 쿠키 재사용 → 동일 Redis 슬롯 유지
// - 최초 로그인 / 로그아웃 후 재로그인 시: 새 UUID 생성 → 새 Redis 슬롯
const existingDeviceId = tokenManager.getDeviceId();
const deviceId = existingDeviceId ?? crypto.randomUUID();
const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, { const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, {
method: "POST", 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({ body: JSON.stringify({
userId: payload.userId, userId: payload.userId,
password: payload.password, password: payload.password,
@@ -32,24 +46,27 @@ export class LoginApiService implements LoginService {
}); });
const data = await response.json(); const data = await response.json();
console.log('data ', data) console.log("data ", data);
const accessToken = data[ACCESS_TOKEN]; const accessToken = data[ACCESS_TOKEN];
const tokenId = data[AUTH_USER_ID]; const tokenId = data[AUTH_USER_ID];
if (!accessToken || !tokenId) { if (!accessToken || !tokenId) {
if (data.message == 'Invalid Credentials' ) { if (data.message == "Invalid Credentials") {
throw new Error('아이디와 비밀번호가 일치하지 않습니다.'); throw new Error("아이디와 비밀번호가 일치하지 않습니다.");
} else if (data.message == 'User not found') { } else if (data.message == "User not found") {
throw new Error('존재하지 않는 아이디입니다.'); throw new Error("존재하지 않는 아이디입니다.");
} else if (data.message == 'User is not active') { } else if (data.message == "User is not active") {
throw new Error('활성화되지 않은 계정입니다.'); throw new Error("활성화되지 않은 계정입니다.");
} else if (data.message == 'User is not verified') { } else if (data.message == "User is not verified") {
throw new Error('이메일 인증이 필요합니다.'); throw new Error("이메일 인증이 필요합니다.");
} else { } else {
throw new Error(data.message); throw new Error(data.message);
} }
} }
// ✅ 로그인 성공 후 deviceId 쿠키 저장 (maxAge=86400, Refresh Token TTL과 동기화)
tokenManager.setDeviceId(deviceId);
tokenManager.setToken(CLAIM_NAME, accessToken); tokenManager.setToken(CLAIM_NAME, accessToken);
tokenManager.setToken(AUTH_USER_ID, tokenId); tokenManager.setToken(AUTH_USER_ID, tokenId);
tokenManager.setupRefreshTimer( tokenManager.setupRefreshTimer(
@@ -66,6 +83,10 @@ export class LoginApiService implements LoginService {
public async silentRefresh(): Promise<void> { public async silentRefresh(): Promise<void> {
try { 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( const response = await fetch(
`/api/proxy${ENDPOINT.USER_SERVICE.SILENT_REFRESH}`, `/api/proxy${ENDPOINT.USER_SERVICE.SILENT_REFRESH}`,
{ method: "PUT" }, { method: "PUT" },
+31 -9
View File
@@ -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"; import { getCookie, setCookie, removeCookie } from "@/shared/lib";
export class TokenManager { export class TokenManager {
@@ -20,22 +20,18 @@ export class TokenManager {
public setToken(key: string, value: string): void { public setToken(key: string, value: string): void {
const domain = this.getCookieDomain(); const domain = this.getCookieDomain();
setCookie(key, value, { setCookie(key, value, {
secure: window.location.protocol === "https:", secure: window.location.protocol === "https:",
sameSite: "lax", sameSite: "lax",
domain: domain, domain: domain,
path: "/", // 모든 경로에서 접근 가능 path: "/",
}); });
} }
private getCookieDomain(): string | undefined { private getCookieDomain(): string | undefined {
const hostname = window.location.hostname; const hostname = window.location.hostname;
// 개발 환경에서는 localhost 도메인 사용
if (hostname === "localhost" || hostname === "127.0.0.1") { if (hostname === "localhost" || hostname === "127.0.0.1") {
return undefined; // localhost에서는 도메인 설정하지 않음 return undefined;
} }
// 운영 환경에서는 서브도메인을 포함한 최상위 도메인 사용 // 운영 환경에서는 서브도메인을 포함한 최상위 도메인 사용
@@ -52,6 +48,33 @@ export class TokenManager {
removeCookie(key, { domain, path: "/" }); 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( public setupRefreshTimer(
callback: () => Promise<void>, callback: () => Promise<void>,
expirationTime: number, expirationTime: number,
@@ -59,7 +82,6 @@ export class TokenManager {
if (this.refreshTimer) { if (this.refreshTimer) {
clearTimeout(this.refreshTimer); clearTimeout(this.refreshTimer);
} }
this.refreshTimer = setTimeout(() => { this.refreshTimer = setTimeout(() => {
callback().catch((error) => { callback().catch((error) => {
console.error("Silent refresh failed:", error); console.error("Silent refresh failed:", error);
@@ -73,9 +95,9 @@ export class TokenManager {
clearTimeout(this.refreshTimer); clearTimeout(this.refreshTimer);
this.refreshTimer = null; this.refreshTimer = null;
} }
this.removeToken(CLAIM_NAME); this.removeToken(CLAIM_NAME);
this.removeToken(AUTH_USER_ID); this.removeToken(AUTH_USER_ID);
this.removeToken(DEVICE_ID); // deviceId 함께 삭제
} }
} }
+1
View File
@@ -9,3 +9,4 @@ export const ACCESS_TOKEN =
export const REFRESH_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 AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id";
export const DEVICE_ID = process.env.NEXT_PUBLIC_DEVICE_ID || "device-id";
+12 -10
View File
@@ -1,17 +1,19 @@
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { import {
ACCESS_TOKEN, ACCESS_TOKEN,
AUTH_USER_ID, AUTH_USER_ID,
CLAIM_NAME, CLAIM_NAME,
REFRESH_TOKEN, DEVICE_ID,
REFRESH_TOKEN,
} from "@/shared/config"; } from "@/shared/config";
export async function POST() { export async function POST() {
const cookieStore = await cookies(); const cookieStore = await cookies();
cookieStore.delete(ACCESS_TOKEN); cookieStore.delete(ACCESS_TOKEN);
cookieStore.delete(REFRESH_TOKEN); cookieStore.delete(REFRESH_TOKEN);
cookieStore.delete(AUTH_USER_ID); cookieStore.delete(AUTH_USER_ID);
cookieStore.delete(CLAIM_NAME); cookieStore.delete(CLAIM_NAME);
return NextResponse.json({ message: "Logged out" }, { status: 200 }); cookieStore.delete(DEVICE_ID);
return NextResponse.json({ message: "Logged out" }, { status: 200 });
} }
+130 -76
View File
@@ -1,104 +1,158 @@
import { import {
ACCESS_TOKEN, ACCESS_TOKEN,
AUTH_USER_ID, AUTH_USER_ID,
CLAIM_NAME, CLAIM_NAME,
REFRESH_TOKEN, DEVICE_ID,
SERVER_API_URL, REFRESH_TOKEN,
SITE_ID, SERVER_API_URL,
SITE_ID,
} from "@/shared/config"; } from "@/shared/config";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
async function handler( async function handler(
request: Request, request: Request,
params: { url: string[] }, params: { url: string[] },
method: "GET" | "POST" | "PUT" | "DELETE", method: "GET" | "POST" | "PUT" | "DELETE",
) { ) {
const cookieStore = await cookies(); const cookieStore = await cookies();
const headers: Record<string, string> = { const headers: Record<string, string> = {
"Content-Type": "application/json", "Content-Type": "application/json",
"X-Site-Id": SITE_ID, "X-Site-Id": SITE_ID,
}; };
let body = {}; let body = {};
const isRefreshHandler = params.url.includes("refresh");
// silent refresh const isRefreshHandler = params.url.includes("refresh");
if (isRefreshHandler) { const isLoginHandler = params.url.includes("login");
const refreshToken = cookieStore.get(REFRESH_TOKEN);
headers[CLAIM_NAME] = refreshToken?.value ?? "";
if (!headers[CLAIM_NAME]) { // ── Silent Refresh ──────────────────────────────────────────────────────────
return NextResponse.json({ message: "Can't refresh" }, { status: 401 }); // 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 (!headers[CLAIM_NAME]) {
if (!isRefreshHandler) { return NextResponse.json(
body = await request.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("/")}`, { // ── Login ────────────────────────────────────────────────────────────────────
method, // 클라이언트(login-service.ts)가 fetch 헤더에 X-Device-Id 를 포함해서 전송한다.
headers, // BFF는 해당 헤더를 그대로 백엔드로 포워딩한다.
body: JSON.stringify(body), // 백엔드: AuthenticationFilter.successfulAuthentication
}); // → TokenProvider.createTokenAndAddHeader(request, ...)
// → request.getHeader("X-Device-Id") 로 deviceId 추출
if (!result.ok) { // → userService.updateRefreshToken(userUnqId, refreshToken, deviceId)
return NextResponse.json( // → Redis Hash[user:sessions:{userUnqId}][deviceId] = refreshToken (TTL 1일)
{ message: "Invalid Credentials" }, if (isLoginHandler) {
{ status: 401 }, const deviceId = request.headers.get("X-Device-Id");
); if (deviceId) {
headers["X-Device-Id"] = deviceId;
}
} }
const accessToken = result.headers.get(ACCESS_TOKEN); try {
const refreshToken = result.headers.get(REFRESH_TOKEN); if (!isRefreshHandler) {
const userId = result.headers.get(AUTH_USER_ID); body = await request.json();
}
if (accessToken && userId) { const result = await fetch(
const response = NextResponse.json({ `${SERVER_API_URL}/${params.url.join("/")}`,
[ACCESS_TOKEN]: accessToken, {
[AUTH_USER_ID]: userId, method,
}); headers,
body: JSON.stringify(body),
},
);
if (!result.ok) {
return NextResponse.json(
{ message: "Invalid Credentials" },
{ status: 401 },
);
}
if (refreshToken) { const accessToken = result.headers.get(ACCESS_TOKEN);
cookieStore.set(REFRESH_TOKEN, refreshToken, { const refreshToken = result.headers.get(REFRESH_TOKEN);
httpOnly: true, const userId = result.headers.get(AUTH_USER_ID);
sameSite: "lax",
// domain: domain,
path: "/",
});
}
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( export async function POST(
request: Request, request: Request,
props: { params: Promise<{ url: string[] }> }, props: { params: Promise<{ url: string[] }> },
) { ) {
const params = await props.params; const params = await props.params;
return handler(request, params, "POST"); return handler(request, params, "POST");
} }
export async function PUT( export async function PUT(
request: Request, request: Request,
props: { params: Promise<{ url: string[] }> }, props: { params: Promise<{ url: string[] }> },
) { ) {
const params = await props.params; const params = await props.params;
return handler(request, params, "PUT"); return handler(request, params, "PUT");
} }
+93 -68
View File
@@ -1,95 +1,120 @@
import { AxiosError } from "axios"; import { AxiosError } from "axios";
import { import {
ACCESS_TOKEN, ACCESS_TOKEN,
AUTH_USER_ID, AUTH_USER_ID,
CLAIM_NAME, CLAIM_NAME,
ENDPOINT, DEVICE_ID,
JWT_EXPIRATION_TIME, ENDPOINT,
JWT_EXPIRATION_TIME,
} from "@/shared/config"; } from "@/shared/config";
import { getCookie } from "@/shared/lib";
import { tokenManager } from "../lib/token-manager"; 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"; import { clearTabStorePersist } from "@/store/tab-store";
export class LoginApiService implements LoginService { export class LoginApiService implements LoginService {
private static instance: LoginApiService; private static instance: LoginApiService;
private constructor() {} private constructor() {}
public static getInstance(): LoginApiService { public static getInstance(): LoginApiService {
if (!LoginApiService.instance) { if (!LoginApiService.instance) {
LoginApiService.instance = new LoginApiService(); LoginApiService.instance = new LoginApiService();
}
return LoginApiService.instance;
} }
return LoginApiService.instance;
}
public async login(payload: ILogin): Promise<void> { public async login(payload: ILogin): Promise<void> {
try { try {
const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, { // ✅ 로그인 요청 전에 deviceId 생성/결정
method: "POST", // - 같은 브라우저 재로그인 시: 기존 쿠키 재사용 → 동일 Redis 슬롯 유지
body: JSON.stringify({ // - 최초 로그인 / 로그아웃 후 재로그인 시: 새 UUID 생성 → 새 Redis 슬롯
userId: payload.userId, const existingDeviceId = tokenManager.getDeviceId();
password: payload.password, const deviceId = existingDeviceId ?? crypto.randomUUID();
}),
});
const data = await response.json(); const response = await fetch(
const accessToken = data[ACCESS_TOKEN]; `/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`,
const tokenId = data[AUTH_USER_ID]; {
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) { const data = await response.json();
throw new Error("Missing required authentication tokens"); const accessToken = data[ACCESS_TOKEN];
} const tokenId = data[AUTH_USER_ID];
const prevTokenId = getCookie(AUTH_USER_ID); if (!accessToken || !tokenId) {
if (prevTokenId !== undefined && prevTokenId !== tokenId) { throw new Error("Missing required authentication tokens");
clearTabStorePersist(); }
}
tokenManager.setToken(CLAIM_NAME, accessToken); const prevTokenId = tokenManager.getToken(AUTH_USER_ID);
tokenManager.setToken(AUTH_USER_ID, tokenId); if (prevTokenId !== undefined && prevTokenId !== tokenId) {
tokenManager.setupRefreshTimer( clearTabStorePersist();
() => this.silentRefresh(), }
JWT_EXPIRATION_TIME,
); // ✅ 로그인 성공 후 deviceId 쿠키 저장 (maxAge=86400, Refresh Token TTL과 동기화)
} catch (error) { tokenManager.setDeviceId(deviceId);
if (error instanceof AxiosError) {
throw new Error(`Login failed: ${error.response?.data?.message}`); tokenManager.setToken(CLAIM_NAME, accessToken);
} tokenManager.setToken(AUTH_USER_ID, tokenId);
throw new Error(`Login failed: ${error}`); 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<void> { public async silentRefresh(): Promise<void> {
try { try {
const response = await fetch( // deviceId는 BFF(route.ts)가 쿠키(device-id)에서 읽어 X-Device-Id 헤더로 자동 전달
`/api/proxy${ENDPOINT.USER_SERVICE.SILENT_REFRESH}`, // → 백엔드 TokenProvider.refreshToken(token, request, response)
{ method: "PUT" }, // → 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 data = await response.json();
const accessToken = data[ACCESS_TOKEN]; const accessToken = data[ACCESS_TOKEN];
const tokenId = data[AUTH_USER_ID]; const tokenId = data[AUTH_USER_ID];
if (!accessToken || !tokenId) { if (!accessToken || !tokenId) {
throw new Error("Missing required authentication tokens"); throw new Error("Missing required authentication tokens");
} }
tokenManager.setToken(CLAIM_NAME, accessToken); tokenManager.setToken(CLAIM_NAME, accessToken);
tokenManager.setToken(AUTH_USER_ID, tokenId); tokenManager.setToken(AUTH_USER_ID, tokenId);
} catch (error) { } catch (error) {
tokenManager.clearAuthToken(); tokenManager.clearAuthToken();
if (error instanceof AxiosError) { if (error instanceof AxiosError) {
throw new Error( throw new Error(
`Silent refresh failed: ${error.response?.data?.message}`, `Silent refresh failed: ${error.response?.data?.message}`,
); );
} }
throw new Error(`Silent refresh failed: ${error}`); throw new Error(`Silent refresh failed: ${error}`);
}
} }
}
} }
export const LoginApi = LoginApiService.getInstance(); export const LoginApi = LoginApiService.getInstance();
+100 -72
View File
@@ -1,85 +1,113 @@
import { AUTH_USER_ID, CLAIM_NAME } from "@/shared/config"; import { AUTH_USER_ID, CLAIM_NAME, DEVICE_ID } from "@/shared/config";
import { getCookie, removeCookie,setCookie } from "@/shared/lib"; import { getCookie, removeCookie, setCookie } from "@/shared/lib";
import { clearTabStorePersist } from "@/store/tab-store"; import { clearTabStorePersist } from "@/store/tab-store";
export class TokenManager { export class TokenManager {
private static instance: TokenManager; private static instance: TokenManager;
private refreshTimer: NodeJS.Timeout | null = null; private refreshTimer: NodeJS.Timeout | null = null;
private constructor() {} private constructor() {}
public static getInstance(): TokenManager { public static getInstance(): TokenManager {
if (!TokenManager.instance) { if (!TokenManager.instance) {
TokenManager.instance = new TokenManager(); TokenManager.instance = new TokenManager();
} }
return TokenManager.instance; 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<void>,
expirationTime: number,
): void {
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
} }
this.refreshTimer = setTimeout(() => { public getToken(key: string): string | undefined {
callback().catch((error) => { return getCookie(key);
console.error("Silent refresh failed:", error);
this.clearAuthToken();
});
}, expirationTime - 60000);
}
public clearAuthToken(): void {
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
this.refreshTimer = null;
} }
clearTabStorePersist(); public setToken(key: string, value: string): void {
this.removeToken(CLAIM_NAME); const domain = this.getCookieDomain();
this.removeToken(AUTH_USER_ID);
} 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<void>,
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(); export const tokenManager = TokenManager.getInstance();
+6 -4
View File
@@ -1,12 +1,14 @@
export const PORT = process.env.NEXT_PUBLIC_PORT || "3000"; export const PORT = process.env.NEXT_PUBLIC_PORT || "3000";
export const PROXY_HOST = 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 SERVER_API_URL = process.env.NEXT_PUBLIC_SERVER_API_URL;
export const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "ST"; export const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "ST";
export const CLAIM_NAME = process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization"; export const CLAIM_NAME = process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization";
export const ACCESS_TOKEN = export const ACCESS_TOKEN =
process.env.NEXT_PUBLIC_ACCESS_TOKEN || "access-token"; process.env.NEXT_PUBLIC_ACCESS_TOKEN || "access-token";
export const REFRESH_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 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";