feat: AI 서버 관리 페이지 Phase 3 보안 강화 - JWT 인증 시스템

🔐 JWT 기반 로그인 시스템:
- 로그인 페이지: 아름다운 애니메이션과 보안 정보 표시
- JWT 토큰: 24시간 또는 30일 (Remember Me) 만료 설정
- 비밀번호 암호화: bcrypt 해싱으로 안전한 저장
- 계정 잠금: 5회 실패 시 15분 자동 잠금

👥 사용자 계정 관리:
- admin/admin123 (관리자 권한)
- hyungi/hyungi123 (시스템 권한)
- 역할 기반 접근 제어 (RBAC)

🛡️ 보안 기능:
- 토큰 자동 검증 및 만료 처리
- 감사 로그: 로그인/로그아웃/관리 작업 추적
- 안전한 세션 관리 및 토큰 정리
- 클라이언트 사이드 토큰 검증

🎨 UI/UX 개선:
- 로그인 페이지: 그라디언트 배경, 플로팅 아이콘 애니메이션
- 사용자 메뉴: 헤더에 사용자명과 로그아웃 버튼 표시
- 보안 표시: SSL, 세션 타임아웃, JWT 인증 정보
- 반응형 디자인 및 다크모드 지원

🔧 기술 구현:
- FastAPI HTTPBearer 보안 스키마
- PyJWT 토큰 생성/검증
- bcrypt 비밀번호 해싱
- 클라이언트-서버 토큰 동기화

새 파일:
- templates/login.html: 로그인 페이지 HTML
- static/login.css: 로그인 페이지 스타일
- static/login.js: 로그인 JavaScript 로직
- server/auth.py: JWT 인증 시스템 (실제 서버용)

수정된 파일:
- test_admin.py: 테스트 서버에 JWT 인증 추가
- static/admin.js: JWT 토큰 기반 API 요청으로 변경
- templates/admin.html: 사용자 메뉴 및 로그아웃 버튼 추가
- static/admin.css: 사용자 메뉴 스타일 추가

보안 레벨: Phase 1 (API Key) → Phase 3 (JWT + 감사로그)
This commit is contained in:
Hyungi Ahn
2025-08-18 15:24:01 +09:00
parent b752e56b94
commit 1e098999c1
9 changed files with 1352 additions and 18 deletions

229
server/auth.py Normal file
View File

@@ -0,0 +1,229 @@
"""
JWT Authentication System for AI Server Admin
Phase 3: Security Enhancement
"""
import jwt
import bcrypt
import secrets
from datetime import datetime, timedelta
from typing import Optional, Dict, Any
from fastapi import HTTPException, Depends, Request
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
import os
# JWT Configuration
JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY", secrets.token_urlsafe(32))
JWT_ALGORITHM = "HS256"
JWT_EXPIRATION_HOURS = 24
JWT_REMEMBER_DAYS = 30
# Security
security = HTTPBearer()
# In-memory user store (in production, use a proper database)
USERS_DB = {
"admin": {
"username": "admin",
"password_hash": bcrypt.hashpw("admin123".encode('utf-8'), bcrypt.gensalt()).decode('utf-8'),
"role": "admin",
"created_at": datetime.now().isoformat(),
"last_login": None,
"login_attempts": 0,
"locked_until": None
},
"hyungi": {
"username": "hyungi",
"password_hash": bcrypt.hashpw("hyungi123".encode('utf-8'), bcrypt.gensalt()).decode('utf-8'),
"role": "system",
"created_at": datetime.now().isoformat(),
"last_login": None,
"login_attempts": 0,
"locked_until": None
}
}
# Login attempt tracking
LOGIN_ATTEMPTS = {}
MAX_LOGIN_ATTEMPTS = 5
LOCKOUT_DURATION_MINUTES = 15
class AuthManager:
@staticmethod
def hash_password(password: str) -> str:
"""Hash password using bcrypt"""
return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
@staticmethod
def verify_password(password: str, password_hash: str) -> bool:
"""Verify password against hash"""
return bcrypt.checkpw(password.encode('utf-8'), password_hash.encode('utf-8'))
@staticmethod
def create_jwt_token(user_data: Dict[str, Any], remember_me: bool = False) -> str:
"""Create JWT token"""
expiration = datetime.utcnow() + timedelta(
days=JWT_REMEMBER_DAYS if remember_me else 0,
hours=JWT_EXPIRATION_HOURS if not remember_me else 0
)
payload = {
"username": user_data["username"],
"role": user_data["role"],
"exp": expiration,
"iat": datetime.utcnow(),
"type": "remember" if remember_me else "session"
}
return jwt.encode(payload, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM)
@staticmethod
def verify_jwt_token(token: str) -> Dict[str, Any]:
"""Verify and decode JWT token"""
try:
payload = jwt.decode(token, JWT_SECRET_KEY, algorithms=[JWT_ALGORITHM])
return payload
except jwt.ExpiredSignatureError:
raise HTTPException(status_code=401, detail="Token has expired")
except jwt.InvalidTokenError:
raise HTTPException(status_code=401, detail="Invalid token")
@staticmethod
def is_account_locked(username: str) -> bool:
"""Check if account is locked due to failed attempts"""
user = USERS_DB.get(username)
if not user:
return False
if user["locked_until"]:
locked_until = datetime.fromisoformat(user["locked_until"])
if datetime.now() < locked_until:
return True
else:
# Unlock account
user["locked_until"] = None
user["login_attempts"] = 0
return False
@staticmethod
def record_login_attempt(username: str, success: bool, ip_address: str = None):
"""Record login attempt"""
user = USERS_DB.get(username)
if not user:
return
if success:
user["login_attempts"] = 0
user["locked_until"] = None
user["last_login"] = datetime.now().isoformat()
else:
user["login_attempts"] += 1
# Lock account after max attempts
if user["login_attempts"] >= MAX_LOGIN_ATTEMPTS:
user["locked_until"] = (
datetime.now() + timedelta(minutes=LOCKOUT_DURATION_MINUTES)
).isoformat()
@staticmethod
def authenticate_user(username: str, password: str) -> Optional[Dict[str, Any]]:
"""Authenticate user credentials"""
user = USERS_DB.get(username)
if not user:
return None
if AuthManager.is_account_locked(username):
raise HTTPException(
status_code=423,
detail=f"Account locked due to too many failed attempts. Try again later."
)
if AuthManager.verify_password(password, user["password_hash"]):
return user
return None
# Dependency functions
async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)):
"""Get current authenticated user from JWT token"""
try:
payload = AuthManager.verify_jwt_token(credentials.credentials)
username = payload.get("username")
user = USERS_DB.get(username)
if not user:
raise HTTPException(status_code=401, detail="User not found")
return {
"username": user["username"],
"role": user["role"],
"token_type": payload.get("type", "session")
}
except Exception as e:
raise HTTPException(status_code=401, detail="Invalid authentication credentials")
async def require_admin_role(current_user: dict = Depends(get_current_user)):
"""Require admin or system role"""
if current_user["role"] not in ["admin", "system"]:
raise HTTPException(status_code=403, detail="Admin privileges required")
return current_user
async def require_system_role(current_user: dict = Depends(get_current_user)):
"""Require system role"""
if current_user["role"] != "system":
raise HTTPException(status_code=403, detail="System privileges required")
return current_user
# Legacy API key support (for backward compatibility)
async def get_current_user_or_api_key(
request: Request,
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
x_api_key: Optional[str] = None
):
"""Support both JWT and API key authentication"""
# Try JWT first
if credentials:
try:
return await get_current_user(credentials)
except HTTPException:
pass
# Fall back to API key
api_key = x_api_key or request.headers.get("X-API-Key")
if api_key and api_key == os.getenv("API_KEY", "test-admin-key-123"):
return {
"username": "api_user",
"role": "system",
"token_type": "api_key"
}
raise HTTPException(status_code=401, detail="Authentication required")
# Audit logging
class AuditLogger:
@staticmethod
def log_login(username: str, success: bool, ip_address: str = None, user_agent: str = None):
"""Log login attempt"""
log_entry = {
"timestamp": datetime.now().isoformat(),
"event": "login_attempt",
"username": username,
"success": success,
"ip_address": ip_address,
"user_agent": user_agent
}
print(f"AUDIT: {log_entry}") # In production, use proper logging
@staticmethod
def log_admin_action(username: str, action: str, details: str = None, ip_address: str = None):
"""Log admin action"""
log_entry = {
"timestamp": datetime.now().isoformat(),
"event": "admin_action",
"username": username,
"action": action,
"details": details,
"ip_address": ip_address
}
print(f"AUDIT: {log_entry}") # In production, use proper logging

View File

@@ -746,3 +746,96 @@ async def admin_get_system_stats(api_key: str = Depends(require_api_key)):
"timestamp": datetime.now().isoformat()
}
# Phase 3: JWT Authentication System
from .auth import AuthManager, AuditLogger, get_current_user, require_admin_role, require_system_role
@app.get("/login", response_class=HTMLResponse)
async def login_page(request: Request):
"""로그인 페이지"""
return templates.TemplateResponse("login.html", {"request": request})
@app.post("/admin/login")
async def admin_login(request: Request):
"""JWT 기반 로그인"""
try:
data = await request.json()
username = data.get("username", "").strip()
password = data.get("password", "")
remember_me = data.get("remember_me", False)
if not username or not password:
return {"success": False, "message": "Username and password are required"}
# Get client IP
client_ip = request.client.host
user_agent = request.headers.get("user-agent", "")
try:
# Authenticate user
user = AuthManager.authenticate_user(username, password)
if user:
# Create JWT token
token = AuthManager.create_jwt_token(user, remember_me)
# Record successful login
AuthManager.record_login_attempt(username, True, client_ip)
AuditLogger.log_login(username, True, client_ip, user_agent)
return {
"success": True,
"message": "Login successful",
"token": token,
"user": {
"username": user["username"],
"role": user["role"]
}
}
else:
# Record failed login
AuthManager.record_login_attempt(username, False, client_ip)
AuditLogger.log_login(username, False, client_ip, user_agent)
return {"success": False, "message": "Invalid username or password"}
except HTTPException as e:
# Account locked
AuditLogger.log_login(username, False, client_ip, user_agent)
return {"success": False, "message": e.detail}
except Exception as e:
return {"success": False, "message": "Login error occurred"}
@app.get("/admin/verify-token")
async def verify_token(current_user: dict = Depends(get_current_user)):
"""JWT 토큰 검증"""
return {
"valid": True,
"user": current_user
}
@app.post("/admin/logout")
async def admin_logout(request: Request, current_user: dict = Depends(get_current_user)):
"""로그아웃 (클라이언트에서 토큰 삭제)"""
client_ip = request.client.host
AuditLogger.log_admin_action(
current_user["username"],
"logout",
"User logged out",
client_ip
)
return {"success": True, "message": "Logged out successfully"}
# Update existing admin routes to use JWT authentication
@app.get("/admin", response_class=HTMLResponse)
async def admin_dashboard(request: Request, current_user: dict = Depends(require_admin_role)):
"""관리자 대시보드 페이지 (JWT 인증 필요)"""
return templates.TemplateResponse("admin.html", {
"request": request,
"server_port": settings.ai_server_port,
"ollama_host": settings.ollama_host,
"current_user": current_user
})

View File

@@ -74,6 +74,39 @@ body {
opacity: 0.9;
}
.user-menu {
display: flex;
align-items: center;
gap: 1rem;
}
.user-info {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
opacity: 0.9;
}
.logout-btn {
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
padding: 0.4rem 0.8rem;
border-radius: 6px;
font-size: 0.8rem;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 0.3rem;
}
.logout-btn:hover {
background: rgba(255, 255, 255, 0.3);
border-color: rgba(255, 255, 255, 0.5);
}
/* Main Content */
.admin-main {
flex: 1;

View File

@@ -8,25 +8,40 @@ class AdminDashboard {
}
getApiKey() {
// 테스트 모드에서는 기본 API 키 사용
let apiKey = localStorage.getItem('ai_admin_api_key');
if (!apiKey) {
// 테스트 모드 기본 키
apiKey = 'test-admin-key-123';
localStorage.setItem('ai_admin_api_key', apiKey);
// 사용자에게 알림
setTimeout(() => {
alert('테스트 모드입니다.\nAPI Key: test-admin-key-123');
}, 1000);
// JWT 토큰 사용
const token = localStorage.getItem('ai_admin_token');
console.log('Getting token:', token ? token.substring(0, 20) + '...' : 'No token found');
if (!token) {
// 토큰이 없으면 로그인 페이지로 리다이렉트
console.log('No token, redirecting to login...');
window.location.href = '/login';
return null;
}
return apiKey;
return token;
}
async init() {
// 먼저 토큰 검증
if (!this.apiKey) {
return; // getApiKey()에서 이미 리다이렉트됨
}
// 토큰 유효성 검증
try {
await this.apiRequest('/admin/verify-token');
console.log('Token verification successful');
} catch (error) {
console.log('Token verification failed, redirecting to login');
localStorage.removeItem('ai_admin_token');
localStorage.removeItem('ai_admin_user');
window.location.href = '/login';
return;
}
this.updateCurrentTime();
setInterval(() => this.updateCurrentTime(), 1000);
await this.loadUserInfo(); // Phase 3: Load user info
await this.loadSystemStatus();
await this.loadModels();
await this.loadApiKeys();
@@ -40,6 +55,28 @@ class AdminDashboard {
}, 30000);
}
// Phase 3: User Management
async loadUserInfo() {
try {
const userInfo = localStorage.getItem('ai_admin_user');
if (userInfo) {
const user = JSON.parse(userInfo);
document.getElementById('username').textContent = user.username;
} else {
// Verify token and get user info
const response = await this.apiRequest('/admin/verify-token');
if (response.valid) {
document.getElementById('username').textContent = response.user.username;
localStorage.setItem('ai_admin_user', JSON.stringify(response.user));
}
}
} catch (error) {
console.error('Failed to load user info:', error);
// Token might be invalid, redirect to login
window.location.href = '/login';
}
}
updateCurrentTime() {
const now = new Date();
document.getElementById('current-time').textContent =
@@ -58,18 +95,27 @@ class AdminDashboard {
const defaultOptions = {
headers: {
'Content-Type': 'application/json',
'X-API-Key': this.apiKey
'Authorization': `Bearer ${this.apiKey}`
}
};
console.log('API Request:', endpoint, 'with token:', this.apiKey ? this.apiKey.substring(0, 20) + '...' : 'No token');
try {
const response = await fetch(url, { ...defaultOptions, ...options });
console.log('API Response:', response.status, response.statusText);
if (!response.ok) {
if (response.status === 401) {
localStorage.removeItem('ai_admin_api_key');
location.reload();
console.log('401 Unauthorized - clearing tokens and redirecting');
// JWT 토큰이 만료되었거나 유효하지 않음
localStorage.removeItem('ai_admin_token');
localStorage.removeItem('ai_admin_user');
window.location.href = '/login';
return;
}
const errorText = await response.text();
console.log('Error response:', errorText);
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
@@ -435,6 +481,23 @@ function closeModal(modalId) {
admin.closeModal(modalId);
}
// Phase 3: Logout function
async function logout() {
if (!confirm('Are you sure you want to logout?')) return;
try {
// Call logout API
await admin.apiRequest('/admin/logout', { method: 'POST' });
} catch (error) {
console.error('Logout API call failed:', error);
} finally {
// Clear local storage and redirect
localStorage.removeItem('ai_admin_token');
localStorage.removeItem('ai_admin_user');
window.location.href = '/login';
}
}
// Initialize dashboard when page loads
document.addEventListener('DOMContentLoaded', () => {
admin = new AdminDashboard();

390
static/login.css Normal file
View File

@@ -0,0 +1,390 @@
/* AI Server Admin Login Page CSS */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
position: relative;
}
/* Background Animation */
.bg-animation {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 0;
}
.floating-icon {
position: absolute;
font-size: 2rem;
color: rgba(255, 255, 255, 0.1);
animation: float 8s ease-in-out infinite;
animation-delay: var(--delay, 0s);
}
.floating-icon:nth-child(1) {
top: 20%;
left: 10%;
}
.floating-icon:nth-child(2) {
top: 60%;
right: 15%;
}
.floating-icon:nth-child(3) {
bottom: 30%;
left: 20%;
}
.floating-icon:nth-child(4) {
top: 40%;
right: 30%;
}
@keyframes float {
0%, 100% {
transform: translateY(0px) rotate(0deg);
opacity: 0.1;
}
50% {
transform: translateY(-20px) rotate(180deg);
opacity: 0.3;
}
}
/* Login Container */
.login-container {
position: relative;
z-index: 1;
width: 100%;
max-width: 400px;
padding: 2rem;
}
.login-card {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
border-radius: 20px;
padding: 2.5rem;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
animation: slideUp 0.6s ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(50px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Header */
.login-header {
text-align: center;
margin-bottom: 2rem;
}
.logo {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.logo i {
font-size: 2rem;
color: #667eea;
}
.logo h1 {
font-size: 1.8rem;
font-weight: 600;
color: #2c3e50;
}
.subtitle {
color: #7f8c8d;
font-size: 0.9rem;
font-weight: 500;
}
/* Form Styles */
.login-form {
margin-bottom: 1.5rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
font-weight: 500;
color: #2c3e50;
font-size: 0.9rem;
}
.form-group label i {
color: #667eea;
width: 16px;
}
.form-group input[type="text"],
.form-group input[type="password"] {
width: 100%;
padding: 0.75rem 1rem;
border: 2px solid #e1e8ed;
border-radius: 10px;
font-size: 1rem;
transition: all 0.3s ease;
background: white;
}
.form-group input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.password-input {
position: relative;
}
.password-toggle {
position: absolute;
right: 1rem;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: #7f8c8d;
cursor: pointer;
padding: 0;
font-size: 1rem;
transition: color 0.2s ease;
}
.password-toggle:hover {
color: #667eea;
}
/* Checkbox */
.checkbox-label {
display: flex !important;
align-items: center;
gap: 0.75rem;
cursor: pointer;
font-size: 0.9rem;
color: #7f8c8d;
}
.checkbox-label input[type="checkbox"] {
display: none;
}
.checkmark {
width: 18px;
height: 18px;
border: 2px solid #e1e8ed;
border-radius: 4px;
position: relative;
transition: all 0.3s ease;
}
.checkbox-label input:checked + .checkmark {
background: #667eea;
border-color: #667eea;
}
.checkbox-label input:checked + .checkmark::after {
content: '\f00c';
font-family: 'Font Awesome 6 Free';
font-weight: 900;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-size: 0.7rem;
}
/* Login Button */
.login-btn {
width: 100%;
padding: 0.875rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 10px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.login-btn:hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(102, 126, 234, 0.3);
}
.login-btn:active {
transform: translateY(0);
}
.login-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
/* Loading State */
.login-btn.loading {
pointer-events: none;
}
.login-btn.loading i {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Error Message */
.error-message {
background: #fadbd8;
border: 1px solid #f1948a;
border-radius: 8px;
padding: 0.75rem;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
color: #e74c3c;
font-size: 0.9rem;
animation: shake 0.5s ease-in-out;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-5px); }
75% { transform: translateX(5px); }
}
/* Security Info */
.security-info {
display: flex;
justify-content: space-between;
margin-bottom: 1.5rem;
padding: 1rem;
background: rgba(102, 126, 234, 0.05);
border-radius: 8px;
border: 1px solid rgba(102, 126, 234, 0.1);
}
.security-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
color: #7f8c8d;
text-align: center;
}
.security-item i {
color: #667eea;
font-size: 1rem;
}
/* Footer */
.login-footer {
text-align: center;
color: #95a5a6;
font-size: 0.8rem;
}
.version-info {
margin-top: 0.5rem;
font-size: 0.75rem;
color: #bdc3c7;
}
/* Responsive */
@media (max-width: 480px) {
.login-container {
padding: 1rem;
}
.login-card {
padding: 2rem;
}
.security-info {
flex-direction: column;
gap: 1rem;
}
.security-item {
flex-direction: row;
justify-content: center;
}
.floating-icon {
display: none;
}
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.login-card {
background: rgba(44, 62, 80, 0.95);
color: #ecf0f1;
}
.logo h1 {
color: #ecf0f1;
}
.form-group label {
color: #ecf0f1;
}
.form-group input {
background: rgba(52, 73, 94, 0.8);
border-color: #34495e;
color: #ecf0f1;
}
.form-group input::placeholder {
color: #95a5a6;
}
}

235
static/login.js Normal file
View File

@@ -0,0 +1,235 @@
// AI Server Admin Login JavaScript
class LoginManager {
constructor() {
this.baseUrl = window.location.origin;
this.init();
}
init() {
// Check if already logged in
this.checkExistingAuth();
// Setup form submission
document.getElementById('login-form').addEventListener('submit', (e) => {
e.preventDefault();
this.handleLogin();
});
// Setup enter key handling
document.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
this.handleLogin();
}
});
// Auto-focus username field
document.getElementById('username').focus();
}
async checkExistingAuth() {
const token = localStorage.getItem('ai_admin_token');
if (token) {
try {
console.log('Checking existing token...');
// Verify token is still valid
const response = await fetch(`${this.baseUrl}/admin/verify-token`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
console.log('Token is valid, redirecting to admin...');
// Token is valid, redirect to admin
window.location.href = '/admin';
return;
} else {
console.log('Token verification failed with status:', response.status);
}
} catch (error) {
console.log('Token verification failed:', error);
}
// Token is invalid, remove it
console.log('Removing invalid token...');
localStorage.removeItem('ai_admin_token');
localStorage.removeItem('ai_admin_user');
}
}
async handleLogin() {
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value;
const rememberMe = document.getElementById('remember-me').checked;
// Validation
if (!username || !password) {
this.showError('Please enter both username and password');
return;
}
// Show loading state
this.setLoading(true);
this.hideError();
try {
const response = await fetch(`${this.baseUrl}/admin/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
username,
password,
remember_me: rememberMe
})
});
const data = await response.json();
if (response.ok && data.success) {
// Store JWT token
localStorage.setItem('ai_admin_token', data.token);
// Store user info
localStorage.setItem('ai_admin_user', JSON.stringify(data.user));
console.log('Token stored:', data.token.substring(0, 20) + '...');
console.log('User stored:', data.user);
// Show success message
this.showSuccess('Login successful! Redirecting...');
// Redirect after short delay
setTimeout(() => {
window.location.href = '/admin';
}, 1000);
} else {
this.showError(data.message || 'Login failed. Please check your credentials.');
}
} catch (error) {
console.error('Login error:', error);
this.showError('Connection error. Please try again.');
} finally {
this.setLoading(false);
}
}
setLoading(loading) {
const btn = document.getElementById('login-btn');
const icon = btn.querySelector('i');
if (loading) {
btn.disabled = true;
btn.classList.add('loading');
icon.className = 'fas fa-spinner';
btn.querySelector('span') ?
btn.querySelector('span').textContent = 'Signing In...' :
btn.innerHTML = '<i class="fas fa-spinner"></i> Signing In...';
} else {
btn.disabled = false;
btn.classList.remove('loading');
icon.className = 'fas fa-sign-in-alt';
btn.innerHTML = '<i class="fas fa-sign-in-alt"></i> Sign In';
}
}
showError(message) {
const errorDiv = document.getElementById('error-message');
const errorText = document.getElementById('error-text');
errorText.textContent = message;
errorDiv.style.display = 'flex';
// Auto-hide after 5 seconds
setTimeout(() => {
this.hideError();
}, 5000);
}
hideError() {
document.getElementById('error-message').style.display = 'none';
}
showSuccess(message) {
// Create success message element if it doesn't exist
let successDiv = document.getElementById('success-message');
if (!successDiv) {
successDiv = document.createElement('div');
successDiv.id = 'success-message';
successDiv.className = 'success-message';
successDiv.innerHTML = `
<i class="fas fa-check-circle"></i>
<span id="success-text">${message}</span>
`;
// Add CSS for success message
const style = document.createElement('style');
style.textContent = `
.success-message {
background: #d5f4e6;
border: 1px solid #27ae60;
border-radius: 8px;
padding: 0.75rem;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
color: #27ae60;
font-size: 0.9rem;
animation: slideDown 0.3s ease-out;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`;
document.head.appendChild(style);
// Insert before error message
const errorDiv = document.getElementById('error-message');
errorDiv.parentNode.insertBefore(successDiv, errorDiv);
} else {
document.getElementById('success-text').textContent = message;
successDiv.style.display = 'flex';
}
}
}
// Password toggle functionality
function togglePassword() {
const passwordInput = document.getElementById('password');
const passwordEye = document.getElementById('password-eye');
if (passwordInput.type === 'password') {
passwordInput.type = 'text';
passwordEye.className = 'fas fa-eye-slash';
} else {
passwordInput.type = 'password';
passwordEye.className = 'fas fa-eye';
}
}
// Initialize login manager when page loads
document.addEventListener('DOMContentLoaded', () => {
new LoginManager();
});
// Security: Clear sensitive data on page unload
window.addEventListener('beforeunload', () => {
// Clear password field
const passwordField = document.getElementById('password');
if (passwordField) {
passwordField.value = '';
}
});

View File

@@ -18,6 +18,16 @@
<i class="fas fa-circle"></i> Online
</span>
<span class="current-time" id="current-time"></span>
<div class="user-menu">
<span class="user-info" id="user-info">
<i class="fas fa-user"></i>
<span id="username">Loading...</span>
</span>
<button class="logout-btn" onclick="logout()">
<i class="fas fa-sign-out-alt"></i>
Logout
</button>
</div>
</div>
</div>
</header>

124
templates/login.html Normal file
View File

@@ -0,0 +1,124 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Server Admin - Login</title>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<link href="/static/login.css" rel="stylesheet">
</head>
<body>
<div class="login-container">
<div class="login-card">
<!-- Header -->
<div class="login-header">
<div class="logo">
<i class="fas fa-robot"></i>
<h1>AI Server Admin</h1>
</div>
<p class="subtitle">Secure Access Portal</p>
</div>
<!-- Login Form -->
<form id="login-form" class="login-form">
<div class="form-group">
<label for="username">
<i class="fas fa-user"></i>
Username
</label>
<input
type="text"
id="username"
name="username"
required
autocomplete="username"
placeholder="Enter your username"
>
</div>
<div class="form-group">
<label for="password">
<i class="fas fa-lock"></i>
Password
</label>
<div class="password-input">
<input
type="password"
id="password"
name="password"
required
autocomplete="current-password"
placeholder="Enter your password"
>
<button type="button" class="password-toggle" onclick="togglePassword()">
<i class="fas fa-eye" id="password-eye"></i>
</button>
</div>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="remember-me" name="remember">
<span class="checkmark"></span>
Remember me for 30 days
</label>
</div>
<button type="submit" class="login-btn" id="login-btn">
<i class="fas fa-sign-in-alt"></i>
Sign In
</button>
</form>
<!-- Error Message -->
<div id="error-message" class="error-message" style="display: none;">
<i class="fas fa-exclamation-triangle"></i>
<span id="error-text">Invalid credentials</span>
</div>
<!-- Security Info -->
<div class="security-info">
<div class="security-item">
<i class="fas fa-shield-alt"></i>
<span>Secure Connection</span>
</div>
<div class="security-item">
<i class="fas fa-clock"></i>
<span>Session Timeout: 24h</span>
</div>
<div class="security-item">
<i class="fas fa-key"></i>
<span>JWT Authentication</span>
</div>
</div>
<!-- Footer -->
<div class="login-footer">
<p>&copy; 2025 AI Server Admin. All rights reserved.</p>
<div class="version-info">
<span>Version 2.0 (Phase 3)</span>
</div>
</div>
</div>
<!-- Background Animation -->
<div class="bg-animation">
<div class="floating-icon" style="--delay: 0s;">
<i class="fas fa-robot"></i>
</div>
<div class="floating-icon" style="--delay: 2s;">
<i class="fas fa-brain"></i>
</div>
<div class="floating-icon" style="--delay: 4s;">
<i class="fas fa-microchip"></i>
</div>
<div class="floating-icon" style="--delay: 6s;">
<i class="fas fa-network-wired"></i>
</div>
</div>
</div>
<!-- Scripts -->
<script src="/static/login.js"></script>
</body>
</html>

View File

@@ -7,7 +7,9 @@ AI Server Admin Dashboard Test Server
import os
import secrets
import uuid
from datetime import datetime
import jwt
import bcrypt
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional
@@ -15,6 +17,7 @@ from fastapi import FastAPI, Request, HTTPException, Depends, Header
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
import uvicorn
# FastAPI 앱 초기화
@@ -29,6 +32,25 @@ TEST_API_KEY = os.getenv("API_KEY", "test-admin-key-123")
TEST_SERVER_PORT = 28080
TEST_OLLAMA_HOST = "http://localhost:11434"
# JWT 설정
JWT_SECRET_KEY = "test-jwt-secret-key-for-development"
JWT_ALGORITHM = "HS256"
security = HTTPBearer(auto_error=False)
# 테스트용 사용자 데이터
TEST_USERS = {
"admin": {
"username": "admin",
"password_hash": bcrypt.hashpw("admin123".encode('utf-8'), bcrypt.gensalt()).decode('utf-8'),
"role": "admin"
},
"hyungi": {
"username": "hyungi",
"password_hash": bcrypt.hashpw("hyungi123".encode('utf-8'), bcrypt.gensalt()).decode('utf-8'),
"role": "system"
}
}
# 임시 데이터 저장소
api_keys_store = {
"test-key-1": {
@@ -71,6 +93,60 @@ test_models = [
]
# JWT 인증 함수들
def create_jwt_token(user_data: dict, remember_me: bool = False) -> str:
"""JWT 토큰 생성"""
expiration = datetime.utcnow() + timedelta(
days=30 if remember_me else 0,
hours=24 if not remember_me else 0
)
payload = {
"username": user_data["username"],
"role": user_data["role"],
"exp": expiration,
"iat": datetime.utcnow()
}
return jwt.encode(payload, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM)
def verify_jwt_token(token: str) -> dict:
"""JWT 토큰 검증"""
try:
payload = jwt.decode(token, JWT_SECRET_KEY, algorithms=[JWT_ALGORITHM])
return payload
except jwt.ExpiredSignatureError:
raise HTTPException(status_code=401, detail="Token has expired")
except jwt.InvalidTokenError:
raise HTTPException(status_code=401, detail="Invalid token")
def verify_password(password: str, password_hash: str) -> bool:
"""비밀번호 검증"""
return bcrypt.checkpw(password.encode('utf-8'), password_hash.encode('utf-8'))
async def get_current_user(credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)):
"""현재 인증된 사용자 가져오기"""
if not credentials:
raise HTTPException(status_code=401, detail="Authentication required")
try:
payload = verify_jwt_token(credentials.credentials)
username = payload.get("username")
user = TEST_USERS.get(username)
if not user:
raise HTTPException(status_code=401, detail="User not found")
return {
"username": user["username"],
"role": user["role"]
}
except HTTPException:
raise
except Exception as e:
print(f"JWT verification error: {e}")
raise HTTPException(status_code=401, detail="Invalid authentication credentials")
def require_api_key(x_api_key: Optional[str] = Header(None), api_key: Optional[str] = None):
"""API 키 검증 (테스트 모드에서는 URL 파라미터도 허용)"""
# URL 파라미터로 API 키가 전달된 경우
@@ -82,6 +158,33 @@ def require_api_key(x_api_key: Optional[str] = Header(None), api_key: Optional[s
# 테스트 모드에서는 기본 허용
return "test-mode"
async def require_admin_role(current_user: dict = Depends(get_current_user)):
"""관리자 권한 필요"""
if current_user["role"] not in ["admin", "system"]:
raise HTTPException(status_code=403, detail="Admin privileges required")
return current_user
# 유연한 인증 (JWT 또는 API 키)
async def flexible_auth(
request: Request,
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
x_api_key: Optional[str] = Header(None)
):
"""JWT 또는 API 키 인증"""
# JWT 토큰 시도
if credentials:
try:
return await get_current_user(credentials)
except HTTPException:
pass
# API 키 시도 (테스트 모드)
if x_api_key == TEST_API_KEY:
return {"username": "api_user", "role": "system"}
# 둘 다 실패하면 로그인 페이지로 리다이렉트
raise HTTPException(status_code=401, detail="Authentication required")
@app.get("/", response_class=HTMLResponse)
async def root():
@@ -105,10 +208,64 @@ async def health_check():
return {"status": "ok", "mode": "test", "timestamp": datetime.now().isoformat()}
# JWT 인증 엔드포인트들
@app.get("/login", response_class=HTMLResponse)
async def login_page(request: Request):
"""로그인 페이지"""
return templates.TemplateResponse("login.html", {"request": request})
@app.post("/admin/login")
async def admin_login(request: Request):
"""JWT 기반 로그인 (테스트 모드)"""
try:
data = await request.json()
username = data.get("username", "").strip()
password = data.get("password", "")
remember_me = data.get("remember_me", False)
if not username or not password:
return {"success": False, "message": "Username and password are required"}
# 사용자 인증
user = TEST_USERS.get(username)
if user and verify_password(password, user["password_hash"]):
# JWT 토큰 생성
token = create_jwt_token(user, remember_me)
return {
"success": True,
"message": "Login successful",
"token": token,
"user": {
"username": user["username"],
"role": user["role"]
}
}
else:
return {"success": False, "message": "Invalid username or password"}
except Exception as e:
return {"success": False, "message": "Login error occurred"}
@app.get("/admin/verify-token")
async def verify_token(current_user: dict = Depends(get_current_user)):
"""JWT 토큰 검증"""
return {
"valid": True,
"user": current_user
}
@app.post("/admin/logout")
async def admin_logout(current_user: dict = Depends(get_current_user)):
"""로그아웃"""
return {"success": True, "message": "Logged out successfully"}
# Admin Dashboard Routes
@app.get("/admin", response_class=HTMLResponse)
async def admin_dashboard(request: Request, api_key: str = Depends(require_api_key)):
"""관리자 대시보드 페이지"""
async def admin_dashboard(request: Request):
"""관리자 대시보드 페이지 (클라이언트에서 JWT 검증)"""
# HTML 페이지를 먼저 반환하고, JavaScript에서 토큰 검증
return templates.TemplateResponse("admin.html", {
"request": request,
"server_port": TEST_SERVER_PORT,