feat(phase1): 기반 구축 - DB Entity, JWT 인증, 프론트엔드 레이아웃

백엔드:
- JPA Entity 9개 + Repository 9개 (common 스키마)
- JWT 인증 (jjwt, Access/Refresh 토큰)
- AuthController (login/logout/refresh)
- 공통 모듈 (BaseEntity, ErrorCode, BusinessException, PageResponse)
- SecurityConfig JWT 필터 체인 통합

프론트엔드:
- MainLayout (사이드바 + 헤더) + AuthLayout
- 로그인 페이지 + ProtectedRoute
- API 클라이언트 (fetch wrapper, JWT 자동 첨부, 401 refresh)
- AuthContext + useAuth 훅
- 9개 플레이스홀더 페이지 + 라우팅

설정:
- DB: snp_connection / snp_admin / common 스키마
- ddl-auto: update (개발), validate (운영)

Closes #6

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
HYOJIN 2026-04-07 13:52:25 +09:00
부모 8842a07d20
커밋 48671af3c5
61개의 변경된 파일2190개의 추가작업 그리고 22개의 파일을 삭제

파일 보기

@ -4,7 +4,7 @@ API Gateway + 모니터링 통합 플랫폼. 모든 서비스 사용자가 모
## 기술 스택 ## 기술 스택
- Java 17, Spring Boot 3.2.1, Spring Data JPA - Java 17, Spring Boot 3.2.1, Spring Data JPA
- PostgreSQL (스키마: std_snp_connection) - PostgreSQL (DB: snp_connection, 스키마: common)
- Spring Security (JWT 기반 인증 예정) - Spring Security (JWT 기반 인증 예정)
- WebFlux WebClient (Heartbeat, Gateway Proxy) - WebFlux WebClient (Heartbeat, Gateway Proxy)
- Springdoc OpenAPI 2.3.0 (Swagger) - Springdoc OpenAPI 2.3.0 (Swagger)

파일 보기

@ -0,0 +1,187 @@
-- =============================================================
-- SNP Connection Monitoring - 테이블 생성 스크립트
-- 스키마: common
-- =============================================================
CREATE SCHEMA IF NOT EXISTS common;
SET search_path TO common;
-- -----------------------------------------------------------
-- 1. snp_tenant (테넌트)
-- -----------------------------------------------------------
CREATE TABLE IF NOT EXISTS snp_tenant (
tenant_id BIGSERIAL PRIMARY KEY,
tenant_code VARCHAR(50) NOT NULL UNIQUE,
tenant_name VARCHAR(200) NOT NULL,
description TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_snp_tenant_code ON snp_tenant (tenant_code);
CREATE INDEX idx_snp_tenant_active ON snp_tenant (is_active);
-- -----------------------------------------------------------
-- 2. snp_user (사용자)
-- -----------------------------------------------------------
CREATE TABLE IF NOT EXISTS snp_user (
user_id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT REFERENCES snp_tenant(tenant_id),
login_id VARCHAR(100) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
user_name VARCHAR(100) NOT NULL,
email VARCHAR(200),
role VARCHAR(20) NOT NULL DEFAULT 'USER',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
last_login_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_snp_user_login_id ON snp_user (login_id);
CREATE INDEX idx_snp_user_tenant ON snp_user (tenant_id);
CREATE INDEX idx_snp_user_role ON snp_user (role);
-- -----------------------------------------------------------
-- 3. snp_service (서비스)
-- -----------------------------------------------------------
CREATE TABLE IF NOT EXISTS snp_service (
service_id BIGSERIAL PRIMARY KEY,
service_code VARCHAR(50) NOT NULL UNIQUE,
service_name VARCHAR(200) NOT NULL,
service_url VARCHAR(500),
description TEXT,
health_check_url VARCHAR(500),
health_check_interval INTEGER NOT NULL DEFAULT 30,
health_status VARCHAR(10) NOT NULL DEFAULT 'UNKNOWN',
health_checked_at TIMESTAMP,
health_response_time INTEGER,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_snp_service_code ON snp_service (service_code);
CREATE INDEX idx_snp_service_status ON snp_service (health_status);
CREATE INDEX idx_snp_service_active ON snp_service (is_active);
-- -----------------------------------------------------------
-- 4. snp_service_health_log (서비스 헬스 로그)
-- -----------------------------------------------------------
CREATE TABLE IF NOT EXISTS snp_service_health_log (
log_id BIGSERIAL PRIMARY KEY,
service_id BIGINT NOT NULL REFERENCES snp_service(service_id),
previous_status VARCHAR(10),
current_status VARCHAR(10) NOT NULL,
response_time INTEGER,
error_message TEXT,
checked_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_snp_health_log_service ON snp_service_health_log (service_id);
CREATE INDEX idx_snp_health_log_checked ON snp_service_health_log (checked_at);
-- -----------------------------------------------------------
-- 5. snp_service_api (서비스 API)
-- -----------------------------------------------------------
CREATE TABLE IF NOT EXISTS snp_service_api (
api_id BIGSERIAL PRIMARY KEY,
service_id BIGINT NOT NULL REFERENCES snp_service(service_id),
api_path VARCHAR(500) NOT NULL,
api_method VARCHAR(10) NOT NULL,
api_name VARCHAR(200) NOT NULL,
description TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT uq_service_api UNIQUE (service_id, api_path, api_method)
);
CREATE INDEX idx_snp_service_api_service ON snp_service_api (service_id);
-- -----------------------------------------------------------
-- 6. snp_api_key (API 키)
-- -----------------------------------------------------------
CREATE TABLE IF NOT EXISTS snp_api_key (
api_key_id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES snp_user(user_id),
api_key VARCHAR(128) NOT NULL UNIQUE,
api_key_prefix VARCHAR(10),
key_name VARCHAR(200) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
approved_by BIGINT REFERENCES snp_user(user_id),
approved_at TIMESTAMP,
expires_at TIMESTAMP,
last_used_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_snp_api_key_user ON snp_api_key (user_id);
CREATE INDEX idx_snp_api_key_key ON snp_api_key (api_key);
CREATE INDEX idx_snp_api_key_status ON snp_api_key (status);
-- -----------------------------------------------------------
-- 7. snp_api_permission (API 권한)
-- -----------------------------------------------------------
CREATE TABLE IF NOT EXISTS snp_api_permission (
permission_id BIGSERIAL PRIMARY KEY,
api_key_id BIGINT NOT NULL REFERENCES snp_api_key(api_key_id),
api_id BIGINT NOT NULL REFERENCES snp_service_api(api_id),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
granted_by BIGINT,
granted_at TIMESTAMP NOT NULL DEFAULT NOW(),
revoked_at TIMESTAMP,
CONSTRAINT uq_api_permission UNIQUE (api_key_id, api_id)
);
CREATE INDEX idx_snp_api_permission_key ON snp_api_permission (api_key_id);
CREATE INDEX idx_snp_api_permission_api ON snp_api_permission (api_id);
-- -----------------------------------------------------------
-- 8. snp_api_request_log (API 요청 로그)
-- -----------------------------------------------------------
CREATE TABLE IF NOT EXISTS snp_api_request_log (
log_id BIGSERIAL PRIMARY KEY,
request_url VARCHAR(2000),
request_params TEXT,
request_method VARCHAR(10),
request_status VARCHAR(20),
request_headers TEXT,
request_ip VARCHAR(45),
service_id BIGINT REFERENCES snp_service(service_id),
user_id BIGINT REFERENCES snp_user(user_id),
api_key_id BIGINT REFERENCES snp_api_key(api_key_id),
response_size BIGINT,
response_time INTEGER,
response_status INTEGER,
error_message TEXT,
requested_at TIMESTAMP NOT NULL DEFAULT NOW(),
tenant_id BIGINT REFERENCES snp_tenant(tenant_id)
);
CREATE INDEX idx_snp_request_log_service ON snp_api_request_log (service_id);
CREATE INDEX idx_snp_request_log_user ON snp_api_request_log (user_id);
CREATE INDEX idx_snp_request_log_requested ON snp_api_request_log (requested_at);
CREATE INDEX idx_snp_request_log_tenant ON snp_api_request_log (tenant_id);
-- -----------------------------------------------------------
-- 9. snp_api_key_request (API 키 발급 요청)
-- -----------------------------------------------------------
CREATE TABLE IF NOT EXISTS snp_api_key_request (
request_id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES snp_user(user_id),
key_name VARCHAR(200) NOT NULL,
purpose TEXT,
requested_apis TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
reviewed_by BIGINT REFERENCES snp_user(user_id),
reviewed_at TIMESTAMP,
review_comment TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_snp_key_request_user ON snp_api_key_request (user_id);
CREATE INDEX idx_snp_key_request_status ON snp_api_key_request (status);

파일 보기

@ -0,0 +1,24 @@
-- =============================================================
-- SNP Connection Monitoring - 초기 데이터
-- 스키마: common
-- =============================================================
SET search_path TO common;
-- 기본 테넌트
INSERT INTO snp_tenant (tenant_code, tenant_name, description, is_active)
VALUES ('DEFAULT', '기본 테넌트', 'SNP Connection Monitoring 기본 테넌트', TRUE)
ON CONFLICT (tenant_code) DO NOTHING;
-- 관리자 계정 (password: admin123, BCrypt 해시)
INSERT INTO snp_user (tenant_id, login_id, password_hash, user_name, email, role, is_active)
VALUES (
(SELECT tenant_id FROM snp_tenant WHERE tenant_code = 'DEFAULT'),
'admin',
'$2b$10$res6.RkRwakcEui0XbCpOOxYzwQiT07/J0Jl4cKlMtaDZFRyDt1EC',
'시스템 관리자',
'admin@gcsc.com',
'ADMIN',
TRUE
)
ON CONFLICT (login_id) DO NOTHING;

파일 보기

@ -1,26 +1,48 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import AuthProvider from './store/AuthContext';
import AuthLayout from './layouts/AuthLayout';
import MainLayout from './layouts/MainLayout';
import ProtectedRoute from './components/ProtectedRoute';
import LoginPage from './pages/LoginPage';
import DashboardPage from './pages/DashboardPage';
import RequestLogsPage from './pages/monitoring/RequestLogsPage';
import MyKeysPage from './pages/apikeys/MyKeysPage';
import KeyRequestPage from './pages/apikeys/KeyRequestPage';
import KeyAdminPage from './pages/apikeys/KeyAdminPage';
import ServicesPage from './pages/admin/ServicesPage';
import UsersPage from './pages/admin/UsersPage';
import TenantsPage from './pages/admin/TenantsPage';
import NotFoundPage from './pages/NotFoundPage';
const BASE_PATH = '/snp-connection'; const BASE_PATH = '/snp-connection';
function App() { const App = () => {
return ( return (
<BrowserRouter basename={BASE_PATH}> <BrowserRouter basename={BASE_PATH}>
<AuthProvider>
<Routes> <Routes>
<Route element={<AuthLayout />}>
<Route path="/login" element={<LoginPage />} />
</Route>
<Route element={<ProtectedRoute />}>
<Route element={<MainLayout />}>
<Route path="/" element={<Navigate to="/dashboard" replace />} /> <Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route <Route path="/dashboard" element={<DashboardPage />} />
path="/dashboard" <Route path="/monitoring/request-logs" element={<RequestLogsPage />} />
element={ <Route path="/apikeys/my-keys" element={<MyKeysPage />} />
<div className="flex min-h-screen items-center justify-center bg-gray-50"> <Route path="/apikeys/request" element={<KeyRequestPage />} />
<div className="text-center"> <Route path="/apikeys/admin" element={<KeyAdminPage />} />
<h1 className="text-3xl font-bold text-gray-900">SNP Connection Monitoring</h1> <Route path="/admin/services" element={<ServicesPage />} />
<p className="mt-2 text-gray-600">Dashboard - Coming Soon</p> <Route path="/admin/users" element={<UsersPage />} />
</div> <Route path="/admin/tenants" element={<TenantsPage />} />
</div> <Route path="*" element={<NotFoundPage />} />
} </Route>
/> </Route>
</Routes> </Routes>
</AuthProvider>
</BrowserRouter> </BrowserRouter>
); );
} };
export default App; export default App;

파일 보기

@ -0,0 +1,22 @@
import { Navigate, Outlet } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
const ProtectedRoute = () => {
const { isAuthenticated, isLoading } = useAuth();
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-blue-500 border-t-transparent" />
</div>
);
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return <Outlet />;
};
export default ProtectedRoute;

파일 보기

@ -0,0 +1,8 @@
import { useContext } from 'react';
import { AuthContext } from '../store/AuthContext';
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) throw new Error('useAuth must be used within AuthProvider');
return context;
};

파일 보기

@ -0,0 +1,11 @@
import { Outlet } from 'react-router-dom';
const AuthLayout = () => {
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50">
<Outlet />
</div>
);
};
export default AuthLayout;

파일 보기

@ -0,0 +1,166 @@
import { useState } from 'react';
import { Outlet, NavLink } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
interface NavGroup {
label: string;
items: { label: string; path: string }[];
adminOnly?: boolean;
}
const navGroups: NavGroup[] = [
{
label: 'Monitoring',
items: [
{ label: 'Request Logs', path: '/monitoring/request-logs' },
],
},
{
label: 'API Keys',
items: [
{ label: 'My Keys', path: '/apikeys/my-keys' },
{ label: 'Request', path: '/apikeys/request' },
{ label: 'Admin', path: '/apikeys/admin' },
],
},
{
label: 'Admin',
adminOnly: true,
items: [
{ label: 'Services', path: '/admin/services' },
{ label: 'Users', path: '/admin/users' },
{ label: 'Tenants', path: '/admin/tenants' },
],
},
];
const MainLayout = () => {
const { user, logout } = useAuth();
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({
Monitoring: true,
'API Keys': true,
Admin: true,
});
const toggleGroup = (label: string) => {
setOpenGroups((prev) => ({ ...prev, [label]: !prev[label] }));
};
const handleLogout = async () => {
await logout();
};
const isAdminOrManager = user?.role === 'ADMIN' || user?.role === 'MANAGER';
return (
<div className="flex min-h-screen">
{/* Sidebar */}
<aside className="fixed left-0 top-0 h-screen w-64 bg-gray-900 text-white flex flex-col">
<div className="flex items-center gap-2 px-6 py-5 border-b border-gray-700">
<svg className="h-6 w-6 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span className="text-lg font-semibold">SNP Connection</span>
</div>
<nav className="flex-1 overflow-y-auto px-3 py-4">
{/* Dashboard */}
<NavLink
to="/dashboard"
className={({ isActive }) =>
`flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
isActive ? 'bg-gray-700 text-white' : 'text-gray-300 hover:bg-gray-800 hover:text-white'
}`
}
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
Dashboard
</NavLink>
{/* Nav Groups */}
{navGroups.map((group) => {
if (group.adminOnly && user?.role !== 'ADMIN') return null;
const isOpen = openGroups[group.label] ?? false;
return (
<div key={group.label} className="mt-4">
<button
onClick={() => toggleGroup(group.label)}
className="flex w-full items-center justify-between rounded-lg px-3 py-2 text-xs font-semibold uppercase tracking-wider text-gray-400 hover:text-white"
>
{group.label}
<svg
className={`h-4 w-4 transition-transform ${isOpen ? 'rotate-90' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
{isOpen && (
<div className="ml-2 space-y-1">
{group.items.map((item) => {
if (
group.label === 'API Keys' &&
item.label === 'Admin' &&
!isAdminOrManager
) {
return null;
}
return (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) =>
`block rounded-lg px-3 py-2 text-sm transition-colors ${
isActive ? 'bg-gray-700 text-white' : 'text-gray-300 hover:bg-gray-800 hover:text-white'
}`
}
>
{item.label}
</NavLink>
);
})}
</div>
)}
</div>
);
})}
</nav>
</aside>
{/* Main Content */}
<div className="flex-1 ml-64">
{/* Header */}
<header className="h-16 bg-white border-b border-gray-200 flex items-center justify-between px-6">
<div />
<div className="flex items-center gap-4">
<span className="text-sm text-gray-700">{user?.userName}</span>
<span className="rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800">
{user?.role}
</span>
<button
onClick={handleLogout}
className="rounded-lg px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition-colors"
>
Logout
</button>
</div>
</header>
{/* Content */}
<main className="p-6">
<Outlet />
</main>
</div>
</div>
);
};
export default MainLayout;

파일 보기

@ -0,0 +1,10 @@
const DashboardPage = () => {
return (
<div>
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
<p className="mt-2 text-gray-600">Coming soon</p>
</div>
);
};
export default DashboardPage;

파일 보기

@ -0,0 +1,93 @@
import { useState } from 'react';
import type { FormEvent } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
const LoginPage = () => {
const navigate = useNavigate();
const { login } = useAuth();
const [loginId, setLoginId] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError('');
if (!loginId.trim() || !password.trim()) {
setError('아이디와 비밀번호를 입력해주세요.');
return;
}
setIsSubmitting(true);
try {
await login(loginId, password);
navigate('/dashboard', { replace: true });
} catch (err) {
if (err instanceof Error) {
setError(err.message);
} else {
setError('로그인에 실패했습니다.');
}
} finally {
setIsSubmitting(false);
}
};
return (
<div className="w-full max-w-md">
<div className="rounded-xl bg-white px-8 py-10 shadow-lg">
<h1 className="mb-8 text-center text-2xl font-bold text-gray-900">
SNP Connection Monitoring
</h1>
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label htmlFor="loginId" className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
id="loginId"
type="text"
value={loginId}
onChange={(e) => setLoginId(e.target.value)}
className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="아이디를 입력하세요"
autoComplete="username"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="비밀번호를 입력하세요"
autoComplete="current-password"
/>
</div>
{error && (
<p className="text-sm text-red-600">{error}</p>
)}
<button
type="submit"
disabled={isSubmitting}
className="w-full rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 transition-colors"
>
{isSubmitting ? '로그인 중...' : '로그인'}
</button>
</form>
</div>
</div>
);
};
export default LoginPage;

파일 보기

@ -0,0 +1,18 @@
import { Link } from 'react-router-dom';
const NotFoundPage = () => {
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center text-center">
<h1 className="text-4xl font-bold text-gray-900">404 - Page Not Found</h1>
<p className="mt-3 text-gray-600"> .</p>
<Link
to="/dashboard"
className="mt-6 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 transition-colors"
>
Dashboard로
</Link>
</div>
);
};
export default NotFoundPage;

파일 보기

@ -0,0 +1,10 @@
const ServicesPage = () => {
return (
<div>
<h1 className="text-2xl font-bold text-gray-900">Services</h1>
<p className="mt-2 text-gray-600">Coming soon</p>
</div>
);
};
export default ServicesPage;

파일 보기

@ -0,0 +1,10 @@
const TenantsPage = () => {
return (
<div>
<h1 className="text-2xl font-bold text-gray-900">Tenants</h1>
<p className="mt-2 text-gray-600">Coming soon</p>
</div>
);
};
export default TenantsPage;

파일 보기

@ -0,0 +1,10 @@
const UsersPage = () => {
return (
<div>
<h1 className="text-2xl font-bold text-gray-900">Users</h1>
<p className="mt-2 text-gray-600">Coming soon</p>
</div>
);
};
export default UsersPage;

파일 보기

@ -0,0 +1,10 @@
const KeyAdminPage = () => {
return (
<div>
<h1 className="text-2xl font-bold text-gray-900">API Key Admin</h1>
<p className="mt-2 text-gray-600">Coming soon</p>
</div>
);
};
export default KeyAdminPage;

파일 보기

@ -0,0 +1,10 @@
const KeyRequestPage = () => {
return (
<div>
<h1 className="text-2xl font-bold text-gray-900">API Key Request</h1>
<p className="mt-2 text-gray-600">Coming soon</p>
</div>
);
};
export default KeyRequestPage;

파일 보기

@ -0,0 +1,10 @@
const MyKeysPage = () => {
return (
<div>
<h1 className="text-2xl font-bold text-gray-900">My API Keys</h1>
<p className="mt-2 text-gray-600">Coming soon</p>
</div>
);
};
export default MyKeysPage;

파일 보기

@ -0,0 +1,10 @@
const RequestLogsPage = () => {
return (
<div>
<h1 className="text-2xl font-bold text-gray-900">Request Logs</h1>
<p className="mt-2 text-gray-600">Coming soon</p>
</div>
);
};
export default RequestLogsPage;

파일 보기

@ -0,0 +1,116 @@
import type { ApiResponse } from '../types/api';
const BASE_URL = '/snp-connection/api';
let isRefreshing = false;
let refreshPromise: Promise<boolean> | null = null;
const getAccessToken = (): string | null => {
return localStorage.getItem('snp_access_token');
};
const getRefreshToken = (): string | null => {
return localStorage.getItem('snp_refresh_token');
};
const clearStorage = () => {
localStorage.removeItem('snp_access_token');
localStorage.removeItem('snp_refresh_token');
localStorage.removeItem('snp_user');
};
const attemptRefresh = async (): Promise<boolean> => {
const refreshToken = getRefreshToken();
if (!refreshToken) return false;
try {
const response = await fetch(`${BASE_URL}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
});
if (!response.ok) return false;
const data = await response.json() as ApiResponse<{ accessToken: string }>;
if (data.success && data.data?.accessToken) {
localStorage.setItem('snp_access_token', data.data.accessToken);
return true;
}
return false;
} catch {
return false;
}
};
const handleTokenRefresh = async (): Promise<boolean> => {
if (isRefreshing && refreshPromise) {
return refreshPromise;
}
isRefreshing = true;
refreshPromise = attemptRefresh().finally(() => {
isRefreshing = false;
refreshPromise = null;
});
return refreshPromise;
};
const request = async <T>(url: string, options: RequestInit = {}): Promise<ApiResponse<T>> => {
const token = getAccessToken();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...options.headers as Record<string, string>,
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
let response = await fetch(`${BASE_URL}${url}`, {
...options,
headers,
});
if (response.status === 401) {
const refreshed = await handleTokenRefresh();
if (refreshed) {
const newToken = getAccessToken();
headers['Authorization'] = `Bearer ${newToken}`;
response = await fetch(`${BASE_URL}${url}`, {
...options,
headers,
});
} else {
clearStorage();
window.location.href = '/snp-connection/login';
return { success: false, message: 'Authentication failed' };
}
}
const data = await response.json() as ApiResponse<T>;
return data;
};
export const get = <T>(url: string): Promise<ApiResponse<T>> => {
return request<T>(url, { method: 'GET' });
};
export const post = <T>(url: string, body?: unknown): Promise<ApiResponse<T>> => {
return request<T>(url, {
method: 'POST',
body: body ? JSON.stringify(body) : undefined,
});
};
export const put = <T>(url: string, body?: unknown): Promise<ApiResponse<T>> => {
return request<T>(url, {
method: 'PUT',
body: body ? JSON.stringify(body) : undefined,
});
};
export const del = <T>(url: string): Promise<ApiResponse<T>> => {
return request<T>(url, { method: 'DELETE' });
};

파일 보기

@ -0,0 +1,67 @@
import { post } from './apiClient';
import type { LoginRequest, LoginResponse, User } from '../types/auth';
const TOKEN_KEY = 'snp_access_token';
const REFRESH_TOKEN_KEY = 'snp_refresh_token';
const USER_KEY = 'snp_user';
export const login = async (req: LoginRequest): Promise<LoginResponse> => {
const response = await post<LoginResponse>('/auth/login', req);
if (!response.success || !response.data) {
throw new Error(response.message || '로그인에 실패했습니다.');
}
const data = response.data;
localStorage.setItem(TOKEN_KEY, data.accessToken);
localStorage.setItem(REFRESH_TOKEN_KEY, data.refreshToken);
localStorage.setItem(USER_KEY, JSON.stringify({
loginId: data.loginId,
userName: data.userName,
role: data.role,
}));
return data;
};
export const logout = async (): Promise<void> => {
try {
await post('/auth/logout');
} finally {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
localStorage.removeItem(USER_KEY);
}
};
export const refreshToken = async (): Promise<void> => {
const storedRefreshToken = localStorage.getItem(REFRESH_TOKEN_KEY);
if (!storedRefreshToken) {
throw new Error('No refresh token available');
}
const response = await post<{ accessToken: string }>('/auth/refresh', {
refreshToken: storedRefreshToken,
});
if (!response.success || !response.data) {
throw new Error('Token refresh failed');
}
localStorage.setItem(TOKEN_KEY, response.data.accessToken);
};
export const getStoredUser = (): User | null => {
const userStr = localStorage.getItem(USER_KEY);
if (!userStr) return null;
try {
return JSON.parse(userStr) as User;
} catch {
return null;
}
};
export const getAccessToken = (): string | null => {
return localStorage.getItem(TOKEN_KEY);
};

파일 보기

@ -0,0 +1,78 @@
import { createContext, useState, useEffect, useCallback } from 'react';
import type { ReactNode } from 'react';
import type { User } from '../types/auth';
import * as authService from '../services/authService';
interface AuthState {
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
}
interface AuthContextValue extends AuthState {
login: (loginId: string, password: string) => Promise<void>;
logout: () => Promise<void>;
}
export const AuthContext = createContext<AuthContextValue | null>(null);
interface AuthProviderProps {
children: ReactNode;
}
const AuthProvider = ({ children }: AuthProviderProps) => {
const [state, setState] = useState<AuthState>({
user: null,
isAuthenticated: false,
isLoading: true,
});
useEffect(() => {
const storedUser = authService.getStoredUser();
const token = authService.getAccessToken();
if (storedUser && token) {
setState({
user: storedUser,
isAuthenticated: true,
isLoading: false,
});
} else {
setState({
user: null,
isAuthenticated: false,
isLoading: false,
});
}
}, []);
const login = useCallback(async (loginId: string, password: string) => {
const response = await authService.login({ loginId, password });
setState({
user: {
loginId: response.loginId,
userName: response.userName,
role: response.role,
},
isAuthenticated: true,
isLoading: false,
});
}, []);
const logout = useCallback(async () => {
await authService.logout();
setState({
user: null,
isAuthenticated: false,
isLoading: false,
});
}, []);
return (
<AuthContext.Provider value={{ ...state, login, logout }}>
{children}
</AuthContext.Provider>
);
};
export default AuthProvider;

파일 보기

@ -0,0 +1,5 @@
export interface ApiResponse<T> {
success: boolean;
message?: string;
data?: T;
}

파일 보기

@ -0,0 +1,19 @@
export interface LoginRequest {
loginId: string;
password: string;
}
export interface LoginResponse {
accessToken: string;
refreshToken: string;
loginId: string;
userName: string;
role: string;
expiresIn: number;
}
export interface User {
loginId: string;
userName: string;
role: string;
}

19
pom.xml
파일 보기

@ -104,6 +104,25 @@
<version>2.3.0</version> <version>2.3.0</version>
</dependency> </dependency>
<!-- JWT (jjwt) -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<!-- Test Dependencies --> <!-- Test Dependencies -->
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>

파일 보기

@ -0,0 +1,9 @@
package com.gcsc.connection.apikey.entity;
public enum ApiKeyStatus {
PENDING,
ACTIVE,
INACTIVE,
EXPIRED,
REVOKED
}

파일 보기

@ -0,0 +1,7 @@
package com.gcsc.connection.apikey.entity;
public enum KeyRequestStatus {
PENDING,
APPROVED,
REJECTED
}

파일 보기

@ -0,0 +1,76 @@
package com.gcsc.connection.apikey.entity;
import com.gcsc.connection.common.entity.BaseEntity;
import com.gcsc.connection.user.entity.SnpUser;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Getter
@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED)
@Entity
@Table(name = "snp_api_key", schema = "common")
public class SnpApiKey extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "api_key_id")
private Long apiKeyId;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private SnpUser user;
@Column(name = "api_key", length = 128, nullable = false, unique = true)
private String apiKey;
@Column(name = "api_key_prefix", length = 10)
private String apiKeyPrefix;
@Column(name = "key_name", length = 200, nullable = false)
private String keyName;
@Enumerated(EnumType.STRING)
@Column(name = "status", length = 20, nullable = false)
private ApiKeyStatus status = ApiKeyStatus.PENDING;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "approved_by")
private SnpUser approvedBy;
@Column(name = "approved_at")
private LocalDateTime approvedAt;
@Column(name = "expires_at")
private LocalDateTime expiresAt;
@Column(name = "last_used_at")
private LocalDateTime lastUsedAt;
@Builder
public SnpApiKey(SnpUser user, String apiKey, String apiKeyPrefix, String keyName,
ApiKeyStatus status, SnpUser approvedBy, LocalDateTime approvedAt,
LocalDateTime expiresAt) {
this.user = user;
this.apiKey = apiKey;
this.apiKeyPrefix = apiKeyPrefix;
this.keyName = keyName;
this.status = status != null ? status : ApiKeyStatus.PENDING;
this.approvedBy = approvedBy;
this.approvedAt = approvedAt;
this.expiresAt = expiresAt;
}
}

파일 보기

@ -0,0 +1,69 @@
package com.gcsc.connection.apikey.entity;
import com.gcsc.connection.common.entity.BaseEntity;
import com.gcsc.connection.user.entity.SnpUser;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Getter
@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED)
@Entity
@Table(name = "snp_api_key_request", schema = "common")
public class SnpApiKeyRequest extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "request_id")
private Long requestId;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private SnpUser user;
@Column(name = "key_name", length = 200, nullable = false)
private String keyName;
@Column(name = "purpose", columnDefinition = "TEXT")
private String purpose;
@Column(name = "requested_apis", columnDefinition = "TEXT")
private String requestedApis;
@Enumerated(EnumType.STRING)
@Column(name = "status", length = 20, nullable = false)
private KeyRequestStatus status = KeyRequestStatus.PENDING;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "reviewed_by")
private SnpUser reviewedBy;
@Column(name = "reviewed_at")
private LocalDateTime reviewedAt;
@Column(name = "review_comment", columnDefinition = "TEXT")
private String reviewComment;
@Builder
public SnpApiKeyRequest(SnpUser user, String keyName, String purpose, String requestedApis,
KeyRequestStatus status) {
this.user = user;
this.keyName = keyName;
this.purpose = purpose;
this.requestedApis = requestedApis;
this.status = status != null ? status : KeyRequestStatus.PENDING;
}
}

파일 보기

@ -0,0 +1,61 @@
package com.gcsc.connection.apikey.entity;
import com.gcsc.connection.service.entity.SnpServiceApi;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Getter
@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED)
@Entity
@Table(name = "snp_api_permission", schema = "common",
uniqueConstraints = @UniqueConstraint(columnNames = {"api_key_id", "api_id"}))
public class SnpApiPermission {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "permission_id")
private Long permissionId;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "api_key_id", nullable = false)
private SnpApiKey apiKey;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "api_id", nullable = false)
private SnpServiceApi api;
@Column(name = "is_active", nullable = false)
private Boolean isActive = true;
@Column(name = "granted_by")
private Long grantedBy;
@Column(name = "granted_at", nullable = false)
private LocalDateTime grantedAt;
@Column(name = "revoked_at")
private LocalDateTime revokedAt;
@Builder
public SnpApiPermission(SnpApiKey apiKey, SnpServiceApi api, Boolean isActive,
Long grantedBy, LocalDateTime grantedAt) {
this.apiKey = apiKey;
this.api = api;
this.isActive = isActive != null ? isActive : true;
this.grantedBy = grantedBy;
this.grantedAt = grantedAt != null ? grantedAt : LocalDateTime.now();
}
}

파일 보기

@ -0,0 +1,11 @@
package com.gcsc.connection.apikey.repository;
import com.gcsc.connection.apikey.entity.SnpApiKey;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface SnpApiKeyRepository extends JpaRepository<SnpApiKey, Long> {
Optional<SnpApiKey> findByApiKey(String apiKey);
}

파일 보기

@ -0,0 +1,7 @@
package com.gcsc.connection.apikey.repository;
import com.gcsc.connection.apikey.entity.SnpApiKeyRequest;
import org.springframework.data.jpa.repository.JpaRepository;
public interface SnpApiKeyRequestRepository extends JpaRepository<SnpApiKeyRequest, Long> {
}

파일 보기

@ -0,0 +1,7 @@
package com.gcsc.connection.apikey.repository;
import com.gcsc.connection.apikey.entity.SnpApiPermission;
import org.springframework.data.jpa.repository.JpaRepository;
public interface SnpApiPermissionRepository extends JpaRepository<SnpApiPermission, Long> {
}

파일 보기

@ -0,0 +1,54 @@
package com.gcsc.connection.auth.controller;
import com.gcsc.connection.auth.dto.LoginRequest;
import com.gcsc.connection.auth.dto.LoginResponse;
import com.gcsc.connection.auth.dto.TokenRefreshRequest;
import com.gcsc.connection.auth.dto.TokenRefreshResponse;
import com.gcsc.connection.auth.service.AuthService;
import com.gcsc.connection.common.dto.ApiResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 인증 관련 API (로그인, 로그아웃, 토큰 갱신)
*/
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
/**
* 로그인 - JWT 토큰 발급
*/
@PostMapping("/login")
public ResponseEntity<ApiResponse<LoginResponse>> login(
@RequestBody @Valid LoginRequest request) {
LoginResponse response = authService.login(request);
return ResponseEntity.ok(ApiResponse.ok(response));
}
/**
* 로그아웃
*/
@PostMapping("/logout")
public ResponseEntity<ApiResponse<Void>> logout() {
return ResponseEntity.ok(ApiResponse.ok(null, "로그아웃되었습니다"));
}
/**
* 토큰 갱신 - Refresh Token으로 Access Token 발급
*/
@PostMapping("/refresh")
public ResponseEntity<ApiResponse<TokenRefreshResponse>> refresh(
@RequestBody @Valid TokenRefreshRequest request) {
TokenRefreshResponse response = authService.refresh(request);
return ResponseEntity.ok(ApiResponse.ok(response));
}
}

파일 보기

@ -0,0 +1,9 @@
package com.gcsc.connection.auth.dto;
import jakarta.validation.constraints.NotBlank;
public record LoginRequest(
@NotBlank String loginId,
@NotBlank String password
) {
}

파일 보기

@ -0,0 +1,11 @@
package com.gcsc.connection.auth.dto;
public record LoginResponse(
String accessToken,
String refreshToken,
String loginId,
String userName,
String role,
long expiresIn
) {
}

파일 보기

@ -0,0 +1,8 @@
package com.gcsc.connection.auth.dto;
import jakarta.validation.constraints.NotBlank;
public record TokenRefreshRequest(
@NotBlank String refreshToken
) {
}

파일 보기

@ -0,0 +1,7 @@
package com.gcsc.connection.auth.dto;
public record TokenRefreshResponse(
String accessToken,
long expiresIn
) {
}

파일 보기

@ -0,0 +1,67 @@
package com.gcsc.connection.auth.jwt;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.List;
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private static final String AUTHORIZATION_HEADER = "Authorization";
private static final String BEARER_PREFIX = "Bearer ";
private final JwtTokenProvider jwtTokenProvider;
private final AntPathMatcher pathMatcher = new AntPathMatcher();
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = resolveToken(request);
if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) {
Long userId = jwtTokenProvider.getUserId(token);
String loginId = jwtTokenProvider.getLoginId(token);
String role = jwtTokenProvider.parseClaims(token).get("role", String.class);
List<SimpleGrantedAuthority> authorities =
List.of(new SimpleGrantedAuthority("ROLE_" + role));
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userId, loginId, authorities);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String path = request.getServletPath();
// /api/ 경로가 아니거나 /api/auth/** 경로인 경우 필터 건너뜀
return !path.startsWith("/api/") || pathMatcher.match("/api/auth/**", path);
}
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
return bearerToken.substring(BEARER_PREFIX.length());
}
return null;
}
}

파일 보기

@ -0,0 +1,107 @@
package com.gcsc.connection.auth.jwt;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
@Slf4j
@Component
public class JwtTokenProvider {
private final SecretKey secretKey;
private final long accessTokenExpiration;
private final long refreshTokenExpiration;
public JwtTokenProvider(
@Value("${app.jwt.secret}") String secret,
@Value("${app.jwt.access-token-expiration}") long accessTokenExpiration,
@Value("${app.jwt.refresh-token-expiration}") long refreshTokenExpiration) {
this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
this.accessTokenExpiration = accessTokenExpiration;
this.refreshTokenExpiration = refreshTokenExpiration;
}
/**
* Access Token 생성
*/
public String generateAccessToken(Long userId, String loginId, String role) {
Date now = new Date();
Date expiry = new Date(now.getTime() + accessTokenExpiration);
return Jwts.builder()
.subject(String.valueOf(userId))
.claim("loginId", loginId)
.claim("role", role)
.issuedAt(now)
.expiration(expiry)
.signWith(secretKey)
.compact();
}
/**
* Refresh Token 생성
*/
public String generateRefreshToken(Long userId) {
Date now = new Date();
Date expiry = new Date(now.getTime() + refreshTokenExpiration);
return Jwts.builder()
.subject(String.valueOf(userId))
.issuedAt(now)
.expiration(expiry)
.signWith(secretKey)
.compact();
}
/**
* 토큰에서 Claims 파싱
*/
public Claims parseClaims(String token) {
return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload();
}
/**
* 토큰 유효성 검증
*/
public boolean validateToken(String token) {
try {
parseClaims(token);
return true;
} catch (ExpiredJwtException e) {
log.warn("만료된 JWT 토큰입니다");
} catch (Exception e) {
log.warn("유효하지 않은 JWT 토큰입니다: {}", e.getMessage());
}
return false;
}
/**
* 토큰에서 사용자 ID 추출
*/
public Long getUserId(String token) {
return Long.parseLong(parseClaims(token).getSubject());
}
/**
* 토큰에서 로그인 ID 추출
*/
public String getLoginId(String token) {
return parseClaims(token).get("loginId", String.class);
}
public long getAccessTokenExpiration() {
return accessTokenExpiration;
}
}

파일 보기

@ -0,0 +1,83 @@
package com.gcsc.connection.auth.service;
import com.gcsc.connection.auth.dto.LoginRequest;
import com.gcsc.connection.auth.dto.LoginResponse;
import com.gcsc.connection.auth.dto.TokenRefreshRequest;
import com.gcsc.connection.auth.dto.TokenRefreshResponse;
import com.gcsc.connection.auth.jwt.JwtTokenProvider;
import com.gcsc.connection.common.exception.BusinessException;
import com.gcsc.connection.common.exception.ErrorCode;
import com.gcsc.connection.user.entity.SnpUser;
import com.gcsc.connection.user.repository.SnpUserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Service
@RequiredArgsConstructor
public class AuthService {
private final SnpUserRepository userRepository;
private final JwtTokenProvider jwtTokenProvider;
private final PasswordEncoder passwordEncoder;
/**
* 로그인 처리: 사용자 인증 JWT 토큰 발급
*/
@Transactional
public LoginResponse login(LoginRequest request) {
SnpUser user = userRepository.findByLoginId(request.loginId())
.orElseThrow(() -> new BusinessException(ErrorCode.INVALID_CREDENTIALS));
if (!Boolean.TRUE.equals(user.getIsActive())) {
throw new BusinessException(ErrorCode.USER_DISABLED);
}
if (!passwordEncoder.matches(request.password(), user.getPasswordHash())) {
throw new BusinessException(ErrorCode.INVALID_CREDENTIALS);
}
String accessToken = jwtTokenProvider.generateAccessToken(
user.getUserId(), user.getLoginId(), user.getRole().name());
String refreshToken = jwtTokenProvider.generateRefreshToken(user.getUserId());
user.updateLastLoginAt();
log.info("사용자 로그인 성공: {}", user.getLoginId());
return new LoginResponse(
accessToken,
refreshToken,
user.getLoginId(),
user.getUserName(),
user.getRole().name(),
jwtTokenProvider.getAccessTokenExpiration()
);
}
/**
* 토큰 갱신: Refresh Token으로 Access Token 발급
*/
@Transactional(readOnly = true)
public TokenRefreshResponse refresh(TokenRefreshRequest request) {
if (!jwtTokenProvider.validateToken(request.refreshToken())) {
throw new BusinessException(ErrorCode.TOKEN_INVALID);
}
Long userId = jwtTokenProvider.getUserId(request.refreshToken());
SnpUser user = userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
if (!Boolean.TRUE.equals(user.getIsActive())) {
throw new BusinessException(ErrorCode.USER_DISABLED);
}
String accessToken = jwtTokenProvider.generateAccessToken(
user.getUserId(), user.getLoginId(), user.getRole().name());
return new TokenRefreshResponse(accessToken, jwtTokenProvider.getAccessTokenExpiration());
}
}

파일 보기

@ -0,0 +1,24 @@
package com.gcsc.connection.common.dto;
import org.springframework.data.domain.Page;
import java.util.List;
public record PageResponse<T>(
List<T> content,
int page,
int size,
long totalElements,
int totalPages
) {
public static <T> PageResponse<T> from(Page<T> page) {
return new PageResponse<>(
page.getContent(),
page.getNumber(),
page.getSize(),
page.getTotalElements(),
page.getTotalPages()
);
}
}

파일 보기

@ -0,0 +1,33 @@
package com.gcsc.connection.common.entity;
import jakarta.persistence.Column;
import jakarta.persistence.MappedSuperclass;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Getter
@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED)
@MappedSuperclass
public abstract class BaseEntity {
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
this.createdAt = LocalDateTime.now();
this.updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
this.updatedAt = LocalDateTime.now();
}
}

파일 보기

@ -0,0 +1,14 @@
package com.gcsc.connection.common.exception;
import lombok.Getter;
@Getter
public class BusinessException extends RuntimeException {
private final ErrorCode errorCode;
public BusinessException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.errorCode = errorCode;
}
}

파일 보기

@ -0,0 +1,21 @@
package com.gcsc.connection.common.exception;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
public enum ErrorCode {
INVALID_CREDENTIALS(401, "AUTH001", "아이디 또는 비밀번호가 올바르지 않습니다"),
TOKEN_EXPIRED(401, "AUTH002", "토큰이 만료되었습니다"),
TOKEN_INVALID(401, "AUTH003", "유효하지 않은 토큰입니다"),
ACCESS_DENIED(403, "AUTH004", "접근 권한이 없습니다"),
USER_NOT_FOUND(404, "USER001", "사용자를 찾을 수 없습니다"),
USER_DISABLED(403, "USER002", "비활성화된 사용자입니다"),
INTERNAL_ERROR(500, "SYS001", "시스템 오류가 발생했습니다");
private final int status;
private final String code;
private final String message;
}

파일 보기

@ -4,13 +4,46 @@ import com.gcsc.connection.common.dto.ApiResponse;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.stream.Collectors;
@Slf4j @Slf4j
@RestControllerAdvice @RestControllerAdvice
public class GlobalExceptionHandler { public class GlobalExceptionHandler {
/**
* 비즈니스 예외 처리
*/
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ApiResponse<Void>> handleBusinessException(BusinessException e) {
ErrorCode errorCode = e.getErrorCode();
log.warn("Business exception: {} - {}", errorCode.getCode(), errorCode.getMessage());
return ResponseEntity
.status(errorCode.getStatus())
.body(ApiResponse.error(errorCode.getMessage()));
}
/**
* 요청 파라미터 유효성 검증 실패 처리
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Void>> handleValidationException(
MethodArgumentNotValidException e) {
String message = e.getBindingResult().getFieldErrors().stream()
.map(error -> error.getField() + ": " + error.getDefaultMessage())
.collect(Collectors.joining(", "));
log.warn("Validation failed: {}", message);
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error(message));
}
/**
* 처리되지 않은 예외 처리
*/
@ExceptionHandler(Exception.class) @ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleException(Exception e) { public ResponseEntity<ApiResponse<Void>> handleException(Exception e) {
log.error("Unhandled exception: {}", e.getMessage(), e); log.error("Unhandled exception: {}", e.getMessage(), e);

파일 보기

@ -1,25 +1,47 @@
package com.gcsc.connection.config; package com.gcsc.connection.config;
import com.gcsc.connection.auth.jwt.JwtAuthenticationFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer; import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration @Configuration
@EnableWebSecurity @EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig { public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean @Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http http
.csrf(AbstractHttpConfigurer::disable) .csrf(AbstractHttpConfigurer::disable)
.headers(headers -> headers .headers(headers -> headers
.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin)) .frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin))
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
.anyRequest().permitAll()); .requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
.requestMatchers("/actuator/**").permitAll()
.requestMatchers("/", "/*.html", "/assets/**", "/favicon*", "/site.webmanifest").permitAll()
.requestMatchers("/api/**").authenticated()
.anyRequest().permitAll())
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build(); return http.build();
} }

파일 보기

@ -0,0 +1,104 @@
package com.gcsc.connection.monitoring.entity;
import com.gcsc.connection.apikey.entity.SnpApiKey;
import com.gcsc.connection.service.entity.SnpService;
import com.gcsc.connection.tenant.entity.SnpTenant;
import com.gcsc.connection.user.entity.SnpUser;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Getter
@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED)
@Entity
@Table(name = "snp_api_request_log", schema = "common")
public class SnpApiRequestLog {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "log_id")
private Long logId;
@Column(name = "request_url", length = 2000)
private String requestUrl;
@Column(name = "request_params", columnDefinition = "TEXT")
private String requestParams;
@Column(name = "request_method", length = 10)
private String requestMethod;
@Column(name = "request_status", length = 20)
private String requestStatus;
@Column(name = "request_headers", columnDefinition = "TEXT")
private String requestHeaders;
@Column(name = "request_ip", length = 45)
private String requestIp;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "service_id")
private SnpService service;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private SnpUser user;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "api_key_id")
private SnpApiKey apiKey;
@Column(name = "response_size")
private Long responseSize;
@Column(name = "response_time")
private Integer responseTime;
@Column(name = "response_status")
private Integer responseStatus;
@Column(name = "error_message", columnDefinition = "TEXT")
private String errorMessage;
@Column(name = "requested_at", nullable = false)
private LocalDateTime requestedAt;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "tenant_id")
private SnpTenant tenant;
@Builder
public SnpApiRequestLog(String requestUrl, String requestParams, String requestMethod,
String requestStatus, String requestHeaders, String requestIp,
SnpService service, SnpUser user, SnpApiKey apiKey,
Long responseSize, Integer responseTime, Integer responseStatus,
String errorMessage, LocalDateTime requestedAt, SnpTenant tenant) {
this.requestUrl = requestUrl;
this.requestParams = requestParams;
this.requestMethod = requestMethod;
this.requestStatus = requestStatus;
this.requestHeaders = requestHeaders;
this.requestIp = requestIp;
this.service = service;
this.user = user;
this.apiKey = apiKey;
this.responseSize = responseSize;
this.responseTime = responseTime;
this.responseStatus = responseStatus;
this.errorMessage = errorMessage;
this.requestedAt = requestedAt != null ? requestedAt : LocalDateTime.now();
this.tenant = tenant;
}
}

파일 보기

@ -0,0 +1,59 @@
package com.gcsc.connection.monitoring.entity;
import com.gcsc.connection.service.entity.SnpService;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Getter
@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED)
@Entity
@Table(name = "snp_service_health_log", schema = "common")
public class SnpServiceHealthLog {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "log_id")
private Long logId;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "service_id", nullable = false)
private SnpService service;
@Column(name = "previous_status", length = 10)
private String previousStatus;
@Column(name = "current_status", length = 10, nullable = false)
private String currentStatus;
@Column(name = "response_time")
private Integer responseTime;
@Column(name = "error_message", columnDefinition = "TEXT")
private String errorMessage;
@Column(name = "checked_at", nullable = false)
private LocalDateTime checkedAt;
@Builder
public SnpServiceHealthLog(SnpService service, String previousStatus, String currentStatus,
Integer responseTime, String errorMessage, LocalDateTime checkedAt) {
this.service = service;
this.previousStatus = previousStatus;
this.currentStatus = currentStatus;
this.responseTime = responseTime;
this.errorMessage = errorMessage;
this.checkedAt = checkedAt != null ? checkedAt : LocalDateTime.now();
}
}

파일 보기

@ -0,0 +1,7 @@
package com.gcsc.connection.monitoring.repository;
import com.gcsc.connection.monitoring.entity.SnpApiRequestLog;
import org.springframework.data.jpa.repository.JpaRepository;
public interface SnpApiRequestLogRepository extends JpaRepository<SnpApiRequestLog, Long> {
}

파일 보기

@ -0,0 +1,7 @@
package com.gcsc.connection.monitoring.repository;
import com.gcsc.connection.monitoring.entity.SnpServiceHealthLog;
import org.springframework.data.jpa.repository.JpaRepository;
public interface SnpServiceHealthLogRepository extends JpaRepository<SnpServiceHealthLog, Long> {
}

파일 보기

@ -0,0 +1,7 @@
package com.gcsc.connection.service.entity;
public enum ServiceStatus {
UP,
DOWN,
UNKNOWN
}

파일 보기

@ -0,0 +1,73 @@
package com.gcsc.connection.service.entity;
import com.gcsc.connection.common.entity.BaseEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Getter
@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED)
@Entity
@Table(name = "snp_service", schema = "common")
public class SnpService extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "service_id")
private Long serviceId;
@Column(name = "service_code", length = 50, nullable = false, unique = true)
private String serviceCode;
@Column(name = "service_name", length = 200, nullable = false)
private String serviceName;
@Column(name = "service_url", length = 500)
private String serviceUrl;
@Column(name = "description", columnDefinition = "TEXT")
private String description;
@Column(name = "health_check_url", length = 500)
private String healthCheckUrl;
@Column(name = "health_check_interval", nullable = false)
private Integer healthCheckInterval = 30;
@Enumerated(EnumType.STRING)
@Column(name = "health_status", length = 10, nullable = false)
private ServiceStatus healthStatus = ServiceStatus.UNKNOWN;
@Column(name = "health_checked_at")
private LocalDateTime healthCheckedAt;
@Column(name = "health_response_time")
private Integer healthResponseTime;
@Column(name = "is_active", nullable = false)
private Boolean isActive = true;
@Builder
public SnpService(String serviceCode, String serviceName, String serviceUrl,
String description, String healthCheckUrl, Integer healthCheckInterval,
ServiceStatus healthStatus, Boolean isActive) {
this.serviceCode = serviceCode;
this.serviceName = serviceName;
this.serviceUrl = serviceUrl;
this.description = description;
this.healthCheckUrl = healthCheckUrl;
this.healthCheckInterval = healthCheckInterval != null ? healthCheckInterval : 30;
this.healthStatus = healthStatus != null ? healthStatus : ServiceStatus.UNKNOWN;
this.isActive = isActive != null ? isActive : true;
}
}

파일 보기

@ -0,0 +1,59 @@
package com.gcsc.connection.service.entity;
import com.gcsc.connection.common.entity.BaseEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Getter
@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED)
@Entity
@Table(name = "snp_service_api", schema = "common",
uniqueConstraints = @UniqueConstraint(columnNames = {"service_id", "api_path", "api_method"}))
public class SnpServiceApi extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "api_id")
private Long apiId;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "service_id", nullable = false)
private SnpService service;
@Column(name = "api_path", length = 500, nullable = false)
private String apiPath;
@Column(name = "api_method", length = 10, nullable = false)
private String apiMethod;
@Column(name = "api_name", length = 200, nullable = false)
private String apiName;
@Column(name = "description", columnDefinition = "TEXT")
private String description;
@Column(name = "is_active", nullable = false)
private Boolean isActive = true;
@Builder
public SnpServiceApi(SnpService service, String apiPath, String apiMethod, String apiName,
String description, Boolean isActive) {
this.service = service;
this.apiPath = apiPath;
this.apiMethod = apiMethod;
this.apiName = apiName;
this.description = description;
this.isActive = isActive != null ? isActive : true;
}
}

파일 보기

@ -0,0 +1,12 @@
package com.gcsc.connection.service.repository;
import com.gcsc.connection.service.entity.SnpService;
import com.gcsc.connection.service.entity.SnpServiceApi;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface SnpServiceApiRepository extends JpaRepository<SnpServiceApi, Long> {
List<SnpServiceApi> findByService(SnpService service);
}

파일 보기

@ -0,0 +1,11 @@
package com.gcsc.connection.service.repository;
import com.gcsc.connection.service.entity.SnpService;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface SnpServiceRepository extends JpaRepository<SnpService, Long> {
Optional<SnpService> findByServiceCode(String serviceCode);
}

파일 보기

@ -0,0 +1,44 @@
package com.gcsc.connection.tenant.entity;
import com.gcsc.connection.common.entity.BaseEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Getter
@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED)
@Entity
@Table(name = "snp_tenant", schema = "common")
public class SnpTenant extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "tenant_id")
private Long tenantId;
@Column(name = "tenant_code", length = 50, nullable = false, unique = true)
private String tenantCode;
@Column(name = "tenant_name", length = 200, nullable = false)
private String tenantName;
@Column(name = "description", columnDefinition = "TEXT")
private String description;
@Column(name = "is_active", nullable = false)
private Boolean isActive = true;
@Builder
public SnpTenant(String tenantCode, String tenantName, String description, Boolean isActive) {
this.tenantCode = tenantCode;
this.tenantName = tenantName;
this.description = description;
this.isActive = isActive != null ? isActive : true;
}
}

파일 보기

@ -0,0 +1,11 @@
package com.gcsc.connection.tenant.repository;
import com.gcsc.connection.tenant.entity.SnpTenant;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface SnpTenantRepository extends JpaRepository<SnpTenant, Long> {
Optional<SnpTenant> findByTenantCode(String tenantCode);
}

파일 보기

@ -0,0 +1,74 @@
package com.gcsc.connection.user.entity;
import com.gcsc.connection.common.entity.BaseEntity;
import com.gcsc.connection.tenant.entity.SnpTenant;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Getter
@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED)
@Entity
@Table(name = "snp_user", schema = "common")
public class SnpUser extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "user_id")
private Long userId;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "tenant_id")
private SnpTenant tenant;
@Column(name = "login_id", length = 100, nullable = false, unique = true)
private String loginId;
@Column(name = "password_hash", length = 255, nullable = false)
private String passwordHash;
@Column(name = "user_name", length = 100, nullable = false)
private String userName;
@Column(name = "email", length = 200)
private String email;
@Enumerated(EnumType.STRING)
@Column(name = "role", length = 20, nullable = false)
private UserRole role = UserRole.USER;
@Column(name = "is_active", nullable = false)
private Boolean isActive = true;
@Column(name = "last_login_at")
private LocalDateTime lastLoginAt;
@Builder
public SnpUser(SnpTenant tenant, String loginId, String passwordHash, String userName,
String email, UserRole role, Boolean isActive) {
this.tenant = tenant;
this.loginId = loginId;
this.passwordHash = passwordHash;
this.userName = userName;
this.email = email;
this.role = role != null ? role : UserRole.USER;
this.isActive = isActive != null ? isActive : true;
}
public void updateLastLoginAt() {
this.lastLoginAt = LocalDateTime.now();
}
}

파일 보기

@ -0,0 +1,8 @@
package com.gcsc.connection.user.entity;
public enum UserRole {
ADMIN,
MANAGER,
USER,
VIEWER
}

파일 보기

@ -0,0 +1,13 @@
package com.gcsc.connection.user.repository;
import com.gcsc.connection.user.entity.SnpUser;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface SnpUserRepository extends JpaRepository<SnpUser, Long> {
Optional<SnpUser> findByLoginId(String loginId);
boolean existsByLoginId(String loginId);
}

파일 보기

@ -4,8 +4,8 @@ spring:
# PostgreSQL Database Configuration # PostgreSQL Database Configuration
datasource: datasource:
url: jdbc:postgresql://211.208.115.83:5432/snpdb url: jdbc:postgresql://211.208.115.83:5432/snp_connection
username: snp username: snp_admin
password: snp#8932 password: snp#8932
driver-class-name: org.postgresql.Driver driver-class-name: org.postgresql.Driver
hikari: hikari:
@ -16,13 +16,13 @@ spring:
# JPA Configuration # JPA Configuration
jpa: jpa:
hibernate: hibernate:
ddl-auto: validate ddl-auto: update
show-sql: false show-sql: false
properties: properties:
hibernate: hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect dialect: org.hibernate.dialect.PostgreSQLDialect
format_sql: true format_sql: true
default_schema: std_snp_connection default_schema: common
# Server Configuration # Server Configuration
server: server:
@ -58,3 +58,7 @@ app:
heartbeat: heartbeat:
default-interval-seconds: 30 default-interval-seconds: 30
timeout-seconds: 5 timeout-seconds: 5
jwt:
secret: c25wLWNvbm5lY3Rpb24tbW9uaXRvcmluZy1qd3Qtc2VjcmV0LWtleS0yMDI2
access-token-expiration: 3600000
refresh-token-expiration: 604800000