From 48671af3c58fa9b6ad323f305c44e6305ef4b280 Mon Sep 17 00:00:00 2001 From: HYOJIN Date: Tue, 7 Apr 2026 13:52:25 +0900 Subject: [PATCH] =?UTF-8?q?feat(phase1):=20=EA=B8=B0=EB=B0=98=20=EA=B5=AC?= =?UTF-8?q?=EC=B6=95=20-=20DB=20Entity,=20JWT=20=EC=9D=B8=EC=A6=9D,=20?= =?UTF-8?q?=ED=94=84=EB=A1=A0=ED=8A=B8=EC=97=94=EB=93=9C=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=95=84=EC=9B=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 백엔드: - 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) --- CLAUDE.md | 2 +- docs/schema/create_tables.sql | 187 ++++++++++++++++++ docs/schema/initial_data.sql | 24 +++ frontend/src/App.tsx | 54 +++-- frontend/src/components/ProtectedRoute.tsx | 22 +++ frontend/src/hooks/useAuth.ts | 8 + frontend/src/layouts/AuthLayout.tsx | 11 ++ frontend/src/layouts/MainLayout.tsx | 166 ++++++++++++++++ frontend/src/pages/DashboardPage.tsx | 10 + frontend/src/pages/LoginPage.tsx | 93 +++++++++ frontend/src/pages/NotFoundPage.tsx | 18 ++ frontend/src/pages/admin/ServicesPage.tsx | 10 + frontend/src/pages/admin/TenantsPage.tsx | 10 + frontend/src/pages/admin/UsersPage.tsx | 10 + frontend/src/pages/apikeys/KeyAdminPage.tsx | 10 + frontend/src/pages/apikeys/KeyRequestPage.tsx | 10 + frontend/src/pages/apikeys/MyKeysPage.tsx | 10 + .../src/pages/monitoring/RequestLogsPage.tsx | 10 + frontend/src/services/apiClient.ts | 116 +++++++++++ frontend/src/services/authService.ts | 67 +++++++ frontend/src/store/AuthContext.tsx | 78 ++++++++ frontend/src/types/api.ts | 5 + frontend/src/types/auth.ts | 19 ++ pom.xml | 19 ++ .../apikey/entity/ApiKeyStatus.java | 9 + .../apikey/entity/KeyRequestStatus.java | 7 + .../connection/apikey/entity/SnpApiKey.java | 76 +++++++ .../apikey/entity/SnpApiKeyRequest.java | 69 +++++++ .../apikey/entity/SnpApiPermission.java | 61 ++++++ .../repository/SnpApiKeyRepository.java | 11 ++ .../SnpApiKeyRequestRepository.java | 7 + .../SnpApiPermissionRepository.java | 7 + .../auth/controller/AuthController.java | 54 +++++ .../connection/auth/dto/LoginRequest.java | 9 + .../connection/auth/dto/LoginResponse.java | 11 ++ .../auth/dto/TokenRefreshRequest.java | 8 + .../auth/dto/TokenRefreshResponse.java | 7 + .../auth/jwt/JwtAuthenticationFilter.java | 67 +++++++ .../connection/auth/jwt/JwtTokenProvider.java | 107 ++++++++++ .../connection/auth/service/AuthService.java | 83 ++++++++ .../connection/common/dto/PageResponse.java | 24 +++ .../connection/common/entity/BaseEntity.java | 33 ++++ .../common/exception/BusinessException.java | 14 ++ .../common/exception/ErrorCode.java | 21 ++ .../exception/GlobalExceptionHandler.java | 33 ++++ .../connection/config/SecurityConfig.java | 24 ++- .../monitoring/entity/SnpApiRequestLog.java | 104 ++++++++++ .../entity/SnpServiceHealthLog.java | 59 ++++++ .../SnpApiRequestLogRepository.java | 7 + .../SnpServiceHealthLogRepository.java | 7 + .../service/entity/ServiceStatus.java | 7 + .../connection/service/entity/SnpService.java | 73 +++++++ .../service/entity/SnpServiceApi.java | 59 ++++++ .../repository/SnpServiceApiRepository.java | 12 ++ .../repository/SnpServiceRepository.java | 11 ++ .../connection/tenant/entity/SnpTenant.java | 44 +++++ .../repository/SnpTenantRepository.java | 11 ++ .../gcsc/connection/user/entity/SnpUser.java | 74 +++++++ .../gcsc/connection/user/entity/UserRole.java | 8 + .../user/repository/SnpUserRepository.java | 13 ++ src/main/resources/application.yml | 12 +- 61 files changed, 2190 insertions(+), 22 deletions(-) create mode 100644 docs/schema/create_tables.sql create mode 100644 docs/schema/initial_data.sql create mode 100644 frontend/src/components/ProtectedRoute.tsx create mode 100644 frontend/src/hooks/useAuth.ts create mode 100644 frontend/src/layouts/AuthLayout.tsx create mode 100644 frontend/src/layouts/MainLayout.tsx create mode 100644 frontend/src/pages/DashboardPage.tsx create mode 100644 frontend/src/pages/LoginPage.tsx create mode 100644 frontend/src/pages/NotFoundPage.tsx create mode 100644 frontend/src/pages/admin/ServicesPage.tsx create mode 100644 frontend/src/pages/admin/TenantsPage.tsx create mode 100644 frontend/src/pages/admin/UsersPage.tsx create mode 100644 frontend/src/pages/apikeys/KeyAdminPage.tsx create mode 100644 frontend/src/pages/apikeys/KeyRequestPage.tsx create mode 100644 frontend/src/pages/apikeys/MyKeysPage.tsx create mode 100644 frontend/src/pages/monitoring/RequestLogsPage.tsx create mode 100644 frontend/src/services/apiClient.ts create mode 100644 frontend/src/services/authService.ts create mode 100644 frontend/src/store/AuthContext.tsx create mode 100644 frontend/src/types/api.ts create mode 100644 frontend/src/types/auth.ts create mode 100644 src/main/java/com/gcsc/connection/apikey/entity/ApiKeyStatus.java create mode 100644 src/main/java/com/gcsc/connection/apikey/entity/KeyRequestStatus.java create mode 100644 src/main/java/com/gcsc/connection/apikey/entity/SnpApiKey.java create mode 100644 src/main/java/com/gcsc/connection/apikey/entity/SnpApiKeyRequest.java create mode 100644 src/main/java/com/gcsc/connection/apikey/entity/SnpApiPermission.java create mode 100644 src/main/java/com/gcsc/connection/apikey/repository/SnpApiKeyRepository.java create mode 100644 src/main/java/com/gcsc/connection/apikey/repository/SnpApiKeyRequestRepository.java create mode 100644 src/main/java/com/gcsc/connection/apikey/repository/SnpApiPermissionRepository.java create mode 100644 src/main/java/com/gcsc/connection/auth/controller/AuthController.java create mode 100644 src/main/java/com/gcsc/connection/auth/dto/LoginRequest.java create mode 100644 src/main/java/com/gcsc/connection/auth/dto/LoginResponse.java create mode 100644 src/main/java/com/gcsc/connection/auth/dto/TokenRefreshRequest.java create mode 100644 src/main/java/com/gcsc/connection/auth/dto/TokenRefreshResponse.java create mode 100644 src/main/java/com/gcsc/connection/auth/jwt/JwtAuthenticationFilter.java create mode 100644 src/main/java/com/gcsc/connection/auth/jwt/JwtTokenProvider.java create mode 100644 src/main/java/com/gcsc/connection/auth/service/AuthService.java create mode 100644 src/main/java/com/gcsc/connection/common/dto/PageResponse.java create mode 100644 src/main/java/com/gcsc/connection/common/entity/BaseEntity.java create mode 100644 src/main/java/com/gcsc/connection/common/exception/BusinessException.java create mode 100644 src/main/java/com/gcsc/connection/common/exception/ErrorCode.java create mode 100644 src/main/java/com/gcsc/connection/monitoring/entity/SnpApiRequestLog.java create mode 100644 src/main/java/com/gcsc/connection/monitoring/entity/SnpServiceHealthLog.java create mode 100644 src/main/java/com/gcsc/connection/monitoring/repository/SnpApiRequestLogRepository.java create mode 100644 src/main/java/com/gcsc/connection/monitoring/repository/SnpServiceHealthLogRepository.java create mode 100644 src/main/java/com/gcsc/connection/service/entity/ServiceStatus.java create mode 100644 src/main/java/com/gcsc/connection/service/entity/SnpService.java create mode 100644 src/main/java/com/gcsc/connection/service/entity/SnpServiceApi.java create mode 100644 src/main/java/com/gcsc/connection/service/repository/SnpServiceApiRepository.java create mode 100644 src/main/java/com/gcsc/connection/service/repository/SnpServiceRepository.java create mode 100644 src/main/java/com/gcsc/connection/tenant/entity/SnpTenant.java create mode 100644 src/main/java/com/gcsc/connection/tenant/repository/SnpTenantRepository.java create mode 100644 src/main/java/com/gcsc/connection/user/entity/SnpUser.java create mode 100644 src/main/java/com/gcsc/connection/user/entity/UserRole.java create mode 100644 src/main/java/com/gcsc/connection/user/repository/SnpUserRepository.java diff --git a/CLAUDE.md b/CLAUDE.md index 4c0fedf..200cbb5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ API Gateway + 모니터링 통합 플랫폼. 모든 서비스 사용자가 모 ## 기술 스택 - Java 17, Spring Boot 3.2.1, Spring Data JPA -- PostgreSQL (스키마: std_snp_connection) +- PostgreSQL (DB: snp_connection, 스키마: common) - Spring Security (JWT 기반 인증 예정) - WebFlux WebClient (Heartbeat, Gateway Proxy) - Springdoc OpenAPI 2.3.0 (Swagger) diff --git a/docs/schema/create_tables.sql b/docs/schema/create_tables.sql new file mode 100644 index 0000000..6369579 --- /dev/null +++ b/docs/schema/create_tables.sql @@ -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); diff --git a/docs/schema/initial_data.sql b/docs/schema/initial_data.sql new file mode 100644 index 0000000..8c1d137 --- /dev/null +++ b/docs/schema/initial_data.sql @@ -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; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ce1211a..e985552 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,26 +1,48 @@ 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'; -function App() { +const App = () => { return ( - - } /> - -
-

SNP Connection Monitoring

-

Dashboard - Coming Soon

-
- - } - /> -
+ + + }> + } /> + + + }> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + +
); -} +}; export default App; diff --git a/frontend/src/components/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..6a3d913 --- /dev/null +++ b/frontend/src/components/ProtectedRoute.tsx @@ -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 ( +
+
+
+ ); + } + + if (!isAuthenticated) { + return ; + } + + return ; +}; + +export default ProtectedRoute; diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts new file mode 100644 index 0000000..68173a3 --- /dev/null +++ b/frontend/src/hooks/useAuth.ts @@ -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; +}; diff --git a/frontend/src/layouts/AuthLayout.tsx b/frontend/src/layouts/AuthLayout.tsx new file mode 100644 index 0000000..6819e6a --- /dev/null +++ b/frontend/src/layouts/AuthLayout.tsx @@ -0,0 +1,11 @@ +import { Outlet } from 'react-router-dom'; + +const AuthLayout = () => { + return ( +
+ +
+ ); +}; + +export default AuthLayout; diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx new file mode 100644 index 0000000..cd8d38c --- /dev/null +++ b/frontend/src/layouts/MainLayout.tsx @@ -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>({ + 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 ( +
+ {/* Sidebar */} + + + {/* Main Content */} +
+ {/* Header */} +
+
+
+ {user?.userName} + + {user?.role} + + +
+
+ + {/* Content */} +
+ +
+
+
+ ); +}; + +export default MainLayout; diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx new file mode 100644 index 0000000..dd0a71b --- /dev/null +++ b/frontend/src/pages/DashboardPage.tsx @@ -0,0 +1,10 @@ +const DashboardPage = () => { + return ( +
+

Dashboard

+

Coming soon

+
+ ); +}; + +export default DashboardPage; diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx new file mode 100644 index 0000000..0a0a44c --- /dev/null +++ b/frontend/src/pages/LoginPage.tsx @@ -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 ( +
+
+

+ SNP Connection Monitoring +

+ +
+
+ + 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" + /> +
+ +
+ + 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" + /> +
+ + {error && ( +

{error}

+ )} + + +
+
+
+ ); +}; + +export default LoginPage; diff --git a/frontend/src/pages/NotFoundPage.tsx b/frontend/src/pages/NotFoundPage.tsx new file mode 100644 index 0000000..5dc4188 --- /dev/null +++ b/frontend/src/pages/NotFoundPage.tsx @@ -0,0 +1,18 @@ +import { Link } from 'react-router-dom'; + +const NotFoundPage = () => { + return ( +
+

404 - Page Not Found

+

요청하신 페이지를 찾을 수 없습니다.

+ + Dashboard로 이동 + +
+ ); +}; + +export default NotFoundPage; diff --git a/frontend/src/pages/admin/ServicesPage.tsx b/frontend/src/pages/admin/ServicesPage.tsx new file mode 100644 index 0000000..49de803 --- /dev/null +++ b/frontend/src/pages/admin/ServicesPage.tsx @@ -0,0 +1,10 @@ +const ServicesPage = () => { + return ( +
+

Services

+

Coming soon

+
+ ); +}; + +export default ServicesPage; diff --git a/frontend/src/pages/admin/TenantsPage.tsx b/frontend/src/pages/admin/TenantsPage.tsx new file mode 100644 index 0000000..58cab6f --- /dev/null +++ b/frontend/src/pages/admin/TenantsPage.tsx @@ -0,0 +1,10 @@ +const TenantsPage = () => { + return ( +
+

Tenants

+

Coming soon

+
+ ); +}; + +export default TenantsPage; diff --git a/frontend/src/pages/admin/UsersPage.tsx b/frontend/src/pages/admin/UsersPage.tsx new file mode 100644 index 0000000..49e8c1f --- /dev/null +++ b/frontend/src/pages/admin/UsersPage.tsx @@ -0,0 +1,10 @@ +const UsersPage = () => { + return ( +
+

Users

+

Coming soon

+
+ ); +}; + +export default UsersPage; diff --git a/frontend/src/pages/apikeys/KeyAdminPage.tsx b/frontend/src/pages/apikeys/KeyAdminPage.tsx new file mode 100644 index 0000000..21f557f --- /dev/null +++ b/frontend/src/pages/apikeys/KeyAdminPage.tsx @@ -0,0 +1,10 @@ +const KeyAdminPage = () => { + return ( +
+

API Key Admin

+

Coming soon

+
+ ); +}; + +export default KeyAdminPage; diff --git a/frontend/src/pages/apikeys/KeyRequestPage.tsx b/frontend/src/pages/apikeys/KeyRequestPage.tsx new file mode 100644 index 0000000..52b8bcd --- /dev/null +++ b/frontend/src/pages/apikeys/KeyRequestPage.tsx @@ -0,0 +1,10 @@ +const KeyRequestPage = () => { + return ( +
+

API Key Request

+

Coming soon

+
+ ); +}; + +export default KeyRequestPage; diff --git a/frontend/src/pages/apikeys/MyKeysPage.tsx b/frontend/src/pages/apikeys/MyKeysPage.tsx new file mode 100644 index 0000000..4bc8538 --- /dev/null +++ b/frontend/src/pages/apikeys/MyKeysPage.tsx @@ -0,0 +1,10 @@ +const MyKeysPage = () => { + return ( +
+

My API Keys

+

Coming soon

+
+ ); +}; + +export default MyKeysPage; diff --git a/frontend/src/pages/monitoring/RequestLogsPage.tsx b/frontend/src/pages/monitoring/RequestLogsPage.tsx new file mode 100644 index 0000000..ac6fc43 --- /dev/null +++ b/frontend/src/pages/monitoring/RequestLogsPage.tsx @@ -0,0 +1,10 @@ +const RequestLogsPage = () => { + return ( +
+

Request Logs

+

Coming soon

+
+ ); +}; + +export default RequestLogsPage; diff --git a/frontend/src/services/apiClient.ts b/frontend/src/services/apiClient.ts new file mode 100644 index 0000000..85a3462 --- /dev/null +++ b/frontend/src/services/apiClient.ts @@ -0,0 +1,116 @@ +import type { ApiResponse } from '../types/api'; + +const BASE_URL = '/snp-connection/api'; + +let isRefreshing = false; +let refreshPromise: Promise | 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 => { + 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 => { + if (isRefreshing && refreshPromise) { + return refreshPromise; + } + + isRefreshing = true; + refreshPromise = attemptRefresh().finally(() => { + isRefreshing = false; + refreshPromise = null; + }); + + return refreshPromise; +}; + +const request = async (url: string, options: RequestInit = {}): Promise> => { + const token = getAccessToken(); + const headers: Record = { + 'Content-Type': 'application/json', + ...options.headers as Record, + }; + + 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; + return data; +}; + +export const get = (url: string): Promise> => { + return request(url, { method: 'GET' }); +}; + +export const post = (url: string, body?: unknown): Promise> => { + return request(url, { + method: 'POST', + body: body ? JSON.stringify(body) : undefined, + }); +}; + +export const put = (url: string, body?: unknown): Promise> => { + return request(url, { + method: 'PUT', + body: body ? JSON.stringify(body) : undefined, + }); +}; + +export const del = (url: string): Promise> => { + return request(url, { method: 'DELETE' }); +}; diff --git a/frontend/src/services/authService.ts b/frontend/src/services/authService.ts new file mode 100644 index 0000000..4e453ad --- /dev/null +++ b/frontend/src/services/authService.ts @@ -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 => { + const response = await post('/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 => { + try { + await post('/auth/logout'); + } finally { + localStorage.removeItem(TOKEN_KEY); + localStorage.removeItem(REFRESH_TOKEN_KEY); + localStorage.removeItem(USER_KEY); + } +}; + +export const refreshToken = async (): Promise => { + 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); +}; diff --git a/frontend/src/store/AuthContext.tsx b/frontend/src/store/AuthContext.tsx new file mode 100644 index 0000000..a6d8ff4 --- /dev/null +++ b/frontend/src/store/AuthContext.tsx @@ -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; + logout: () => Promise; +} + +export const AuthContext = createContext(null); + +interface AuthProviderProps { + children: ReactNode; +} + +const AuthProvider = ({ children }: AuthProviderProps) => { + const [state, setState] = useState({ + 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 ( + + {children} + + ); +}; + +export default AuthProvider; diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts new file mode 100644 index 0000000..a12cbed --- /dev/null +++ b/frontend/src/types/api.ts @@ -0,0 +1,5 @@ +export interface ApiResponse { + success: boolean; + message?: string; + data?: T; +} diff --git a/frontend/src/types/auth.ts b/frontend/src/types/auth.ts new file mode 100644 index 0000000..009136b --- /dev/null +++ b/frontend/src/types/auth.ts @@ -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; +} diff --git a/pom.xml b/pom.xml index c9d2cc6..82c2ed2 100644 --- a/pom.xml +++ b/pom.xml @@ -104,6 +104,25 @@ 2.3.0 + + + io.jsonwebtoken + jjwt-api + 0.12.6 + + + io.jsonwebtoken + jjwt-impl + 0.12.6 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.12.6 + runtime + + org.springframework.boot diff --git a/src/main/java/com/gcsc/connection/apikey/entity/ApiKeyStatus.java b/src/main/java/com/gcsc/connection/apikey/entity/ApiKeyStatus.java new file mode 100644 index 0000000..3f52999 --- /dev/null +++ b/src/main/java/com/gcsc/connection/apikey/entity/ApiKeyStatus.java @@ -0,0 +1,9 @@ +package com.gcsc.connection.apikey.entity; + +public enum ApiKeyStatus { + PENDING, + ACTIVE, + INACTIVE, + EXPIRED, + REVOKED +} diff --git a/src/main/java/com/gcsc/connection/apikey/entity/KeyRequestStatus.java b/src/main/java/com/gcsc/connection/apikey/entity/KeyRequestStatus.java new file mode 100644 index 0000000..a7aaa3b --- /dev/null +++ b/src/main/java/com/gcsc/connection/apikey/entity/KeyRequestStatus.java @@ -0,0 +1,7 @@ +package com.gcsc.connection.apikey.entity; + +public enum KeyRequestStatus { + PENDING, + APPROVED, + REJECTED +} diff --git a/src/main/java/com/gcsc/connection/apikey/entity/SnpApiKey.java b/src/main/java/com/gcsc/connection/apikey/entity/SnpApiKey.java new file mode 100644 index 0000000..b69e000 --- /dev/null +++ b/src/main/java/com/gcsc/connection/apikey/entity/SnpApiKey.java @@ -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; + } +} diff --git a/src/main/java/com/gcsc/connection/apikey/entity/SnpApiKeyRequest.java b/src/main/java/com/gcsc/connection/apikey/entity/SnpApiKeyRequest.java new file mode 100644 index 0000000..30eac98 --- /dev/null +++ b/src/main/java/com/gcsc/connection/apikey/entity/SnpApiKeyRequest.java @@ -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; + } +} diff --git a/src/main/java/com/gcsc/connection/apikey/entity/SnpApiPermission.java b/src/main/java/com/gcsc/connection/apikey/entity/SnpApiPermission.java new file mode 100644 index 0000000..da04e7a --- /dev/null +++ b/src/main/java/com/gcsc/connection/apikey/entity/SnpApiPermission.java @@ -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(); + } +} diff --git a/src/main/java/com/gcsc/connection/apikey/repository/SnpApiKeyRepository.java b/src/main/java/com/gcsc/connection/apikey/repository/SnpApiKeyRepository.java new file mode 100644 index 0000000..cb6e5dd --- /dev/null +++ b/src/main/java/com/gcsc/connection/apikey/repository/SnpApiKeyRepository.java @@ -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 { + + Optional findByApiKey(String apiKey); +} diff --git a/src/main/java/com/gcsc/connection/apikey/repository/SnpApiKeyRequestRepository.java b/src/main/java/com/gcsc/connection/apikey/repository/SnpApiKeyRequestRepository.java new file mode 100644 index 0000000..65f9777 --- /dev/null +++ b/src/main/java/com/gcsc/connection/apikey/repository/SnpApiKeyRequestRepository.java @@ -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 { +} diff --git a/src/main/java/com/gcsc/connection/apikey/repository/SnpApiPermissionRepository.java b/src/main/java/com/gcsc/connection/apikey/repository/SnpApiPermissionRepository.java new file mode 100644 index 0000000..338cf43 --- /dev/null +++ b/src/main/java/com/gcsc/connection/apikey/repository/SnpApiPermissionRepository.java @@ -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 { +} diff --git a/src/main/java/com/gcsc/connection/auth/controller/AuthController.java b/src/main/java/com/gcsc/connection/auth/controller/AuthController.java new file mode 100644 index 0000000..e143f5d --- /dev/null +++ b/src/main/java/com/gcsc/connection/auth/controller/AuthController.java @@ -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> login( + @RequestBody @Valid LoginRequest request) { + LoginResponse response = authService.login(request); + return ResponseEntity.ok(ApiResponse.ok(response)); + } + + /** + * 로그아웃 + */ + @PostMapping("/logout") + public ResponseEntity> logout() { + return ResponseEntity.ok(ApiResponse.ok(null, "로그아웃되었습니다")); + } + + /** + * 토큰 갱신 - Refresh Token으로 새 Access Token 발급 + */ + @PostMapping("/refresh") + public ResponseEntity> refresh( + @RequestBody @Valid TokenRefreshRequest request) { + TokenRefreshResponse response = authService.refresh(request); + return ResponseEntity.ok(ApiResponse.ok(response)); + } +} diff --git a/src/main/java/com/gcsc/connection/auth/dto/LoginRequest.java b/src/main/java/com/gcsc/connection/auth/dto/LoginRequest.java new file mode 100644 index 0000000..d936ad2 --- /dev/null +++ b/src/main/java/com/gcsc/connection/auth/dto/LoginRequest.java @@ -0,0 +1,9 @@ +package com.gcsc.connection.auth.dto; + +import jakarta.validation.constraints.NotBlank; + +public record LoginRequest( + @NotBlank String loginId, + @NotBlank String password +) { +} diff --git a/src/main/java/com/gcsc/connection/auth/dto/LoginResponse.java b/src/main/java/com/gcsc/connection/auth/dto/LoginResponse.java new file mode 100644 index 0000000..34ac606 --- /dev/null +++ b/src/main/java/com/gcsc/connection/auth/dto/LoginResponse.java @@ -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 +) { +} diff --git a/src/main/java/com/gcsc/connection/auth/dto/TokenRefreshRequest.java b/src/main/java/com/gcsc/connection/auth/dto/TokenRefreshRequest.java new file mode 100644 index 0000000..af9798e --- /dev/null +++ b/src/main/java/com/gcsc/connection/auth/dto/TokenRefreshRequest.java @@ -0,0 +1,8 @@ +package com.gcsc.connection.auth.dto; + +import jakarta.validation.constraints.NotBlank; + +public record TokenRefreshRequest( + @NotBlank String refreshToken +) { +} diff --git a/src/main/java/com/gcsc/connection/auth/dto/TokenRefreshResponse.java b/src/main/java/com/gcsc/connection/auth/dto/TokenRefreshResponse.java new file mode 100644 index 0000000..284a8c6 --- /dev/null +++ b/src/main/java/com/gcsc/connection/auth/dto/TokenRefreshResponse.java @@ -0,0 +1,7 @@ +package com.gcsc.connection.auth.dto; + +public record TokenRefreshResponse( + String accessToken, + long expiresIn +) { +} diff --git a/src/main/java/com/gcsc/connection/auth/jwt/JwtAuthenticationFilter.java b/src/main/java/com/gcsc/connection/auth/jwt/JwtAuthenticationFilter.java new file mode 100644 index 0000000..44f7bbb --- /dev/null +++ b/src/main/java/com/gcsc/connection/auth/jwt/JwtAuthenticationFilter.java @@ -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 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; + } +} diff --git a/src/main/java/com/gcsc/connection/auth/jwt/JwtTokenProvider.java b/src/main/java/com/gcsc/connection/auth/jwt/JwtTokenProvider.java new file mode 100644 index 0000000..6ac24a8 --- /dev/null +++ b/src/main/java/com/gcsc/connection/auth/jwt/JwtTokenProvider.java @@ -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; + } +} diff --git a/src/main/java/com/gcsc/connection/auth/service/AuthService.java b/src/main/java/com/gcsc/connection/auth/service/AuthService.java new file mode 100644 index 0000000..72cdaa8 --- /dev/null +++ b/src/main/java/com/gcsc/connection/auth/service/AuthService.java @@ -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()); + } +} diff --git a/src/main/java/com/gcsc/connection/common/dto/PageResponse.java b/src/main/java/com/gcsc/connection/common/dto/PageResponse.java new file mode 100644 index 0000000..fda8b4b --- /dev/null +++ b/src/main/java/com/gcsc/connection/common/dto/PageResponse.java @@ -0,0 +1,24 @@ +package com.gcsc.connection.common.dto; + +import org.springframework.data.domain.Page; + +import java.util.List; + +public record PageResponse( + List content, + int page, + int size, + long totalElements, + int totalPages +) { + + public static PageResponse from(Page page) { + return new PageResponse<>( + page.getContent(), + page.getNumber(), + page.getSize(), + page.getTotalElements(), + page.getTotalPages() + ); + } +} diff --git a/src/main/java/com/gcsc/connection/common/entity/BaseEntity.java b/src/main/java/com/gcsc/connection/common/entity/BaseEntity.java new file mode 100644 index 0000000..576faca --- /dev/null +++ b/src/main/java/com/gcsc/connection/common/entity/BaseEntity.java @@ -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(); + } +} diff --git a/src/main/java/com/gcsc/connection/common/exception/BusinessException.java b/src/main/java/com/gcsc/connection/common/exception/BusinessException.java new file mode 100644 index 0000000..4d1633a --- /dev/null +++ b/src/main/java/com/gcsc/connection/common/exception/BusinessException.java @@ -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; + } +} diff --git a/src/main/java/com/gcsc/connection/common/exception/ErrorCode.java b/src/main/java/com/gcsc/connection/common/exception/ErrorCode.java new file mode 100644 index 0000000..850110c --- /dev/null +++ b/src/main/java/com/gcsc/connection/common/exception/ErrorCode.java @@ -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; +} diff --git a/src/main/java/com/gcsc/connection/common/exception/GlobalExceptionHandler.java b/src/main/java/com/gcsc/connection/common/exception/GlobalExceptionHandler.java index ab87f26..bbbee81 100644 --- a/src/main/java/com/gcsc/connection/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/gcsc/connection/common/exception/GlobalExceptionHandler.java @@ -4,13 +4,46 @@ import com.gcsc.connection.common.dto.ApiResponse; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import java.util.stream.Collectors; + @Slf4j @RestControllerAdvice public class GlobalExceptionHandler { + /** + * 비즈니스 예외 처리 + */ + @ExceptionHandler(BusinessException.class) + public ResponseEntity> 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> 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) public ResponseEntity> handleException(Exception e) { log.error("Unhandled exception: {}", e.getMessage(), e); diff --git a/src/main/java/com/gcsc/connection/config/SecurityConfig.java b/src/main/java/com/gcsc/connection/config/SecurityConfig.java index 44f84ed..e86f6ab 100644 --- a/src/main/java/com/gcsc/connection/config/SecurityConfig.java +++ b/src/main/java/com/gcsc/connection/config/SecurityConfig.java @@ -1,25 +1,47 @@ 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.Configuration; 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.configurers.AbstractHttpConfigurer; 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.authentication.UsernamePasswordAuthenticationFilter; @Configuration @EnableWebSecurity +@RequiredArgsConstructor public class SecurityConfig { + private final JwtAuthenticationFilter jwtAuthenticationFilter; + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(AbstractHttpConfigurer::disable) .headers(headers -> headers .frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin)) + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .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(); } diff --git a/src/main/java/com/gcsc/connection/monitoring/entity/SnpApiRequestLog.java b/src/main/java/com/gcsc/connection/monitoring/entity/SnpApiRequestLog.java new file mode 100644 index 0000000..b50218b --- /dev/null +++ b/src/main/java/com/gcsc/connection/monitoring/entity/SnpApiRequestLog.java @@ -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; + } +} diff --git a/src/main/java/com/gcsc/connection/monitoring/entity/SnpServiceHealthLog.java b/src/main/java/com/gcsc/connection/monitoring/entity/SnpServiceHealthLog.java new file mode 100644 index 0000000..887770e --- /dev/null +++ b/src/main/java/com/gcsc/connection/monitoring/entity/SnpServiceHealthLog.java @@ -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(); + } +} diff --git a/src/main/java/com/gcsc/connection/monitoring/repository/SnpApiRequestLogRepository.java b/src/main/java/com/gcsc/connection/monitoring/repository/SnpApiRequestLogRepository.java new file mode 100644 index 0000000..8edf50a --- /dev/null +++ b/src/main/java/com/gcsc/connection/monitoring/repository/SnpApiRequestLogRepository.java @@ -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 { +} diff --git a/src/main/java/com/gcsc/connection/monitoring/repository/SnpServiceHealthLogRepository.java b/src/main/java/com/gcsc/connection/monitoring/repository/SnpServiceHealthLogRepository.java new file mode 100644 index 0000000..90925e3 --- /dev/null +++ b/src/main/java/com/gcsc/connection/monitoring/repository/SnpServiceHealthLogRepository.java @@ -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 { +} diff --git a/src/main/java/com/gcsc/connection/service/entity/ServiceStatus.java b/src/main/java/com/gcsc/connection/service/entity/ServiceStatus.java new file mode 100644 index 0000000..dfc04ca --- /dev/null +++ b/src/main/java/com/gcsc/connection/service/entity/ServiceStatus.java @@ -0,0 +1,7 @@ +package com.gcsc.connection.service.entity; + +public enum ServiceStatus { + UP, + DOWN, + UNKNOWN +} diff --git a/src/main/java/com/gcsc/connection/service/entity/SnpService.java b/src/main/java/com/gcsc/connection/service/entity/SnpService.java new file mode 100644 index 0000000..6d10b04 --- /dev/null +++ b/src/main/java/com/gcsc/connection/service/entity/SnpService.java @@ -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; + } +} diff --git a/src/main/java/com/gcsc/connection/service/entity/SnpServiceApi.java b/src/main/java/com/gcsc/connection/service/entity/SnpServiceApi.java new file mode 100644 index 0000000..83d9c79 --- /dev/null +++ b/src/main/java/com/gcsc/connection/service/entity/SnpServiceApi.java @@ -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; + } +} diff --git a/src/main/java/com/gcsc/connection/service/repository/SnpServiceApiRepository.java b/src/main/java/com/gcsc/connection/service/repository/SnpServiceApiRepository.java new file mode 100644 index 0000000..15c09ce --- /dev/null +++ b/src/main/java/com/gcsc/connection/service/repository/SnpServiceApiRepository.java @@ -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 { + + List findByService(SnpService service); +} diff --git a/src/main/java/com/gcsc/connection/service/repository/SnpServiceRepository.java b/src/main/java/com/gcsc/connection/service/repository/SnpServiceRepository.java new file mode 100644 index 0000000..e0fbce9 --- /dev/null +++ b/src/main/java/com/gcsc/connection/service/repository/SnpServiceRepository.java @@ -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 { + + Optional findByServiceCode(String serviceCode); +} diff --git a/src/main/java/com/gcsc/connection/tenant/entity/SnpTenant.java b/src/main/java/com/gcsc/connection/tenant/entity/SnpTenant.java new file mode 100644 index 0000000..0ce680e --- /dev/null +++ b/src/main/java/com/gcsc/connection/tenant/entity/SnpTenant.java @@ -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; + } +} diff --git a/src/main/java/com/gcsc/connection/tenant/repository/SnpTenantRepository.java b/src/main/java/com/gcsc/connection/tenant/repository/SnpTenantRepository.java new file mode 100644 index 0000000..5b47e07 --- /dev/null +++ b/src/main/java/com/gcsc/connection/tenant/repository/SnpTenantRepository.java @@ -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 { + + Optional findByTenantCode(String tenantCode); +} diff --git a/src/main/java/com/gcsc/connection/user/entity/SnpUser.java b/src/main/java/com/gcsc/connection/user/entity/SnpUser.java new file mode 100644 index 0000000..69220e7 --- /dev/null +++ b/src/main/java/com/gcsc/connection/user/entity/SnpUser.java @@ -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(); + } +} diff --git a/src/main/java/com/gcsc/connection/user/entity/UserRole.java b/src/main/java/com/gcsc/connection/user/entity/UserRole.java new file mode 100644 index 0000000..9fc9a9b --- /dev/null +++ b/src/main/java/com/gcsc/connection/user/entity/UserRole.java @@ -0,0 +1,8 @@ +package com.gcsc.connection.user.entity; + +public enum UserRole { + ADMIN, + MANAGER, + USER, + VIEWER +} diff --git a/src/main/java/com/gcsc/connection/user/repository/SnpUserRepository.java b/src/main/java/com/gcsc/connection/user/repository/SnpUserRepository.java new file mode 100644 index 0000000..6802372 --- /dev/null +++ b/src/main/java/com/gcsc/connection/user/repository/SnpUserRepository.java @@ -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 { + + Optional findByLoginId(String loginId); + + boolean existsByLoginId(String loginId); +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index dec39a7..ae1617b 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -4,8 +4,8 @@ spring: # PostgreSQL Database Configuration datasource: - url: jdbc:postgresql://211.208.115.83:5432/snpdb - username: snp + url: jdbc:postgresql://211.208.115.83:5432/snp_connection + username: snp_admin password: snp#8932 driver-class-name: org.postgresql.Driver hikari: @@ -16,13 +16,13 @@ spring: # JPA Configuration jpa: hibernate: - ddl-auto: validate + ddl-auto: update show-sql: false properties: hibernate: dialect: org.hibernate.dialect.PostgreSQLDialect format_sql: true - default_schema: std_snp_connection + default_schema: common # Server Configuration server: @@ -58,3 +58,7 @@ app: heartbeat: default-interval-seconds: 30 timeout-seconds: 5 + jwt: + secret: c25wLWNvbm5lY3Rpb24tbW9uaXRvcmluZy1qd3Qtc2VjcmV0LWtleS0yMDI2 + access-token-expiration: 3600000 + refresh-token-expiration: 604800000