- 37개 파일 IP → zioinfo.co.kr 치환 (소스/매뉴얼/설정/하네스) - Manager DrConsole/NetworkConsole/CsapConsole 빌드 + /var/www/manager/ 배포 - 테스트: Manager HTTP 200, ITSM 신규 API 7개 전체 200 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
197 lines
6.6 KiB
Markdown
197 lines
6.6 KiB
Markdown
---
|
|
name: manager-security
|
|
description: >
|
|
GUARDiA Manager의 인증, 권한 제어, 보안 설정을 구현하는 스킬.
|
|
GUARDiA ITSM JWT 재활용, React Route Guard, API Key 관리 UI,
|
|
감사 로그 시각화를 포함한다.
|
|
트리거: 인증 구현, 로그인 페이지, Route Guard, JWT 검증,
|
|
API Key 발급/관리, 감사 로그 화면, 보안 설정 요청 시.
|
|
---
|
|
|
|
# GUARDiA Manager 보안 구현 스킬
|
|
|
|
## 인증 아키텍처
|
|
|
|
GUARDiA Manager는 별도 인증 서버 없이 **GUARDiA ITSM JWT를 공유**한다.
|
|
|
|
```
|
|
1. 사용자 → Manager 로그인 페이지
|
|
2. POST /api/auth/login (GUARDiA ITSM 8001)
|
|
3. JWT 수신 → sessionStorage 저장 (localStorage 금지)
|
|
4. 이후 모든 요청: Authorization: Bearer {token}
|
|
5. GUARDiA ITSM과 Manager Backend 양쪽에서 동일 JWT 검증
|
|
```
|
|
|
|
## useAuth 훅
|
|
|
|
```typescript
|
|
// frontend/src/hooks/useAuth.ts
|
|
import { useState, useEffect, createContext, useContext } from 'react';
|
|
import { guardiaApi } from '../api/guardiaClient';
|
|
|
|
interface AuthState {
|
|
user: { username: string; role: string; display_name: string } | null;
|
|
token: string | null;
|
|
loading: boolean;
|
|
}
|
|
|
|
export function useAuth() {
|
|
const [state, setState] = useState<AuthState>({
|
|
user: null, token: sessionStorage.getItem('guardia_token'), loading: true
|
|
});
|
|
|
|
useEffect(() => {
|
|
const token = sessionStorage.getItem('guardia_token');
|
|
if (!token) { setState(s => ({ ...s, loading: false })); return; }
|
|
guardiaApi.get('/api/auth/me')
|
|
.then(res => setState({ user: res.data, token, loading: false }))
|
|
.catch(() => {
|
|
sessionStorage.removeItem('guardia_token');
|
|
setState({ user: null, token: null, loading: false });
|
|
});
|
|
}, []);
|
|
|
|
const login = async (username: string, password: string) => {
|
|
const res = await guardiaApi.post('/api/auth/login',
|
|
new URLSearchParams({ username, password }),
|
|
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
|
|
);
|
|
const { access_token, user } = res.data;
|
|
sessionStorage.setItem('guardia_token', access_token);
|
|
setState({ user, token: access_token, loading: false });
|
|
return user;
|
|
};
|
|
|
|
const logout = () => {
|
|
sessionStorage.removeItem('guardia_token');
|
|
setState({ user: null, token: null, loading: false });
|
|
window.location.href = '/login';
|
|
};
|
|
|
|
return { ...state, login, logout };
|
|
}
|
|
```
|
|
|
|
## Route Guard 컴포넌트
|
|
|
|
```tsx
|
|
// components/common/ProtectedRoute.tsx
|
|
import { Navigate, useLocation } from 'react-router-dom';
|
|
import { useAuth } from '../../hooks/useAuth';
|
|
|
|
const ADMIN_ONLY_PATHS = [
|
|
'/api-keys', '/config', '/config/env', '/config/nginx'
|
|
];
|
|
|
|
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
|
const { user, token, loading } = useAuth();
|
|
const location = useLocation();
|
|
|
|
if (loading) return <div style={{ padding: 40 }}>인증 확인 중...</div>;
|
|
if (!token || !user) return <Navigate to="/login" state={{ from: location }} replace />;
|
|
|
|
// admin 전용 페이지 접근 제어
|
|
if (ADMIN_ONLY_PATHS.some(p => location.pathname.startsWith(p))
|
|
&& user.role !== 'admin') {
|
|
return <div style={{ padding: 40, color: '#ef4444' }}>
|
|
관리자 권한이 필요합니다.
|
|
</div>;
|
|
}
|
|
|
|
return <>{children}</>;
|
|
}
|
|
```
|
|
|
|
## API Key 관리 화면 (M-05)
|
|
|
|
NCloud 콘솔의 "API 인증키 관리" 화면을 참조:
|
|
- 테이블: 키 이름, 스코프, 마지막 사용, 만료일, 상태
|
|
- 발급: 슬라이드 패널 → 이름/스코프/IP 제한 입력 → 발급 시 키 1회 표시
|
|
- 비활성화: 확인 모달 → `DELETE /api/external/keys/{id}`
|
|
|
|
```tsx
|
|
// pages/ApiKeys.tsx 핵심 구조
|
|
export function ApiKeys() {
|
|
const { data, loading, refetch } = useGuardiaApi<APIKey[]>('/api/external/keys');
|
|
const [creating, setCreating] = useState(false);
|
|
const [newKey, setNewKey] = useState<string | null>(null);
|
|
|
|
const handleCreate = async (form: { name: string; scopes: string; expires_days: number }) => {
|
|
const res = await guardiaApi.post('/api/external/keys', form);
|
|
setNewKey(res.data.api_key); // 1회만 노출
|
|
refetch();
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{/* 발급된 키 1회 노출 모달 */}
|
|
{newKey && (
|
|
<div className="one-time-key-modal">
|
|
<h4>⚠️ API Key가 발급되었습니다 — 지금만 확인 가능합니다</h4>
|
|
<code style={{ userSelect: 'all', background: '#f0f2f5', padding: '10px 14px',
|
|
borderRadius: 6, display: 'block', wordBreak: 'break-all' }}>{newKey}</code>
|
|
<button onClick={() => { navigator.clipboard.writeText(newKey); }}>복사</button>
|
|
<button onClick={() => setNewKey(null)}>확인 완료</button>
|
|
</div>
|
|
)}
|
|
<DataTable
|
|
columns={[
|
|
{ key: 'name', header: '키 이름' },
|
|
{ key: 'scopes', header: '권한' },
|
|
{ key: 'use_count', header: '사용 횟수' },
|
|
{ key: 'is_active', header: '상태',
|
|
render: r => <StatusBadge status={r.is_active ? 'running' : 'stopped'} /> },
|
|
{ key: 'expires_at', header: '만료일' },
|
|
]}
|
|
data={data ?? []}
|
|
loading={loading}
|
|
actions={<button onClick={() => setCreating(true)}>+ API Key 발급</button>}
|
|
selectable
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
```
|
|
|
|
## 감사 로그 화면 (M-05)
|
|
|
|
```tsx
|
|
// pages/AuditLog.tsx
|
|
// - 타임라인 형태로 감사 이벤트 표시 (NCloud "활동 로그" 참조)
|
|
// - 필터: 날짜 범위, 사용자, 액션 유형
|
|
// - 데이터: GET /api/audit?page=0&size=50
|
|
// - IP Hash 표시 (원본 IP는 ITSM 보안 정책상 제공 안 함)
|
|
```
|
|
|
|
## Manager Backend JWT 검증 (core/auth.py)
|
|
|
|
```python
|
|
# backend/core/auth.py
|
|
import os
|
|
from fastapi import Depends, HTTPException
|
|
from fastapi.security import OAuth2PasswordBearer
|
|
from jose import JWTError, jwt
|
|
|
|
SECRET = os.environ.get("GUARDIA_JWT_SECRET", "guardia-jwt-secret-2026-change-me!")
|
|
ALGORITHM = "HS256"
|
|
oauth2 = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
|
|
|
|
async def verify_guardia_token(token: str = Depends(oauth2)):
|
|
if not token:
|
|
raise HTTPException(status_code=401, detail="인증이 필요합니다.")
|
|
try:
|
|
payload = jwt.decode(token, SECRET, algorithms=[ALGORITHM])
|
|
return payload
|
|
except JWTError:
|
|
raise HTTPException(status_code=401, detail="유효하지 않은 토큰입니다.")
|
|
```
|
|
|
|
## 보안 체크리스트
|
|
|
|
- [ ] localStorage에 토큰 저장 금지 (sessionStorage 사용)
|
|
- [ ] API Key, 비밀번호 화면 표시 마스킹 (`****`)
|
|
- [ ] 클립보드 복사 후 즉시 메모리 해제
|
|
- [ ] admin 전용 라우트 보호 완료
|
|
- [ ] CORS 출처 명시적 설정 (wildcard 금지)
|
|
- [ ] 모든 시스템 제어 API에 admin 역할 검증
|