BrianChuan
小細節修正,model pkl update, resend API 調用
4996b79
Raw
History Blame Contribute Delete
18.1 kB
# api.py
# 此檔案定義所有 API 端點
# 包含統計數據、日誌查詢、圖表數據等
from fastapi import FastAPI, Query, Body
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from typing import Optional, List
import os
from groq import Groq
from auth import (
simulate_click, USER_DB, check_session_timeouts, get_user_status,
internal_process_login, internal_logout
)
from security import test_send_security_alert, verify_identity
from config import SYSTEM_WEIGHTS, ACTION_MAP, FIXED_TEST_EMAIL
from plotting import get_plot_data_for_api
import pandas as pd
import models # 使用 models.XXX 存取即時狀態,避免布林值被複製後失效
from utils import get_geo_level, update_system_weights
api_app = FastAPI(title="數位保鏢 API")
# --- Pydantic Models ---
class AnalyzeLogRequest(BaseModel):
log_data: str # JSON 格式的 log 字串
event_desc: Optional[str] = ""
event_ip: Optional[str] = ""
event_status: Optional[str] = ""
class WeightsRequest(BaseModel):
ow: float
tw: float
iw: float
dw: float
gw: float
fw: float
class LoginRequest(BaseModel):
account: str
password: str
captcha: str
ip: str
cookie: str
time_mode: str
custom_time: Optional[str] = ""
class StudentActionRequest(BaseModel):
ip: str
cookie: str
time_mode: str
custom_time: Optional[str] = ""
class StudentActionExtendedRequest(StudentActionRequest):
account: str
class GenericActionRequest(StudentActionRequest):
account: str
action_code: str
# --- 純淨 API 端點專用函式 (供內部呼叫) ---
def internal_get_stats():
"""取得系統狀態與權重"""
total = models.global_stats["total_count"]
abnormal = models.global_stats["abnormal_count"]
rate = round((abnormal / total * 100), 2) if total > 0 else 0
return {
"status": "success",
"ai_ready": models.AI_READY,
"data": {
"total_logs": total,
"abnormal_logs": abnormal,
"anomaly_rate_percent": rate,
"current_weights": SYSTEM_WEIGHTS
}
}
def internal_get_logs(limit=20):
"""取得最新的 N 筆 Log"""
return {
"status": "success",
"count": len(models.log_history[:limit]),
"data": models.log_history[:limit]
}
def _extract_gr_update_value(upd):
"""從 Gradio update 物件中提取 value (如果有)"""
if hasattr(upd, 'value'):
return upd.value
if isinstance(upd, dict) and 'value' in upd:
return upd['value']
return ""
# --- FastAPI 路由定義 ---
@api_app.get("/api/stats")
def get_stats():
return internal_get_stats()
@api_app.get("/api/logs")
def get_logs(limit: int = 20):
return internal_get_logs(limit)
@api_app.post("/api/login")
def login_endpoint(req: LoginRequest):
return internal_process_login(
req.ip, req.cookie, req.account, req.password,
req.captcha, req.time_mode, req.custom_time
)
@api_app.post("/api/logout")
def logout_endpoint(req: StudentActionExtendedRequest):
return internal_logout(
req.ip, req.cookie, req.account, req.time_mode, req.custom_time
)
@api_app.get("/api/chart_data")
def get_chart_data(action_name: str = Query(...)):
"""取得特定系統的圖表原始數據"""
if not models.DATA_READY:
return {"status": "error", "message": "歷史基準資料未載入,請確認 baseline_logs.csv 是否存在或雲端同步是否正常"}
# 嘗試從顯示名稱映射到代碼,如果找不到,假設輸入已經是代碼
action_code = ACTION_MAP.get(action_name) or action_name
# 驗證 action_code 是否有效 (在 baseline_df 中存在或在 ACTION_MAP 的值中)
valid_codes = set(ACTION_MAP.values())
if action_code not in valid_codes and action_code not in models.baseline_df['action'].unique():
return {"status": "error", "message": f"找不到系統代碼或名稱: {action_name}"}
df_hist = models.baseline_df[models.baseline_df['action'] == action_code].copy()
df_live = pd.DataFrame(models.log_history)
if not df_live.empty:
df_live['timestamp'] = pd.to_datetime(df_live['timestamp'], errors='coerce')
df_live = df_live.dropna(subset=['timestamp'])
df_live = df_live[df_live['action'] == action_code].copy()
if not df_live.empty:
df_combined = pd.concat([df_hist, df_live], ignore_index=True)
df_combined = df_combined.drop_duplicates(subset=['timestamp', 'account', 'action'])
else:
df_combined = df_hist.copy()
if df_combined.empty:
return {"status": "success", "action": action_code, "data": {"normal": [], "abnormal": []}}
df_combined['month'] = df_combined['timestamp'].dt.month
df_combined['time_of_day'] = df_combined['timestamp'].dt.hour + df_combined['timestamp'].dt.minute / 60.0
is_abnormal = df_combined['status'].astype(str).str.contains("異常", na=False)
normal_data = df_combined[~is_abnormal][['month', 'time_of_day', 'timestamp']].astype(str).to_dict('records')
abnormal_data = df_combined[is_abnormal][['month', 'time_of_day', 'timestamp', 'status']].astype(str).to_dict('records')
return {
"status": "success",
"action_code": action_code,
"data": {"normal": normal_data, "abnormal": abnormal_data}
}
@api_app.get("/api/abnormal_logs")
def get_abnormal_logs():
"""取得所有異常 Log"""
abnormal_logs = [log for log in models.log_history if "異常" in str(log.get("status", "")) or "錯誤" in str(log.get("status", "")) or "受限" in str(log.get("status", ""))]
return {
"status": "success",
"count": len(abnormal_logs),
"data": abnormal_logs
}
@api_app.get("/api/get_user_logs")
def get_user_logs(session_id: str = Query(...)):
"""
獲取指定 session_id (cookie_id) 的所有日誌
過濾自 Hugging Face 同步的歷史數據與當前記憶體數據
"""
# 1. 準備過濾後的數據
filtered_logs = []
# 從歷史數據中過濾 (如果有的話)
if models.baseline_df is not None and not models.baseline_df.empty:
# 確保資料格式一致
df_filtered = models.baseline_df[models.baseline_df['cookie_id'] == session_id].copy()
if not df_filtered.empty:
df_filtered['timestamp'] = pd.to_datetime(df_filtered['timestamp']).dt.strftime('%Y-%m-%d %H:%M:%S')
filtered_logs.extend(df_filtered.to_dict('records'))
# 從當前記憶體日誌中過濾
mem_logs = [log for log in models.log_history if log.get("cookie_id") == session_id]
filtered_logs.extend(mem_logs)
# 2. 去重並排序 (依時間倒序)
# 使用 timestamp + action 作為簡單的去重基準
seen = set()
unique_logs = []
for log in filtered_logs:
key = (log.get('timestamp'), log.get('action'))
if key not in seen:
seen.add(key)
unique_logs.append(log)
unique_logs.sort(key=lambda x: x.get('timestamp', ''), reverse=True)
# 3. 豐富化資料 (加上地理位置、設備資訊、判定狀態)
final_logs = []
for log in unique_logs:
ip = log.get('ip_address', 'Unknown')
geo_code = get_geo_level(ip)
# 地理位置映射
geo_map = {
0: "台北市 (校內專網)",
1: "台北市 (校園網路)",
2: "台灣 (宿舍/寬頻)",
3: "台灣 (公共網路)",
4: "美國 (海外連線)",
5: "未知區域"
}
location = geo_map.get(geo_code, "未知區域")
# 設備資訊 (目前資料集無此欄位,給予模擬值或依 IP 推斷)
# 這裡模擬一些常見設備
device = "PC (Chrome/Windows)" if "140.124" in ip else "Mobile (Safari/iOS)"
# AI 判定狀態轉為中文
status_raw = str(log.get('status', 'success'))
ai_status = "異常" if ("異常" in status_raw or "受限" in status_raw or "錯誤" in status_raw) else "正常"
final_logs.append({
"time": log.get('timestamp'),
"action": log.get('action'),
"ip": ip,
"location": location,
"device": device,
"ai_status": ai_status,
"status_detail": status_raw # 保留原始詳細資訊
})
return {
"status": "success",
"session_id": session_id,
"count": len(final_logs),
"data": final_logs
}
@api_app.get("/api/chart_html")
def get_chart_html(action_name: str = Query(...)):
"""取得圖表的 HTML 內容 (Gradio 專用)"""
from plotting import get_chart_js_html
html_data = get_chart_js_html(action_name)
return {"status": "success", "html": html_data}
@api_app.get("/api/plot_image")
def get_plot_image(action_name: str = Query(...)):
"""取得圖表的 Base64 圖片"""
if not models.DATA_READY:
return {"status": "error", "message": "歷史基準資料未載入"}
img_data = get_plot_data_for_api(action_name)
return {"status": "success", "image": img_data}
@api_app.post("/api/update_weights")
def update_weights(req: WeightsRequest):
"""更新系統權重"""
msg = update_system_weights(req.ow, req.tw, req.iw, req.dw, req.gw, req.fw)
return {"status": "success", "message": msg}
@api_app.get("/verify_identity")
def verify_identity_endpoint(token: str = Query(...), choice: str = Query(...)):
return verify_identity(token, choice)
@api_app.get("/test_send_security_alert")
def test_send_security_alert_endpoint():
return test_send_security_alert()
# --- Student Actions ---
def _run_student_action_helper(ip, c, acc, action_code, tm, ct):
# 每次請求時檢查超時
check_session_timeouts(timeout_seconds=300)
# 直接以帳戶名稱作為 key 查詢封鎖狀態
current_status = get_user_status(acc)
if current_status == "verifying":
return {
"status": "pending_verify",
"message": "偵測到異常,請於5分鐘內確認 Email 以繼續使用"
}
elif current_status == "blocked":
return {
"status": "blocked",
"message": "帳號已被封鎖,請聯絡系統管理員或寄驗證信進行解封"
}
df_all, df_abn, inp_upd = simulate_click(ip, c, acc, action_code, tm, ct)
val = _extract_gr_update_value(inp_upd)
return {
"status": "success",
"anomaly_msg": val or "",
"action": action_code
}
@api_app.post("/api/student/eval")
def student_eval(req: StudentActionExtendedRequest):
return _run_student_action_helper(req.ip, req.cookie, req.account, "click_eval_system", req.time_mode, req.custom_time)
@api_app.post("/api/student/pre_select")
def student_pre_select(req: StudentActionExtendedRequest):
return _run_student_action_helper(req.ip, req.cookie, req.account, "click_pre_select_system", req.time_mode, req.custom_time)
@api_app.post("/api/student/leave")
def student_leave(req: StudentActionExtendedRequest):
return _run_student_action_helper(req.ip, req.cookie, req.account, "click_leave_system", req.time_mode, req.custom_time)
@api_app.post("/api/student/dorm")
def student_dorm(req: StudentActionExtendedRequest):
return _run_student_action_helper(req.ip, req.cookie, req.account, "click_dorm_system", req.time_mode, req.custom_time)
@api_app.post("/api/student/webmail")
def student_webmail(req: StudentActionExtendedRequest):
return _run_student_action_helper(req.ip, req.cookie, req.account, "click_webmail", req.time_mode, req.custom_time)
@api_app.post("/api/student/vdesk")
def student_vdesk(req: StudentActionExtendedRequest):
return _run_student_action_helper(req.ip, req.cookie, req.account, "click_vdesk", req.time_mode, req.custom_time)
@api_app.post("/api/student/sql_injection")
def student_sql_injection(req: StudentActionExtendedRequest):
return _run_student_action_helper(req.ip, req.cookie, req.account, "malicious_sql_injection", req.time_mode, req.custom_time)
@api_app.post("/api/student/run_action")
def student_run_action(req: GenericActionRequest):
"""通用學生操作端點"""
# 每次請求時檢查超時
check_session_timeouts(timeout_seconds=300)
# 直接以帳戶名稱作為 key 查詢封鎖狀態
current_status = get_user_status(req.account)
if current_status == "verifying":
return {
"status": "pending_verify",
"message": "偵測到異常,請於5分鐘內確認 Email 以繼續使用"
}
elif current_status == "blocked":
return {
"status": "blocked",
"message": "帳號已被封鎖,請聯絡系統管理員或寄驗證信進行解封"
}
df_all, df_abn, inp_upd = simulate_click(req.ip, req.cookie, req.account, req.action_code, req.time_mode, req.custom_time)
val = _extract_gr_update_value(inp_upd)
return {
"status": "success",
"anomaly_msg": val or "",
# 為了 Vue 前端可能需要的資料更新,回傳簡化後的 log
"log_entry": {
"timestamp": pd.Timestamp.now().strftime("%Y-%m-%d %H:%M:%S"), # 這裡簡化處理
"action": req.action_code,
"status": val or "success"
}
}
# --- 使用者管理 API 端點 ---
class AccountRequest(BaseModel):
account: str
@api_app.get("/api/users")
def get_users_endpoint():
"""取得所有使用者帳戶清單與其狀態"""
users_list = []
for acc, info in USER_DB.items():
role_en = "admin" if info[1] == "管理" else "student"
status = get_user_status(acc)
users_list.append({
"account": acc,
"role": role_en,
"role_zh": info[1],
"name": info[2],
"status": status
})
return {"status": "success", "data": users_list}
@api_app.post("/api/users/block")
def block_user_endpoint(req: AccountRequest):
"""封鎖特定使用者"""
acc = req.account.strip()
if acc not in USER_DB:
return {"status": "error", "message": "帳號不存在"}
from auth import session_cache_lock, session_cache
with session_cache_lock:
if acc not in session_cache:
session_cache[acc] = {}
session_cache[acc]["status"] = "blocked"
session_cache[acc]["pending_token"] = None
session_cache[acc]["anomaly_timestamp"] = None
return {"status": "success", "message": f"帳號 {acc} 已成功封鎖"}
@api_app.post("/api/users/unblock")
def unblock_user_endpoint(req: AccountRequest):
"""解鎖特定使用者"""
acc = req.account.strip()
if acc not in USER_DB:
return {"status": "error", "message": "帳號不存在"}
from auth import unblock_user
unblock_user(acc)
return {"status": "success", "message": f"帳號 {acc} 已成功解封"}
# --- Groq AI 分析端點 ---
@api_app.post("/api/analyze_log")
def analyze_log_with_groq(req: AnalyzeLogRequest):
"""
使用 Groq AI 分析異常 Log 事件。
接收原始 Log JSON 字串,呼叫 Groq API 進行資安分析,回傳中文解析結果。
"""
groq_api_key = os.getenv("GROQ_API_KEY", "")
if not groq_api_key:
return {
"status": "error",
"message": "Groq API Key 未設定,請確認後端 .env 檔案中的 GROQ_API_KEY"
}
try:
client = Groq(api_key=groq_api_key)
# 建構 Prompt
prompt_parts = []
prompt_parts.append("你是一位資訊安全專家,正在分析一筆校園系統的異常存取 Log。")
prompt_parts.append("")
if req.event_ip:
prompt_parts.append(f"來源 IP:{req.event_ip}")
if req.event_status:
prompt_parts.append(f"AI 判定狀態:{req.event_status}")
if req.event_desc:
prompt_parts.append(f"事件描述:{req.event_desc}")
prompt_parts.append("")
prompt_parts.append("原始 Log 數據(JSON 格式):")
prompt_parts.append(req.log_data)
prompt_parts.append("")
prompt_parts.append("請根據以上資訊,以繁體中文提供:")
prompt_parts.append("1. 可能的攻擊類型或異常行為說明(1-2句)")
prompt_parts.append("2. 風險等級評估(低/中/高)並說明原因")
prompt_parts.append("3. 具體的建議處理動作(2-4點條列)")
prompt_parts.append("")
prompt_parts.append("請直接輸出分析內容,格式簡潔專業,不要加入多餘的標題或引號。")
full_prompt = "\n".join(prompt_parts)
chat_completion = client.chat.completions.create(
messages=[
{
"role": "system",
"content": "你是一位熟悉台灣校園資訊安全的資安分析師,專精於識別異常存取行為、帳號盜用與惡意攻擊。請以繁體中文回答。"
},
{
"role": "user",
"content": full_prompt
}
],
model="llama-3.3-70b-versatile",
temperature=0.4,
max_tokens=512,
)
analysis_text = chat_completion.choices[0].message.content or "無法生成分析結果。"
return {
"status": "success",
"analysis": analysis_text,
"model": chat_completion.model,
"usage": {
"prompt_tokens": chat_completion.usage.prompt_tokens,
"completion_tokens": chat_completion.usage.completion_tokens,
}
}
except Exception as e:
error_msg = str(e)
print(f"[Groq API Error] {error_msg}")
return {
"status": "error",
"message": f"Groq AI 分析失敗:{error_msg}"
}