테넌트 서브도메인 존재 검증 Guard 추가
Build & Deploy to K8s / build-and-deploy (push) Failing after 3m6s

- 백엔드 TenantController: GET /api/tenant/check?subdomain=xxx
  (메타 DB 강제 라우팅 + CompanyResolver 로 존재 여부 반환)
- frontend/lib/tenant/subdomain.ts: 호스트 파싱 + 예약어(solution/www/admin 등) 제외
- TenantGuard 클라이언트 컴포넌트: layout.tsx 에서 wrap,
  sessionStorage 로 같은 서브도메인 재체크 방지
- /tenant-not-found 페이지: v5 solid+glow 스타일

등록되지 않은 서브도메인 접속 시 즉시 /tenant-not-found 로 리다이렉트.
This commit is contained in:
2026-04-24 19:27:52 +09:00
parent 76f43cea9b
commit 5812925929
6 changed files with 255 additions and 1 deletions
@@ -0,0 +1,44 @@
package com.erp.tenant;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 테넌트 서브도메인 검증용 공개 API.
* 로그인 전 프론트 TenantGuard 가 "회사 존재 여부" 체크에 사용.
*
* GET /api/tenant/check?subdomain=qnc
* → { "subdomain": "qnc", "exists": true }
*
* SecurityConfig 에서 /api/** 전부 permitAll 이라 별도 설정 불필요.
* 메타 DB 로 강제 라우팅해야 테넌트 DB context 가 섞이지 않음.
*/
@RestController
@RequestMapping("/api/tenant")
@RequiredArgsConstructor
@Slf4j
public class TenantController {
private final CompanyResolver companyResolver;
@GetMapping("/check")
public Map<String, Object> check(@RequestParam("subdomain") String subdomain) {
DbContextHolder.setMeta();
try {
String dbName = companyResolver.resolveDbName(subdomain);
Map<String, Object> res = new LinkedHashMap<>();
res.put("subdomain", subdomain);
res.put("exists", dbName != null);
return res;
} finally {
DbContextHolder.clear();
}
}
}
+4 -1
View File
@@ -5,6 +5,7 @@ import { ThemeProvider } from "@/components/providers/ThemeProvider";
import { QueryProvider } from "@/providers/QueryProvider";
import { RegistryProvider } from "./registry-provider";
import { Toaster } from "@/components/ui/sonner";
import { TenantGuard } from "@/components/TenantGuard";
const inter = Inter({
subsets: ["latin"],
@@ -51,7 +52,9 @@ export default function RootLayout({
<div id="root" className="h-full">
<ThemeProvider>
<QueryProvider>
<RegistryProvider>{children}</RegistryProvider>
<RegistryProvider>
<TenantGuard>{children}</TenantGuard>
</RegistryProvider>
<Toaster />
</QueryProvider>
</ThemeProvider>
+86
View File
@@ -0,0 +1,86 @@
"use client";
import { AlertCircle, Home } from "lucide-react";
export default function TenantNotFoundPage() {
return (
<div
style={{
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "var(--v5-surface-solid)",
padding: "2rem",
}}
>
<div
style={{
maxWidth: 520,
width: "100%",
padding: "2.6rem 2.2rem",
background: "var(--v5-surface-solid)",
border: "1px solid var(--v5-border)",
borderRadius: 14,
boxShadow: "var(--v5-glow-md)",
textAlign: "center",
}}
>
<div
style={{
display: "inline-flex",
padding: "1rem",
background: "rgba(var(--v5-red-rgb, 255,107,107), 0.1)",
borderRadius: "50%",
marginBottom: "1.2rem",
}}
>
<AlertCircle size={40} color="var(--v5-red, #ff6b6b)" strokeWidth={1.5} />
</div>
<h1
style={{
fontSize: "1.3rem",
fontWeight: 700,
color: "var(--v5-text)",
marginBottom: "0.6rem",
letterSpacing: "-0.01em",
}}
>
</h1>
<p
style={{
fontSize: "0.85rem",
color: "var(--v5-text-sec)",
lineHeight: 1.65,
marginBottom: "1.8rem",
}}
>
.
<br />
.
</p>
<a
href="https://solution.invyone.com"
style={{
display: "inline-flex",
alignItems: "center",
gap: "0.5rem",
padding: "0.75rem 1.3rem",
background: "var(--v5-primary)",
color: "#fff",
borderRadius: 8,
fontSize: "0.85rem",
fontWeight: 600,
textDecoration: "none",
boxShadow: "var(--v5-glow-sm)",
}}
>
<Home size={15} strokeWidth={1.75} />
</a>
</div>
</div>
);
}
+46
View File
@@ -0,0 +1,46 @@
"use client";
import { useEffect } from "react";
import { usePathname, useRouter } from "next/navigation";
import { apiClient } from "@/lib/api/client";
import { extractTenantSubdomain } from "@/lib/tenant/subdomain";
const CHECK_CACHE_KEY = "tenant-check-ok";
/**
* 테넌트 서브도메인 접근 차단 Guard.
* - 메인 사이트(solution.invyone.com 등 예약어) 는 통과
* - *.invyone.com 테넌트 서브도메인은 백엔드 /api/tenant/check 로 존재 검증
* - 미등록 회사면 /tenant-not-found 로 리다이렉트
*
* 같은 세션 내 재체크를 피하려고 sessionStorage 에 1회 캐시.
*/
export function TenantGuard({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
useEffect(() => {
if (typeof window === "undefined") return;
if (pathname === "/tenant-not-found") return;
const sub = extractTenantSubdomain(window.location.hostname);
if (!sub) return;
if (sessionStorage.getItem(CHECK_CACHE_KEY) === sub) return;
apiClient
.get(`/tenant/check`, { params: { subdomain: sub } })
.then((res) => {
if (res.data?.exists) {
sessionStorage.setItem(CHECK_CACHE_KEY, sub);
} else {
router.replace("/tenant-not-found");
}
})
.catch(() => {
// API 호출 실패 시 낙관적으로 통과. 백엔드 잠시 재기동 중일 수 있음.
});
}, [pathname, router]);
return <>{children}</>;
}
+38
View File
@@ -0,0 +1,38 @@
/**
* 테넌트 서브도메인 헬퍼.
* 메인 사이트(solution, www, admin 등 예약어) 는 null 을 리턴해서
* TenantGuard 가 체크를 스킵하게 한다.
*
* 백엔드 provisioning 의 RESERVED_SUBDOMAINS 와 같은 값을 유지할 것.
*/
const RESERVED_MAIN = new Set([
"solution",
"www",
"admin",
"api",
"app",
"static",
"assets",
"main",
"mail",
"blog",
"dev",
"test",
"staging",
"prod",
"console",
]);
export function extractTenantSubdomain(host: string): string | null {
if (!host) return null;
const cleanHost = host.split(":")[0].toLowerCase();
if (!cleanHost.endsWith(".invyone.com")) return null;
const prefix = cleanHost.substring(0, cleanHost.length - ".invyone.com".length);
if (!prefix) return null;
if (RESERVED_MAIN.has(prefix)) return null;
return prefix;
}
@@ -0,0 +1,37 @@
#!/bin/bash
set -e
echo "==== [1] Terminating all vexplor sessions ===="
docker exec -i invyone-db psql -U postgres -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname LIKE '%vexplor%' AND pid <> pg_backend_pid();" 2>&1 | tail -3
sleep 2
echo ""
echo "==== [2] Dropping 6 test databases ===="
for db in newtest5_vexplor newtest6_vexplor test002_vexplor test07_vexplor test08_vexplor wace_vexplor; do
echo " - Dropping $db"
docker exec -i invyone-db psql -U postgres -c "DROP DATABASE IF EXISTS \"$db\";" 2>&1
done
echo ""
echo "==== [3] Deleting 8 test company records ===="
docker exec -i invyone-db psql -U postgres -d vexplor -c "DELETE FROM COMPANY_MNG WHERE COMPANY_CODE IN ('NEWTEST03','NEWTEST05','NEWTEST06','TEST07','TEST08','TEST002','WACE11','COMPANY_10');" 2>&1
echo ""
echo "==== [4] RENAME vexplor -> invyone ===="
docker exec -i invyone-db psql -U postgres -c "ALTER DATABASE vexplor RENAME TO invyone;" 2>&1
echo ""
echo "==== [5] Final DB list ===="
docker exec -i invyone-db psql -U postgres -l | grep -v template | grep -v postgres
echo ""
echo "==== [6] Company count in invyone ===="
docker exec -i invyone-db psql -U postgres -d invyone -tAc "SELECT COUNT(*) || ' companies in COMPANY_MNG' FROM COMPANY_MNG;"
echo ""
echo "==== [7] Scale backend back to 1 ===="
KUBECONFIG=/home/chpark/.kube/config kubectl scale deployment backend-spring --replicas=1 -n invyone
echo ""
echo "==== DONE ===="