로그인 처리
iipbs_sn / build (push) Waiting to run

This commit is contained in:
yschoi
2025-05-26 10:32:26 +09:00
parent 6156cf6419
commit d975e8fed0
14 changed files with 494 additions and 262 deletions
+100
View File
@@ -0,0 +1,100 @@
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
const ACCESS_TOKEN = process.env.NEXT_PUBLIC_ACCESS_TOKEN || "access-token";
const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id";
const CLAIM_NAME = process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization";
const REFRESH_TOKEN = process.env.NEXT_PUBLIC_REFRESH_TOKEN || "refresh-token";
const SERVER_API_URL = process.env.NEXT_PUBLIC_SERVER_API_URL || "";
const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "SN";
async function handler(
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");
// silent refresh
if (isRefreshHandler) {
const refreshToken = cookieStore.get(REFRESH_TOKEN);
headers[CLAIM_NAME] = refreshToken?.value ?? "";
if (!headers[CLAIM_NAME]) {
return NextResponse.json({ message: "Can't refresh" }, { status: 401 });
}
}
try {
if (!isRefreshHandler) {
body = await request.json();
}
const result = await fetch(`${SERVER_API_URL}/${params.url.join("/")}`, {
method,
headers,
body: JSON.stringify(body),
});
if (!result.ok) {
console.log("Invalid Credentials ");
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 (accessToken && userId) {
const response = NextResponse.json({
[ACCESS_TOKEN]: accessToken,
[AUTH_USER_ID]: userId,
});
if (refreshToken) {
cookieStore.set(REFRESH_TOKEN, refreshToken, {
httpOnly: true,
sameSite: "lax",
path: "/",
});
}
return response;
}
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[] }> },
) {
const params = await props.params;
return handler(request, params, "POST");
}
export async function PUT(
request: Request,
props: { params: Promise<{ url: string[] }> },
) {
const params = await props.params;
return handler(request, params, "PUT");
}
@@ -0,0 +1,105 @@
import { AxiosError } from "axios";
import { tokenManager } from "../lib/token-manager";
const ENDPOINT = {
USER_SERVICE: {
LOGIN: "/user-service/login",
USERS: "/user-service/api/v1/users",
SILENT_REFRESH: "/user-service/api/v1/users/token/refresh",
AUTHORIZATIONS: "/user-service/api/v1/authorizations",
ROLE_AUTHORIZATIONS: "/user-service/api/v1/role-authorizations",
},
};
const JWT_EXPIRATION_TIME = 1800000;
const CLAIM_NAME = process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization";
const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id";
const ACCESS_TOKEN = process.env.NEXT_PUBLIC_ACCESS_TOKEN || "access-token";
export interface ILogin {
userId: string;
password: string;
}
export interface LoginService {
login(payload: ILogin): Promise<void>;
silentRefresh(): Promise<void>;
}
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 = response.headers.get("access-token");
const tokenId = response.headers.get("token-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();
@@ -0,0 +1,2 @@
export { LoginApi } from "./api/login-service";
export { AuthWrapper } from "./lib/auth-wrapper";
@@ -0,0 +1,87 @@
"use client";
import { useEffect } from "react";
import { useRouter, usePathname } from "next/navigation";
import axios from "axios";
import Cookies from "js-cookie";
import { useUserStore, type IUserInfo } from "@store";
import { getCookie } from "@utils";
import { LoginApi } from "../api/login-service";
import { tokenManager } from "../lib/token-manager";
const CLAIM_NAME = process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization";
const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id";
const ENDPOINT = {
USER_SERVICE: {
LOGIN: "/user-service/login",
USERS: "/user-service/api/v1/users",
SILENT_REFRESH: "/user-service/api/v1/users/token/refresh",
AUTHORIZATIONS: "/user-service/api/v1/authorizations",
ROLE_AUTHORIZATIONS: "/user-service/api/v1/role-authorizations",
},
};
const AuthWrapper = ({ children }: { children: React.ReactNode }) => {
const router = useRouter();
const pathname = usePathname(); // 현재 경로
const { userInfo, setUserInfo } = useUserStore((state) => state);
async function authCheck() {
const checkToken = getCookie(AUTH_USER_ID);
if (!checkToken) {
//TODO 나중에 수정해야....
const userId = "admin1gmail";
const password = "test1234!";
await LoginApi.login({ userId, password });
// window.location.href = "http://dev.onasys.co.kr/auth/login";
// throw new Error("Missing required authentication tokens");
}
const accessToken = tokenManager.getToken(CLAIM_NAME);
const tokenId = tokenManager.getToken(AUTH_USER_ID);
const response = await axios.get<IUserInfo>(
`${ENDPOINT.USER_SERVICE.USERS}/info/me`,
{
withCredentials: true,
headers: {
[CLAIM_NAME]: accessToken,
[AUTH_USER_ID]: tokenId ?? "",
},
},
);
if (!userInfo) setUserInfo(response.data);
}
useEffect(() => {
(async () => {
try {
await authCheck();
} catch (error) {
console.error("Auth check failed:", error);
try {
await LoginApi.silentRefresh();
await authCheck();
} catch (refreshError) {
console.error("Token refresh failed:", refreshError);
setUserInfo(null);
// TODO 운영에 반영 할때 수정해야.....
// 현재 경로가 로그인 페이지가 아닐 때만 redirect
// if (!pathname.startsWith("/auth/login")) {
// window.location.href = "http://dev.onasys.co.kr/auth/login";
// }
}
}
})();
}, [pathname]); // pathname도 의존성에 포함
return <>{children}</>;
};
export { AuthWrapper };
@@ -0,0 +1,61 @@
import { getCookie, setCookie, removeCookie } from "@utils";
const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id";
const CLAIM_NAME = process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization";
export class TokenManager {
private static instance: TokenManager;
private refreshTimer: NodeJS.Timeout | null = null;
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 {
setCookie(key, value, {
secure: window.location.protocol === "https:",
sameSite: "lax",
});
}
public removeToken(key: string): void {
removeCookie(key);
}
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);
}
}
export const tokenManager = TokenManager.getInstance();
@@ -0,0 +1,9 @@
export interface ILogin {
userId: string;
password: string;
}
export interface LoginService {
login(payload: ILogin): Promise<void>;
silentRefresh(): Promise<void>;
}
+10 -10
View File
@@ -1,9 +1,10 @@
import './styles/base.css'
import './styles/components.css'
import "./styles/base.css";
import "./styles/components.css";
import type { Metadata } from "next";
import { cookies } from "next/headers";
import { StoreProvider } from './store'
import { ClientLayout } from './clientLayout'
import { StoreProvider } from "./store/store-provider";
import { AuthWrapper } from "./components/lib/auth";
import { ClientLayout } from "./clientLayout";
export const metadata: Metadata = {
title: "디지털 하수관로",
@@ -20,18 +21,17 @@ export default async function RootLayout({
}: Readonly<{
children: React.ReactNode;
}>) {
const theme = (await cookies()).get("site-theme-key");
return (
<html lang="en" data-theme={theme?.value}>
<body>
<StoreProvider>
<ClientLayout>
{children}
</ClientLayout>
<StoreProvider>
<AuthWrapper>
<ClientLayout>{children}</ClientLayout>
</AuthWrapper>
</StoreProvider>
</body>
</html>
)
);
}
+1 -45
View File
@@ -1,45 +1 @@
'use client'
import { type ReactNode, createContext, useContext, useRef } from 'react'
import { useStore } from 'zustand'
import { type SnStore, StoreZus } from './store'
export type StoreApi = ReturnType<typeof StoreZus>
export const StoreContext = createContext<StoreApi | undefined>(
undefined,
)
export interface StoreProviderProps {
children: ReactNode
}
export const StoreProvider = ({
children,
}: StoreProviderProps) => {
const storeRef = useRef<StoreApi | null>(null)
if (!storeRef.current) {
storeRef.current = StoreZus()
}
return (
<StoreContext.Provider value={storeRef.current}>
{children}
</StoreContext.Provider>
)
}
export const useSnStore = <T,>(
//selector: (store: ReturnType<typeof StoreZus>) => T,
selector: (store: SnStore) => T,
): T => {
const storeContext = useContext(StoreContext)
if (!storeContext) {
throw new Error(`useStore must be used within StoreProvider`)
}
return useStore(storeContext, selector)
}
export { useUserStore, type IUserInfo } from "./user-store";
-76
View File
@@ -1,76 +0,0 @@
export type TableUpperProps = {
id: string;
title: string;
upName?: string;
upSequnce?: number;
}
type PublicReuseFacility = {
sido: string,
sigungo: string,
facilityName: string,
location: string,
locationgubun: string,
facilityCapacity: string,
yn?: string,
authoDay?: string,
area?: string,
reuse?: string
}
const upHeadList:TableUpperProps[] = [
{ id: "sido", title: "자산ID"},
{ id: "sigungo", title: "설비시설"},
{ id: "facilityName", title: "주소"},
{ id: "location", title: "자산코드"},
{ id: "locationgubun", title: "자산명"},
{ id: "facilityCapacity", title: "용도"},
{ id: "yn", title: '신설/교체'},
{ id: "authoDay", title: '처리구역'},
{ id: "area", title: '내용연수' },
{ id: "reuse", title: '취득금액' },
];
const publicReuseFacilityUper:PublicReuseFacility[] = [
{
sido: "1048967",
sigungo: "관로",
facilityName: "양평군 양평읍 백안리 25-5전",
location: "S-T-S-2018000675",
locationgubun: "2018000675",
facilityCapacity: "오수관",
yn: "신설",
authoDay: "양평하수처리구역",
area: "30",
reuse: "28,313,220"
}
]
export type tableState = {
isInit: boolean;
upHeadList: TableUpperProps[];
publicReuseFacilityUper: PublicReuseFacility[];
}
type tableActions = {
decrementList: () => void
}
export type tableType = tableState & tableActions;
export const tableStateExport:tableState= {
isInit:false,
upHeadList: upHeadList,
publicReuseFacilityUper: publicReuseFacilityUper,
}
export const tableActionsExport:(set:any)=>tableActions=(set: any) => {
return {
decrementList:()=>set(
(state:tableState) => {
return (
{
upHeadList: state.upHeadList,
publicReuseFacilityUper: state.publicReuseFacilityUper.slice(0, state.publicReuseFacilityUper.length - 1)
})}
)
}
}
+27
View File
@@ -0,0 +1,27 @@
"use client";
import { createContext, useRef } from "react";
import { createUserStore } from "../store/user-store";
type StoreContextType = {
userStore: ReturnType<typeof createUserStore>;
};
export const StoreContext = createContext<StoreContextType | undefined>(
undefined,
);
export const StoreProvider = ({ children }: { children: React.ReactNode }) => {
const storeRef = useRef<StoreContextType | null>(null);
if (storeRef.current === null) {
storeRef.current = {
userStore: createUserStore(),
};
}
return (
<StoreContext.Provider value={storeRef.current}>
{children}
</StoreContext.Provider>
);
};
-14
View File
@@ -1,14 +0,0 @@
import { createStore } from 'zustand/vanilla';
import { tableActionsExport, tableStateExport, type tableState, type tableType } from './table';
export type SnStore = tableType;
//export type SnStore = ReturnType<typeof StoreZus>["getState"];
export const StoreZus = (initState: tableState = tableStateExport) => {
return createStore<SnStore>()((set) => {
return {
...initState,
...tableActionsExport(set)
}
})
}
//store 등록하는 부분
-116
View File
@@ -1,116 +0,0 @@
export type TableUpperProps = {
id: string,
title: string,
upSequnce?: number,
upName?: string
}
type PublicReuseFacility = {
sido: string,
sigungo: string,
facilityName: string,
location: string,
locationgubun: string,
facilityCapacity: string,
yn?: string,
authoDay?: string,
area?: string,
reuse?: string
}
const upHeadList:TableUpperProps[] = [
{ id: "sido", title: "자산ID"},
{ id: "sigungo", title: "설비시설"},
{ id: "facilityName", title: "주소"},
{ id: "location", title: "자산코드"},
{ id: "locationgubun", title: "자산명"},
{ id: "facilityCapacity", title: "용도"},
{ id: "yn", title: '신설/교체'},
{ id: "authoDay", title: '처리구역'},
{ id: "area", title: '내용연수' },
{ id: "reuse", title: '취득금액' },
];
const publicReuseFacilityUper:PublicReuseFacility[] = [
{
sido: "1048967",
sigungo: "관로",
facilityName: "양평군 양평읍 백안리 25-5전",
location: "S-T-S-2018000675",
locationgubun: "2018000675",
facilityCapacity: "오수관",
yn: "신설",
authoDay: "양평하수처리구역",
area: "30",
reuse: "28,313,220"
}
]
export type tableState = {
isInit: boolean;
upHeadList: TableUpperProps[];
publicReuseFacilityUper: PublicReuseFacility[];
}
type tableActions = {
setHeadList: (newHeadList: TableUpperProps[]) => void
setTableData: (newTableDataList: PublicReuseFacility[]) => void
decrementList: () => void
addRow: (newTableData: PublicReuseFacility) => void
}
export type tableType = tableState & tableActions;
export const tableStateExport:tableState= {
isInit:false,
upHeadList: [
{ id: "sido", title: "자산ID"},
{ id: "sigungo", title: "설비시설"},
{ id: "facilityName", title: "주소"},
{ id: "location", title: "자산코드"},
{ id: "locationgubun", title: "자산명"},
{ id: "facilityCapacity", title: "용도"},
{ id: "yn", title: '신설/교체'},
{ id: "authoDay", title: '처리구역'},
{ id: "area", title: '내용연수' },
{ id: "reuse", title: '취득금액' },
],
publicReuseFacilityUper: publicReuseFacilityUper,
}
export const tableActionsExport:(set:any)=>tableActions=(set: any) => {
return {
// 마지막 데이터 삭제
decrementList: ()=>set(
(state: tableState) => {
return (
{
...state,
upHeadList: state.upHeadList,
publicReuseFacilityUper: state.publicReuseFacilityUper.slice(0, state.publicReuseFacilityUper.length - 1)
})}
),
// 데이터 추가
addRow: (newTableData: PublicReuseFacility)=>set(
(state: tableState) => {
console.log('asdfasdfadsf')
return (
{
...state,
upHeadList: state.upHeadList,
publicReuseFacilityUper: [...state.publicReuseFacilityUper, newTableData]
})}
),
// 테이블 헤더 변경
setHeadList: (newHeadList: TableUpperProps[]) => set(
(state: tableState) => ({
...state,
upHeadList: newHeadList
})
),
// 시설 목록 데이터 변경
setTableData: (newTableData: PublicReuseFacility[]) => set(
(state: tableState) => ({
...state,
publicReuseFacilityUper: newTableData
})
),
}
}
+74
View File
@@ -0,0 +1,74 @@
"use client";
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { createStore } from "zustand/vanilla";
export interface IUserInfo {
regDt: string;
mdfcnDt: string;
rgtr: string;
mdfr: string;
adminRoleYn: string;
userUnqId: string;
userId: string;
userName: string;
email: string;
encryptedPassword: string;
refreshToken: string;
userStateCode: string;
lastLoginDate: string;
loginFailCount: number;
pswdHnt: string;
pswdAns: string;
mphn: string;
telno: string;
zip: string;
addr: string;
daddr: string;
laeSeCd: string;
ogdpSeCd: string;
aprvYn: string;
autzrId: string;
expryYmd: string;
lastPswdChgDt: string;
googleId: string;
kakaoId: string;
naverId: string;
isSocialUser: boolean;
hasPassword: boolean;
}
export type UserState = {
userInfo: IUserInfo | null;
};
type UserStore = {
userInfo: IUserInfo | null;
setUserInfo: (userInfo: IUserInfo | null) => void;
};
// ✅ zustand 기본 create + persist 적용
export const useUserStore = create<UserStore>()(
persist(
(set) => ({
userInfo: null,
setUserInfo: (userInfo) => set({ userInfo }),
}),
{
name: "user-store", // localStorage 키
partialize: (state) => ({ userInfo: state.userInfo }), // 선택적 저장
},
),
);
export const defaultInitState: UserState = {
userInfo: null,
};
export const createUserStore = (initState: UserState = defaultInitState) => {
return createStore<UserStore>()((set) => ({
...initState,
setUserInfo: (userInfo: IUserInfo | null) => set(() => ({ userInfo })),
}));
};
+18 -1
View File
@@ -46,9 +46,26 @@ export function middleware(req: NextRequest) {
});
}
if (pathname.startsWith("/user-service")) {
const requestHeaders = new Headers(req.headers);
const newUrl = `${serverApiUrl}${pathname}`;
console.log(
`[Middleware] Rewriting from '/user-service' path to: ${newUrl}`,
);
return NextResponse.rewrite(new URL(newUrl), {
headers: requestHeaders,
});
}
return NextResponse.next();
}
export const config = {
matcher: ["/server/:path*","/geoserver/:path*", "/attach-service/:path*"],
matcher: [
"/server/:path*",
"/geoserver/:path*",
"/attach-service/:path*",
"/user-service/:path*",
],
};