Compare commits

...
11 changed files with 455 additions and 257 deletions
+1
View File
@@ -30,3 +30,4 @@ settings.json
apps/**/tsconfig.tsbuildinfo
logs/
apps/st/start_cs.log
+2
View File
@@ -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 });
}
+47 -7
View File
@@ -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();
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) {
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[] }> },
+30 -9
View File
@@ -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<void> {
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<void> {
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" },
+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";
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<void>,
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 함께 삭제
}
}
+1
View File
@@ -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";
+12 -10
View File
@@ -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 });
}
+129 -75
View File
@@ -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<string, string> = {
"Content-Type": "application/json",
"X-Site-Id": SITE_ID,
};
let body = {};
const isRefreshHandler = params.url.includes("refresh");
const cookieStore = await cookies();
const headers: Record<string, string> = {
"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 },
);
}
const accessToken = result.headers.get(ACCESS_TOKEN);
const refreshToken = result.headers.get(REFRESH_TOKEN);
const userId = result.headers.get(AUTH_USER_ID);
if (refreshToken) {
cookieStore.set(REFRESH_TOKEN, refreshToken, {
httpOnly: true,
sameSite: "lax",
// domain: domain,
path: "/",
});
}
if (accessToken && userId) {
const response = NextResponse.json({
[ACCESS_TOKEN]: accessToken,
[AUTH_USER_ID]: userId,
});
return response;
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");
}
+93 -68
View File
@@ -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<void> {
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<void> {
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<void> {
try {
const response = await fetch(
`/api/proxy${ENDPOINT.USER_SERVICE.SILENT_REFRESH}`,
{ method: "PUT" },
);
public async silentRefresh(): Promise<void> {
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();
+94 -66
View File
@@ -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에서는 도메인 설정하지 않음
public static getInstance(): TokenManager {
if (!TokenManager.instance) {
TokenManager.instance = new TokenManager();
}
return TokenManager.instance;
}
// 운영 환경에서는 서브도메인을 포함한 최상위 도메인 사용
if (hostname.includes("hasuon.or.kr")) {
return ".hasuon.or.kr";
public getToken(key: string): string | undefined {
return getCookie(key);
}
// 기타 환경에서는 현재 호스트명 사용
return hostname;
}
public setToken(key: string, value: string): void {
const domain = this.getCookieDomain();
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);
setCookie(key, value, {
secure: window.location.protocol === "https:",
sameSite: "lax",
domain: domain,
path: "/", // 모든 경로에서 접근 가능
});
}
this.refreshTimer = setTimeout(() => {
callback().catch((error) => {
console.error("Silent refresh failed:", error);
this.clearAuthToken();
});
}, expirationTime - 60000);
}
private getCookieDomain(): string | undefined {
const hostname = window.location.hostname;
public clearAuthToken(): void {
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
this.refreshTimer = null;
// 개발 환경에서는 localhost 도메인 사용
if (hostname === "localhost" || hostname === "127.0.0.1") {
return undefined; // localhost에서는 도메인 설정하지 않음
}
// 운영 환경에서는 서브도메인을 포함한 최상위 도메인 사용
if (hostname.includes("hasuon.or.kr")) {
return ".hasuon.or.kr";
}
// 기타 환경에서는 현재 호스트명 사용
return hostname;
}
clearTabStorePersist();
this.removeToken(CLAIM_NAME);
this.removeToken(AUTH_USER_ID);
}
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();
+6 -4
View File
@@ -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";