diff --git a/apps/cs/app/api/getClientIp/route.ts b/apps/cs/app/api/getClientIp/route.ts new file mode 100644 index 000000000..ec9ff72d5 --- /dev/null +++ b/apps/cs/app/api/getClientIp/route.ts @@ -0,0 +1,16 @@ +import { NextRequest, NextResponse } from "next/server"; + +export async function GET(request: NextRequest) { + const ipAddress = + request.headers.get("x-forwarded-for")?.split(",")[0] || "unknown"; + + const ipv4Regex = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/; + const match = ipAddress.match(ipv4Regex); + + const normalizedIp = match ? match[1] : ipAddress; + + return NextResponse.json({ + originalIp: ipAddress, + normalizedIp: normalizedIp, + }); +} diff --git a/apps/cs/src/app/providers/app-provider.tsx b/apps/cs/src/app/providers/app-provider.tsx index 1a2189b03..b8fabff3d 100644 --- a/apps/cs/src/app/providers/app-provider.tsx +++ b/apps/cs/src/app/providers/app-provider.tsx @@ -5,6 +5,7 @@ import { MultiBackend, getBackendOptions } from "@minoru/react-dnd-treeview"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { ConfirmProvider } from "@frontend-opensource/use-react-hooks"; +import { ClientIpGuard } from "@/entities/ip"; import { AuthWrapper } from "@/entities/auth"; import { MenuWrapper } from "@/entities/menu"; import { PromptProvider, StoreProvider } from "@/shared/providers"; @@ -16,28 +17,30 @@ const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => { const queryClient = new QueryClient(); return ( - - - - - - - {children} - - - - - - - - - -
- -
+ + + + + + + + {children} + + + + + + + + + +
+ +
+
); }; diff --git a/apps/cs/src/pages/site/api/ip-service.ts b/apps/cs/src/entities/ip/api/ip-service.ts similarity index 89% rename from apps/cs/src/pages/site/api/ip-service.ts rename to apps/cs/src/entities/ip/api/ip-service.ts index 903f0407e..0a9e063e6 100644 --- a/apps/cs/src/pages/site/api/ip-service.ts +++ b/apps/cs/src/entities/ip/api/ip-service.ts @@ -1,6 +1,6 @@ import { client } from "@/shared/api"; import { ENDPOINT } from "@/shared/config"; -import type { IpParams, IP, IpList, IpService } from "../model/ip-interface"; +import type { IpService, IpParams, IpList, IP } from "../model/ip-interface"; export class IpApiService implements IpService { public async getIpList(params: IpParams): Promise { diff --git a/apps/cs/src/entities/ip/index.ts b/apps/cs/src/entities/ip/index.ts new file mode 100644 index 000000000..ef1c5be33 --- /dev/null +++ b/apps/cs/src/entities/ip/index.ts @@ -0,0 +1,10 @@ +export { IpApi } from "./api/ip-service"; +export { + ipFormSchema, + type IpService, + type IPFormData, + type IpParams, + type IpList, + type IP, +} from "./model/ip-interface"; +export { ClientIpGuard } from "./lib/client-ip-guard"; diff --git a/apps/cs/src/entities/ip/lib/client-ip-guard.tsx b/apps/cs/src/entities/ip/lib/client-ip-guard.tsx new file mode 100644 index 000000000..f3ae452ce --- /dev/null +++ b/apps/cs/src/entities/ip/lib/client-ip-guard.tsx @@ -0,0 +1,48 @@ +import { useEffect, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { SiteApi } from "@/entities/site/@x/ip"; +import { ENDPOINT, SITE_ID } from "@/shared/config"; +import { IpApi } from "../api/ip-service"; + +type ClientIpResponse = { + originalIp: string; + normalizedIp: string; +}; + +const ClientIpGuard = ({ children }: { children: React.ReactNode }) => { + const queryClient = useQueryClient(); + const [isAllowed, setIsAllowed] = useState(true); + + useEffect(() => { + (async () => { + const response = await fetch("/api/getClientIp"); + const { normalizedIp } = (await response.json()) as ClientIpResponse; + + if (!normalizedIp || !SITE_ID) return; + + const siteInfo = await queryClient.ensureQueryData({ + queryKey: [ENDPOINT.CMS_SERVICE.SITES, SITE_ID], + queryFn: () => SiteApi.siteDetail(SITE_ID), + }); + + if (siteInfo.siteKndCd === "O") return; + + const { content: ipList } = await IpApi.getIpList({ + siteId: SITE_ID, + prmYn: "Y", + }); + + const allowedClientIp = ipList.some((ip) => ip.ipAddr === normalizedIp); + setIsAllowed(allowedClientIp); + })(); + }, []); + + if (!isAllowed) { + alert("허용되지 않은 IP로 접근하였습니다."); + return <>; + } + + return <>{children}; +}; + +export { ClientIpGuard }; diff --git a/apps/cs/src/pages/site/model/ip-interface.ts b/apps/cs/src/entities/ip/model/ip-interface.ts similarity index 100% rename from apps/cs/src/pages/site/model/ip-interface.ts rename to apps/cs/src/entities/ip/model/ip-interface.ts diff --git a/apps/cs/src/entities/site/@x/ip.ts b/apps/cs/src/entities/site/@x/ip.ts new file mode 100644 index 000000000..9daf50de3 --- /dev/null +++ b/apps/cs/src/entities/site/@x/ip.ts @@ -0,0 +1 @@ +export { SiteApi } from "../api/site-service"; diff --git a/apps/cs/src/pages/site/ui/ip-management-dialog.tsx b/apps/cs/src/pages/site/ui/ip-management-dialog.tsx index ec5c7a0dd..c2a2c2702 100644 --- a/apps/cs/src/pages/site/ui/ip-management-dialog.tsx +++ b/apps/cs/src/pages/site/ui/ip-management-dialog.tsx @@ -24,11 +24,10 @@ import { Checkbox, Label, } from "@common/components"; +import { IpApi, ipFormSchema, type IP, type IPFormData } from "@/entities/ip"; import { ENDPOINT } from "@/shared/config"; import { useAlertStore, useMutationWrapper } from "@/shared/lib"; import { Form, FormControl, FormField, FormItem } from "@/shared/ui/form"; -import { IpApi } from "../api/ip-service"; -import { ipFormSchema, type IP, type IPFormData } from "../model/ip-interface"; type IPManagementDialogProps = { siteId: string;