Spaces:
Sleeping
Sleeping
| # 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 路由定義 --- | |
| def get_stats(): | |
| return internal_get_stats() | |
| def get_logs(limit: int = 20): | |
| return internal_get_logs(limit) | |
| 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 | |
| ) | |
| def logout_endpoint(req: StudentActionExtendedRequest): | |
| return internal_logout( | |
| req.ip, req.cookie, req.account, req.time_mode, req.custom_time | |
| ) | |
| 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} | |
| } | |
| 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 | |
| } | |
| 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, "未知區域") | |
| # 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, | |
| "ai_status": ai_status, | |
| "status_detail": status_raw # 保留原始詳細資訊 | |
| }) | |
| return { | |
| "status": "success", | |
| "session_id": session_id, | |
| "count": len(final_logs), | |
| "data": final_logs | |
| } | |
| 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} | |
| 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} | |
| 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} | |
| def verify_identity_endpoint(token: str = Query(...), choice: str = Query(...)): | |
| return verify_identity(token, choice) | |
| 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 | |
| } | |
| 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) | |
| 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) | |
| 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) | |
| 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) | |
| def student_webmail(req: StudentActionExtendedRequest): | |
| return _run_student_action_helper(req.ip, req.cookie, req.account, "click_webmail", req.time_mode, req.custom_time) | |
| def student_vdesk(req: StudentActionExtendedRequest): | |
| return _run_student_action_helper(req.ip, req.cookie, req.account, "click_vdesk", req.time_mode, req.custom_time) | |
| 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) | |
| 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 | |
| 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} | |
| 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} 已成功封鎖"} | |
| 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 分析端點 --- | |
| 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}" | |
| } |