[user service] 사용자 멀티 디바이스 로그인 처리 deviceId 추가건

This commit is contained in:
2026-03-25 16:51:24 +09:00
parent dc8006899e
commit b204c14e95
11 changed files with 455 additions and 257 deletions
+48 -8
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();
// 운영 환경에서 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[] }> },