[cm] feat: auth 서비스 수정 및 jwt 기반 로그인 만료 구현

This commit is contained in:
2026-07-08 18:52:30 +09:00
parent 8abf32bb53
commit 0dfd5efa97
17 changed files with 270 additions and 274 deletions
+2 -2
View File
@@ -1,10 +1,10 @@
# apps/cs/src/shared/config/env.ts 참고
NEXT_PUBLIC_AUTH_LOGIN_REDIRECT_URL=/auth/login
NEXT_PUBLIC_PORT=3001
NEXT_PUBLIC_PROXY_HOST=http://localhost:3001
NEXT_PUBLIC_SITE_ID=CM
NEXT_PUBLIC_SUB_DOMAIN=http://localhost:13000
# NEXT_PUBLIC_SERVER_API_URL=http://localhost:8000
NEXT_PUBLIC_SERVER_API_URL=http://dev.onasys.co.kr:8000
SERVER_API_URL=http://dev.onasys.co.kr:8000
NEXT_PUBLIC_LINK_BACKEND_API_HOST=http://rsone.iptime.org:10014
NEXT_PUBLIC_TINY_API_KEY=t9nc4z6inquvvd5pq1vea8wcp7lb01qmqseh6egms9vpaju2
@@ -4,6 +4,7 @@ import { usePathname, useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { NoLNBLayout } from "@common/components";
import type { MenuItem } from "@/entities/menu";
import { authService } from "@/entities/auth";
import { useMenuStore } from "@/shared/lib";
import { PageLayout } from "@/shared/ui/page-layout";
@@ -26,6 +27,7 @@ export default function WithBaseLayout({
return (
<NoLNBLayout
queryClient={queryClient}
authService={authService}
currentPath={pathname ?? ""}
handleMenuClick={handleMenuClick}
>
+10 -14
View File
@@ -8,20 +8,16 @@ import {
SITE_ID,
} from "./src/shared/config";
const serverApiUrl =
process.env.NEXT_PUBLIC_SERVER_API_URL ?? "http://localhost:3000";
const serverApiUrl = process.env.SERVER_API_URL;
const serverApiUrl2 =
process.env.NEXT_PUBLIC_SERVER_API_URL2 ?? serverApiUrl;
const serverApiUrl2 = process.env.NEXT_PUBLIC_SERVER_API_URL2 ?? serverApiUrl;
const serverApiUrl3 =
process.env.NEXT_PUBLIC_SERVER_API_URL3 ?? serverApiUrl;
const serverApiUrl3 = process.env.NEXT_PUBLIC_SERVER_API_URL3 ?? serverApiUrl;
// const serverApiUrl4 =
// process.env.NEXT_PUBLIC_SERVER_API_URL4 ?? serverApiUrl;
// const serverApiUrl4 =
// process.env.NEXT_PUBLIC_SERVER_API_URL4 ?? serverApiUrl;
const serverApiUrl5 =
process.env.NEXT_PUBLIC_SERVER_API_URL5 ?? serverApiUrl;
const serverApiUrl5 = process.env.NEXT_PUBLIC_SERVER_API_URL5 ?? serverApiUrl;
export function middleware(req: NextRequest) {
const pathname = req.nextUrl.pathname;
@@ -51,7 +47,6 @@ export function middleware(req: NextRequest) {
});
}
if (pathname.startsWith("/server/sewerage-service")) {
const requestHeaders = new Headers(req.headers);
const token = req.cookies.get(CLAIM_NAME)?.value;
@@ -173,7 +168,6 @@ export function middleware(req: NextRequest) {
return NextResponse.rewrite(new URL(newUrl));
}
if (pathname === "/") {
return NextResponse.redirect(new URL("/dashboard", req.url));
}
@@ -184,11 +178,13 @@ export function middleware(req: NextRequest) {
}
export const config = {
matcher: ["/server/:path*",
matcher: [
"/server/:path*",
"/attach-service/:path*",
"/user-service/:path*",
"/sewerage-service/:path*",
"/reuse-service/:path*",
"/operation-service/:path*",
"/"],
"/",
],
};
+2 -1
View File
@@ -3,13 +3,14 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"dev": "next dev -p 3001",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@common/assets": "workspace:*",
"@common/auth": "workspace:*",
"@common/components": "workspace:*",
"@common/config": "workspace:*",
"@dnd-kit/core": "^6.3.1",
@@ -0,0 +1,121 @@
import {
ACCESS_TOKEN,
AUTH_USER_ID,
CLAIM_NAME,
ENDPOINT,
} from "@/shared/config";
import {
type AuthPayload,
type AuthService,
getRemainingMs,
} from "@common/auth";
import {
clearRefreshTimer,
removeToken,
setToken,
setupRefreshTimer,
tokenManager,
} from "../lib/token-manager";
function resolveLoginError(message: string | undefined): string {
switch (message) {
case "Invalid Credentials":
return "아이디와 비밀번호가 일치하지 않습니다.";
case "User not found":
return "존재하지 않는 아이디입니다.";
case "User is not active":
return "활성화되지 않은 계정입니다.";
case "User is not verified":
return "이메일 인증이 필요합니다.";
default:
return message ?? "로그인에 실패했습니다.";
}
}
function completeSession(accessToken: string, tokenId: string): void {
setToken(CLAIM_NAME, accessToken);
setToken(AUTH_USER_ID, tokenId);
const remainingMs = getRemainingMs(accessToken);
if (remainingMs > 0) {
setupRefreshTimer(silentRefresh, remainingMs);
}
}
async function login(payload: AuthPayload): Promise<void> {
const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await response.json();
if (!response.ok) {
throw new Error(resolveLoginError(data?.message));
}
const accessToken = data[ACCESS_TOKEN] as string | undefined;
const tokenId = data[AUTH_USER_ID] as string | undefined;
if (!accessToken || !tokenId) {
throw new Error("인증 토큰을 받지 못했습니다.");
}
completeSession(accessToken, tokenId);
}
async function silentRefresh(): Promise<void> {
console.log(`[silentRefresh] Called at ${new Date().toISOString()}`);
try {
const response = await fetch(
`/api/proxy${ENDPOINT.USER_SERVICE.SILENT_REFRESH}`,
{ method: "PUT" },
);
const data = await response.json();
if (!response.ok) {
throw new Error(data?.message ?? "토큰 갱신에 실패했습니다.");
}
const accessToken = data[ACCESS_TOKEN] as string | undefined;
const tokenId = data[AUTH_USER_ID] as string | undefined;
if (!accessToken || !tokenId) {
throw new Error("갱신된 인증 토큰을 받지 못했습니다.");
}
completeSession(accessToken, tokenId);
} catch (error) {
console.error(
`[silentRefresh] Failed at ${new Date().toISOString()}`,
error,
);
tokenManager.clearAuthToken();
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("auth:logout"));
}
}
}
async function logout(): Promise<void> {
tokenManager.clearAuthToken();
removeToken("site-id-key");
removeToken("site-theme-key");
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("auth:logout"));
}
}
export const authService: AuthService & {
completeSession: typeof completeSession;
} = {
login,
silentRefresh,
declineSessionExtend: clearRefreshTimer,
logout,
completeSession,
};
@@ -1,85 +0,0 @@
import { AxiosError } from "axios";
import {
ENDPOINT,
JWT_EXPIRATION_TIME,
ACCESS_TOKEN,
AUTH_USER_ID,
CLAIM_NAME,
} from "@/shared/config";
import type { LoginService, ILogin } from "../model/login-interface";
import { tokenManager } from "../lib/token-manager";
export class LoginApiService implements LoginService {
private static instance: LoginApiService;
private constructor() {}
public static getInstance(): LoginApiService {
if (!LoginApiService.instance) {
LoginApiService.instance = new LoginApiService();
}
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,
}),
});
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");
}
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" },
);
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");
}
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();
+2 -1
View File
@@ -1,2 +1,3 @@
export { LoginApi } from "./api/login-service";
export { authService } from "./api/auth-service";
export { AuthWrapper } from "./lib/auth-wrapper";
export { tokenManager } from "./lib/token-manager";
+53 -17
View File
@@ -2,44 +2,80 @@
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { isAxiosError } from "axios";
import { client } from "@/shared/api";
import {
AUTH_LOGIN_REDIRECT_URL,
AUTH_USER_ID,
CLAIM_NAME,
ENDPOINT,
} from "@/shared/config";
import { useUserStore } from "@/shared/lib";
import type { IUserInfo } from "@/shared/store";
import { getCookie, useUserStore } from "@/shared/lib";
import { getRemainingMs } from "@common/auth";
import { isAtLoginRedirectUrl } from "@common/network";
import { ENDPOINT, AUTH_USER_ID } from "@/shared/config";
import { authService } from "../api/auth-service";
import { getToken, setupRefreshTimer } from "./token-manager";
const AuthWrapper = ({ children }: { children: React.ReactNode }) => {
const router = useRouter();
const { userInfo, setUserInfo } = useUserStore((state) => state);
const userInfo = useUserStore((state) => state.userInfo);
const setUserInfo = useUserStore((state) => state.setUserInfo);
useEffect(() => {
const token = getToken(CLAIM_NAME);
if (token) {
const remainingMs = getRemainingMs(token);
if (remainingMs > 0) {
console.log(
`[AuthWrapper] Refresh timer set: ${remainingMs}ms (${remainingMs / 1000}s)`,
);
setupRefreshTimer(authService.silentRefresh, remainingMs);
}
}
if (isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) return;
async function authCheck() {
if (isAtLoginRedirectUrl("/auth/login")) {
const userId = getToken(AUTH_USER_ID);
if (!userId) {
router.replace(AUTH_LOGIN_REDIRECT_URL);
return;
}
const userId = getCookie(AUTH_USER_ID);
if (!userId) {
throw new Error("Missing required authentication tokens");
}
if (userInfo) return;
const response = await client.get<IUserInfo>(
`${ENDPOINT.USER_SERVICE.USERS}/info/me`,
);
if (!userInfo) setUserInfo(response.data);
setUserInfo(response.data);
}
useEffect(() => {
(async () => {
try {
await authCheck();
} catch (error) {
authCheck().catch((error) => {
if (isAxiosError(error) && error.response?.status === 401) return;
console.error(error);
setUserInfo(null);
if (!isAtLoginRedirectUrl("/auth/login")) {
router.push("/auth/login");
if (!isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) {
router.replace(AUTH_LOGIN_REDIRECT_URL);
}
});
}, []);
useEffect(() => {
function handleAuthLogout() {
setUserInfo(null);
if (!isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) {
router.replace(AUTH_LOGIN_REDIRECT_URL);
}
}
})();
window.addEventListener("auth:logout", handleAuthLogout);
return () => window.removeEventListener("auth:logout", handleAuthLogout);
}, []);
return <>{children}</>;
+13 -82
View File
@@ -1,84 +1,15 @@
import { ACCESS_TOKEN, AUTH_USER_ID, CLAIM_NAME, REFRESH_TOKEN } from "@/shared/config";
import { getCookie, setCookie, removeCookie } from "@/shared/lib";
import { authConfig } from "@/shared/config/auth";
import { createTokenManager } from "@common/auth";
export class TokenManager {
private static instance: TokenManager;
private refreshTimer: NodeJS.Timeout | null = null;
export const tokenManager = createTokenManager(authConfig);
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<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;
}
this.removeToken(CLAIM_NAME);
this.removeToken(AUTH_USER_ID);
this.removeToken(REFRESH_TOKEN);
this.removeToken(ACCESS_TOKEN);
}
}
export const tokenManager = TokenManager.getInstance();
export const {
getToken,
setToken,
removeToken,
setupRefreshTimer,
clearRefreshTimer,
getAccessToken,
getRefreshToken,
getUserId,
} = tokenManager;
@@ -1,9 +0,0 @@
export interface ILogin {
userId: string;
password: string;
}
export interface LoginService {
login(payload: ILogin): Promise<void>;
silentRefresh(): Promise<void>;
}
+13 -31
View File
@@ -1,6 +1,6 @@
import { useState } from "react";
import { ACCESS_TOKEN, AUTH_USER_ID, REFRESH_TOKEN, CLAIM_NAME } from "@/shared/config";
import { tokenManager } from "@/entities/auth/lib/token-manager";
import { ACCESS_TOKEN, AUTH_USER_ID, ENDPOINT } from "@/shared/config";
import { authService } from "@/entities/auth";
import {
Button,
Dialog,
@@ -33,7 +33,7 @@ export default function OtpModal({ onClose, onSuccess, open, onOpenChange }: Otp
}
// Proxy를 통해 OTP 인증 처리
const response = await fetch(`/api/proxy/user-service/api/v1/auth/2fa/verify`, {
const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.OTP_VERIFY}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -57,37 +57,19 @@ export default function OtpModal({ onClose, onSuccess, open, onOpenChange }: Otp
console.log("Response data keys:", Object.keys(data));
console.log("Full response data:", JSON.stringify(data, null, 2));
// 2FA 응답에서는 access-token만 있고 token-id는 없을 수 있음
const accessToken = data[ACCESS_TOKEN] || data.accessToken || data.access_token || data.token || data['ss-token'];
const accessToken =
data[ACCESS_TOKEN] ||
data.accessToken ||
data.access_token ||
data.token ||
data["ss-token"];
const tokenId = data[AUTH_USER_ID] || data.userId;
console.log("Extracted accessToken:", !!accessToken);
console.log("Token value - accessToken:", accessToken?.substring(0, 20) + "...");
// access-token이 있는 경우 저장
if (accessToken) {
console.log("Saving access token from OTP verification");
tokenManager.setToken(CLAIM_NAME, accessToken); // Authorization으로 저장
// 리프레시 토큰도 확인
const refreshToken = data[REFRESH_TOKEN] || data.refreshToken || data.refresh_token;
if (refreshToken) {
console.log("Saving refresh token");
tokenManager.setToken(REFRESH_TOKEN, refreshToken);
if (!accessToken || !tokenId) {
throw new Error("인증 토큰을 받지 못했습니다.");
}
// userId는 원래 로그인 응답에서 가져온 값 사용
const originalUserId = data.userId;
if (originalUserId) {
console.log("Saving userId from original login response");
tokenManager.setToken(AUTH_USER_ID, originalUserId);
}
console.log("All tokens saved successfully");
} else {
console.log("No access token in OTP response");
console.log("Available data keys:", Object.keys(data));
console.log("Data values:", Object.entries(data).map(([k, v]) => `${k}: ${typeof v === 'string' ? v.substring(0, 20) + '...' : v}`));
}
authService.completeSession(accessToken, tokenId);
onSuccess();
} catch (error: unknown) {
@@ -3,7 +3,7 @@
import { useState, useEffect } from "react";
import Image from "next/image";
import { images } from "@common/assets";
import { LoginApi } from "@/entities/auth";
import { authService } from "@/entities/auth";
import { ENDPOINT } from "@/shared/config";
import OtpModal from "./OtpModal";
import TwoFAModal from "./TwoFAModal";
@@ -96,7 +96,7 @@ function LoginPage() {
} else {
// 기본 로그인 - proxy를 통해 처리
console.log("Starting basic login...");
await LoginApi.login({ userId, password });
await authService.login({ userId, password });
window.location.href = "/dashboard";
}
} catch (error: unknown) {
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Image from "next/image";
import { tokenManager } from "@/entities/auth/lib/token-manager";
import { authService, tokenManager } from "@/entities/auth";
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import { KoreaBasinMap } from "../lib/korea-basin-map";
@@ -120,13 +120,11 @@ function DashboardPage() {
fetchUserInfo();
}, [router]);
const handleLogout = () => {
const handleLogout = async () => {
console.log("Logging out...");
// localStorage 정리
localStorage.removeItem("tempToken");
// tokenManager를 통해 쿠키 정리
tokenManager.clearAuthToken();
router.push("/auth/logout");
await authService.logout();
router.push("/auth/login");
};
const handleChangePassword = () => {
-1
View File
@@ -97,4 +97,3 @@ export const ENDPOINT = {
};
export const JWT_EXPIRATION_TIME = 1800000;
+17
View File
@@ -0,0 +1,17 @@
import type { AuthConfig } from "@common/auth";
import {
ACCESS_TOKEN,
AUTH_USER_ID,
CLAIM_NAME,
COOKIE_DOMAIN,
REFRESH_TOKEN,
} from "./env";
export const authConfig: AuthConfig = {
authUserId: AUTH_USER_ID,
claimName: CLAIM_NAME,
cookieDomain: COOKIE_DOMAIN,
accessToken: ACCESS_TOKEN,
refreshToken: REFRESH_TOKEN,
};
+3
View File
@@ -2,6 +2,8 @@ export const PORT = process.env.NEXT_PUBLIC_PORT || "3000";
export const PROXY_HOST =
process.env.NEXT_PUBLIC_PROXY_HOST || `http://localhost:${PORT}`;
export const SERVER_API_URL = process.env.NEXT_PUBLIC_SERVER_API_URL;
export const AUTH_LOGIN_REDIRECT_URL =
process.env.NEXT_PUBLIC_AUTH_LOGIN_REDIRECT_URL || "/auth/login";
export const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "CS";
export const CLAIM_NAME = process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization";
export const ACCESS_TOKEN =
@@ -9,3 +11,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 COOKIE_DOMAIN = process.env.NEXT_PUBLIC_COOKIE_DOMAIN;
+7 -4
View File
@@ -104,10 +104,10 @@ importers:
devDependencies:
eslint:
specifier: ^9.0.0
version: 9.39.4(jiti@1.21.7)
version: 9.39.4(jiti@2.6.1)
eslint-config-next:
specifier: 15.3.8
version: 15.3.8(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
version: 15.3.8(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
postcss:
specifier: ^8.4.49
version: 8.5.8
@@ -235,6 +235,9 @@ importers:
'@common/assets':
specifier: workspace:*
version: link:../../packages/common/assets
'@common/auth':
specifier: workspace:*
version: link:../../packages/common/auth
'@common/components':
specifier: workspace:*
version: link:../../packages/common/components
@@ -364,10 +367,10 @@ importers:
version: 5.28.5
eslint:
specifier: ^9.0.0
version: 9.39.4(jiti@2.6.1)
version: 9.39.4(jiti@1.21.7)
eslint-config-next:
specifier: 15.3.8
version: 15.3.8(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
version: 15.3.8(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
postcss:
specifier: ^8.4.49
version: 8.5.8