Spaces:
Sleeping
Sleeping
| # security.py | |
| # 此檔案負責安全相關功能 | |
| # 包含安全警報發送、身份驗證觸發、異常分析等 | |
| import smtplib | |
| import ssl | |
| import secrets | |
| import datetime | |
| import requests | |
| import json | |
| from email.policy import SMTPUTF8 | |
| from email.mime.text import MIMEText | |
| from email.mime.multipart import MIMEMultipart | |
| from urllib.parse import quote | |
| import os | |
| from config import SMTP_CONFIG, FIXED_TEST_EMAIL, RESEND_API_KEY, EMAIL_PROVIDER | |
| from auth import _get_or_create_session_entry, session_cache_lock, session_cache, _verify_token_to_user | |
| from utils import get_current_time | |
| from fastapi.responses import JSONResponse, HTMLResponse | |
| def _send_via_resend(recipient_email: str, subject: str, html_body: str) -> bool: | |
| """ | |
| 使用 Resend HTTP API 發送郵件。 | |
| 適合部署至 Hugging Face Spaces 等不支援傳統 SMTP 的環境。 | |
| 收件人固定寄送至 h6696201@gmail.com(測試用途)。 | |
| """ | |
| if not RESEND_API_KEY: | |
| print("⚠️ RESEND_API_KEY 未設定,無法使用 Resend 發送郵件。") | |
| return False | |
| # Resend 免費方案要求發件人使用已驗證的網域, | |
| # 使用 onboarding@resend.dev 可在未驗證自有網域時發送至任意收件人。 | |
| from_address = "Digital Bodyguard <onboarding@resend.dev>" | |
| payload = { | |
| "from": from_address, | |
| "to": [recipient_email], | |
| "subject": subject, | |
| "html": html_body, | |
| } | |
| headers = { | |
| "Authorization": f"Bearer {RESEND_API_KEY}", | |
| "Content-Type": "application/json", | |
| } | |
| try: | |
| resp = requests.post( | |
| "https://api.resend.com/emails", | |
| headers=headers, | |
| json=payload, | |
| timeout=20, | |
| ) | |
| if resp.status_code in (200, 201): | |
| data = resp.json() | |
| print(f"✅ [Resend] 郵件已成功發送至: {recipient_email} (id={data.get('id', 'N/A')})") | |
| return True | |
| else: | |
| print(f"❌ [Resend] 發送失敗: HTTP {resp.status_code} → {resp.text}") | |
| return False | |
| except Exception as e: | |
| print(f"❌ [Resend] 請求異常: {e}") | |
| return False | |
| def _send_via_smtp(recipient_email: str, subject: str, html_body: str) -> bool: | |
| """ | |
| 使用傳統 SMTP 發送郵件(保留作為備援 / 本地開發使用)。 | |
| """ | |
| smtp_host = SMTP_CONFIG["host"].replace("\xa0", " ") | |
| smtp_port = SMTP_CONFIG["port"] | |
| smtp_username = SMTP_CONFIG["username"].replace("\xa0", " ") | |
| smtp_password = SMTP_CONFIG["password"].replace("\xa0", " ") | |
| smtp_from = SMTP_CONFIG["from"] | |
| if not smtp_host or not smtp_username or not smtp_password: | |
| print("⚠️ SMTP 設定未完成(SMTP_HOST/SMTP_USERNAME/SMTP_PASSWORD 缺少),跳過寄信。") | |
| return False | |
| # 正規化非斷行空格 | |
| html_body_clean = html_body.replace("\xa0", " ") | |
| msg = MIMEText(html_body_clean, "html", "utf-8", policy=SMTPUTF8) | |
| msg["Subject"] = subject | |
| msg["From"] = smtp_from | |
| msg["To"] = recipient_email | |
| try: | |
| # 使用 SMTP + STARTTLS 進行安全傳輸 | |
| with smtplib.SMTP(smtp_host, smtp_port, timeout=20) as server: | |
| server.set_debuglevel(0) | |
| server.ehlo() | |
| context = ssl.create_default_context() | |
| server.starttls(context=context) | |
| server.ehlo() | |
| server.login(smtp_username, smtp_password) | |
| # send_message 處理非 ascii 內容的正確編碼 | |
| server.send_message(msg) | |
| print(f"✅ [SMTP] 安全警報郵件已成功寄送至: {recipient_email}") | |
| return True | |
| except Exception as e: | |
| print(f"❌ [SMTP] 寄信失敗: {e}") | |
| return False | |
| def send_security_alert(recipient_email: str, verify_token: str, account_name: str = "", anomaly_timestamp: str = "", reason: str = ""): | |
| """ | |
| 當偵測到異常時,發送包含兩個鏈接的郵件: | |
| - 這是我本人(信任) | |
| - 這不是我(封鎖) | |
| account_name: 觸發異常的帳戶名稱(用於顯示在信件中) | |
| anomaly_timestamp: 異常發生的時間 | |
| reason: 異常原因 | |
| 發送優先順序: | |
| 1. Resend(若 RESEND_API_KEY 已設定,適合 HF 部署) | |
| 2. SMTP(Resend 失敗或未設定時 fallback,適合本地開發) | |
| """ | |
| base_url = SMTP_CONFIG["base_url"].replace("\xa0", " ") | |
| recipient_email = recipient_email.replace("\xa0", " ") | |
| trust_url = f"{base_url}/verify_identity?token={quote(verify_token)}&choice=trust" | |
| block_url = f"{base_url}/verify_identity?token={quote(verify_token)}&choice=block" | |
| # 始終記錄鏈接以供調試/測試 | |
| print(f"🔗 [Security Alert] account={account_name}, trust_url={trust_url}") | |
| print(f"🔗 [Security Alert] account={account_name}, block_url={block_url}") | |
| print(f"📧 [Security Alert] 使用發送方式: {EMAIL_PROVIDER.upper()}") | |
| # 計算有效截止時間 (5分鐘後) | |
| expiration_time = "" | |
| if anomaly_timestamp: | |
| try: | |
| ts_str = anomaly_timestamp.strip() | |
| dt = datetime.datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S") | |
| exp_dt = dt + datetime.timedelta(minutes=5) | |
| expiration_time = exp_dt.strftime("%Y-%m-%d %H:%M:%S") | |
| except Exception: | |
| try: | |
| import pandas as pd | |
| dt = pd.to_datetime(ts_str).to_pydatetime() | |
| exp_dt = dt + datetime.timedelta(minutes=5) | |
| expiration_time = exp_dt.strftime("%Y-%m-%d %H:%M:%S") | |
| except Exception: | |
| expiration_time = "收到信件後 5 分鐘內" | |
| else: | |
| now_dt = datetime.datetime.now(datetime.timezone(datetime.timedelta(hours=8))) | |
| anomaly_timestamp = now_dt.strftime("%Y-%m-%d %H:%M:%S") | |
| expiration_time = (now_dt + datetime.timedelta(minutes=5)).strftime("%Y-%m-%d %H:%M:%S") | |
| # 清理原因前綴 | |
| clean_reason = reason.lstrip("⚠️").replace("異常_", "") if reason else "偵測到異常操作行為" | |
| subject = f"[數位保鏢] 安全提醒:帳號 {account_name} 偵測到異常操作" if account_name else "安全提醒:請確認是否為本人操作" | |
| html_body = f""" | |
| <div style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; max-width: 550px; margin: 0 auto; padding: 25px; border: 1px solid #e5e7eb; border-radius: 12px; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.05); background-color: #ffffff;"> | |
| <div style="text-align: center; margin-bottom: 20px;"> | |
| <h2 style="color: #dc2626; margin: 0 0 10px 0; font-size: 22px;">🔐 數位保鏢安全警示通知</h2> | |
| <div style="height: 3px; background: linear-gradient(90deg, #ef4444, #f87171); border-radius: 2px;"></div> | |
| </div> | |
| <p style="color: #374151; font-size: 15px; line-height: 1.6;"> | |
| 系統偵測到您的帳號發生異常登入或敏感操作行為。為了保護您的帳戶安全,已暫時限制您的操作,請確認此行為是否為本人所為: | |
| </p> | |
| <!-- Information Table Card --> | |
| <div style="background-color: #f9fafb; border-left: 4px solid #dc2626; padding: 15px; margin: 20px 0; border-radius: 6px;"> | |
| <table style="width: 100%; border-collapse: collapse; font-size: 14px;"> | |
| <tr> | |
| <td style="padding: 6px 0; color: #6b7280; width: 110px; font-weight: bold;">⚠️ 警示帳號:</td> | |
| <td style="padding: 6px 0; color: #111827; font-weight: bold;">{account_name}</td> | |
| </tr> | |
| <tr> | |
| <td style="padding: 6px 0; color: #6b7280; font-weight: bold;">🕒 發生時間:</td> | |
| <td style="padding: 6px 0; color: #111827;">{anomaly_timestamp}</td> | |
| </tr> | |
| <tr> | |
| <td style="padding: 6px 0; color: #6b7280; font-weight: bold;">⏳ 信件有效期:</td> | |
| <td style="padding: 6px 0; color: #b91c1c; font-weight: bold;">{expiration_time} (5 分鐘內有效)</td> | |
| </tr> | |
| <tr> | |
| <td style="padding: 6px 0; color: #6b7280; font-weight: bold;">🔍 異常原因:</td> | |
| <td style="padding: 6px 0; color: #111827; font-weight: bold;">{clean_reason}</td> | |
| </tr> | |
| </table> | |
| </div> | |
| <!-- Verification Actions --> | |
| <div style="margin: 25px 0; text-align: center;"> | |
| <a href="{trust_url}" style="display: inline-block; padding: 12px 24px; background-color: #16a34a; color: white; text-decoration: none; border-radius: 8px; font-weight: bold; margin-right: 15px; box-shadow: 0 2px 4px rgba(22,163,74,0.2);">✅ 這是我本人(解封)</a> | |
| <a href="{block_url}" style="display: inline-block; padding: 12px 24px; background-color: #dc2626; color: white; text-decoration: none; border-radius: 8px; font-weight: bold; box-shadow: 0 2px 4px rgba(220,38,38,0.2);">🚫 這不是我(封鎖)</a> | |
| </div> | |
| <hr style="border: 0; border-top: 1px solid #f3f4f6; margin: 20px 0;"> | |
| <p style="color: #6b7280; font-size: 12px; line-height: 1.5; margin: 0;"> | |
| ※ 若您未主動進行任何操作,請立即點選「這不是我(立即封鎖)」以保護帳號安全。<br> | |
| ※ 逾時(5 分鐘)未確認,系統將基於安全考量自動封鎖該帳號。 | |
| </p> | |
| </div> | |
| """ | |
| html_body = html_body.replace("\xa0", " ") | |
| # 依照 EMAIL_PROVIDER 選擇發送方式,失敗時自動 fallback | |
| if EMAIL_PROVIDER == "resend": | |
| success = _send_via_resend(recipient_email, subject, html_body) | |
| if not success: | |
| print("🔄 [Fallback] Resend 失敗,嘗試使用 SMTP 備援..") | |
| success = _send_via_smtp(recipient_email, subject, html_body) | |
| else: | |
| success = _send_via_smtp(recipient_email, subject, html_body) | |
| if not success: | |
| print("🔄 [Fallback] SMTP 失敗,嘗試使用 Resend 備援..") | |
| success = _send_via_resend(recipient_email, subject, html_body) | |
| return success | |
| def trigger_identity_verification(account_key: str, recipient_email: str, anomaly_timestamp: str, reason: str = "偵測到異常操作"): | |
| """ | |
| 觸發身份驗證 | |
| account_key: 用於 session_cache 的識別 key(帳戶名稱) | |
| recipient_email: 安全警報信件的收件人(統一使用 FIXED_TEST_EMAIL) | |
| anomaly_timestamp: 異常發生時間 | |
| reason: 異常原因 | |
| """ | |
| # 如果已經封鎖,保持更強的決定 | |
| entry = _get_or_create_session_entry(account_key) | |
| with session_cache_lock: | |
| if entry.get("status") == "blocked": | |
| return None, False | |
| verify_token = secrets.token_urlsafe(32) | |
| entry.update({ | |
| "status": "verifying", | |
| "pending_token": verify_token, | |
| "anomaly_timestamp": anomaly_timestamp, | |
| }) | |
| # 寄送信件時傳入帳戶名稱,讓收件人知道是哪個帳號觸發警示 | |
| success = send_security_alert(recipient_email, verify_token, account_name=account_key, anomaly_timestamp=anomaly_timestamp, reason=reason) | |
| return verify_token, success | |
| def _render_verification_page(success: bool, title: str, message: str, status_badge: str = ""): | |
| icon_class = "success-icon" if success else "error-icon" | |
| svg_icon = """ | |
| <svg class="w-12 h-12" fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width: 48px; height: 48px;"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7"></path></svg> | |
| """ if success else """ | |
| <svg class="w-12 h-12" fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width: 48px; height: 48px;"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12"></path></svg> | |
| """ | |
| status_html = "" | |
| if status_badge: | |
| badge_class = "status-active" if "啟用" in status_badge or "解封" in status_badge else "status-blocked" | |
| status_html = f'<div class="status-badge {badge_class}">{status_badge}</div>' | |
| html = f"""<!DOCTYPE html> | |
| <html lang="zh-TW"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>數位保鏢 - 身份驗證</title> | |
| <style> | |
| body {{ | |
| font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; | |
| background-color: #f3f4f6; | |
| margin: 0; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| min-height: 100vh; | |
| color: #1f2937; | |
| }} | |
| .card {{ | |
| background-color: #ffffff; | |
| border-radius: 16px; | |
| box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1); | |
| padding: 40px 30px; | |
| width: 90%; | |
| max-width: 400px; | |
| text-align: center; | |
| box-sizing: border-box; | |
| }} | |
| .icon-container {{ | |
| width: 80px; | |
| height: 80px; | |
| border-radius: 50%; | |
| margin: 0 auto 24px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| }} | |
| .success-icon {{ | |
| background-color: #ecfdf5; | |
| color: #10b981; | |
| }} | |
| .error-icon {{ | |
| background-color: #fef2f2; | |
| color: #ef4444; | |
| }} | |
| h1 {{ | |
| font-size: 22px; | |
| margin: 0 0 12px; | |
| font-weight: 700; | |
| }} | |
| p {{ | |
| font-size: 14px; | |
| color: #4b5563; | |
| line-height: 1.6; | |
| margin: 0 0 20px; | |
| }} | |
| .status-badge {{ | |
| display: inline-block; | |
| padding: 6px 16px; | |
| border-radius: 20px; | |
| font-size: 14px; | |
| font-weight: 600; | |
| margin-bottom: 24px; | |
| }} | |
| .status-active {{ | |
| background-color: #d1fae5; | |
| color: #065f46; | |
| }} | |
| .status-blocked {{ | |
| background-color: #fee2e2; | |
| color: #991b1b; | |
| }} | |
| .button {{ | |
| display: inline-block; | |
| background-color: #3b82f6; | |
| color: #ffffff; | |
| text-decoration: none; | |
| padding: 12px 24px; | |
| border-radius: 8px; | |
| font-weight: 600; | |
| font-size: 14px; | |
| transition: background-color 0.2s; | |
| cursor: pointer; | |
| border: none; | |
| outline: none; | |
| width: 100%; | |
| box-sizing: border-box; | |
| }} | |
| .button:hover {{ | |
| background-color: #2563eb; | |
| }} | |
| .footer-note {{ | |
| font-size: 12px; | |
| color: #9ca3af; | |
| margin-top: 24px; | |
| margin-bottom: 0; | |
| }} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="card"> | |
| <div class="icon-container {icon_class}"> | |
| {svg_icon} | |
| </div> | |
| <h1>{title}</h1> | |
| <p>{message}</p> | |
| {status_html} | |
| <button onclick="window.close()" class="button">關閉視窗</button> | |
| <p class="footer-note">此頁面將於 3 秒後自動嘗試關閉</p> | |
| </div> | |
| <script> | |
| setTimeout(function() {{ | |
| window.close(); | |
| }}, 3000); | |
| </script> | |
| </body> | |
| </html>""" | |
| return HTMLResponse(content=html, status_code=200 if success else 400) | |
| def verify_identity(token: str, choice: str): | |
| """郵件鏈接回調處理""" | |
| token = token.strip() | |
| user_email = _verify_token_to_user(token) | |
| if not user_email: | |
| print(f"❌ [Verification Failed] Invalid token: {token}") | |
| return _render_verification_page(False, "驗證失敗", "此連結已失效、過期或無效,請確認是否已點擊過。") | |
| # 處理選擇 | |
| choice_norm = choice.lower().strip() | |
| active_choices = {"trust", "allow", "yes", "這幕是我本人(信任)", "這是我本人(信任)", "這是我本人(繼續使用)"} | |
| blocked_choices = {"block", "deny", "blocked", "封鎖", "不是我", "這不是我(封鎖)"} | |
| if choice_norm in active_choices: | |
| next_status = "active" | |
| elif choice_norm in blocked_choices: | |
| next_status = "blocked" | |
| else: | |
| return _render_verification_page(False, "驗證失敗", "無效的操作選項,請重新由警示信件點選。") | |
| with session_cache_lock: | |
| # 確保 key 經過正規化 | |
| user_email = user_email.strip().replace("\xa0", " ") | |
| entry = session_cache.get(user_email) | |
| if not entry: | |
| return _render_verification_page(False, "驗證失敗", "找不到相關的登入會話,可能已被系統清除。") | |
| entry["status"] = next_status | |
| # Token 是單次使用:決定後清除 | |
| entry["pending_token"] = None | |
| status_badge_text = "帳號狀態:已啟用 (解封)" if next_status == "active" else "帳號狀態:已封鎖 (安全防護)" | |
| success_msg = f"已成功驗證帳號:{user_email}" | |
| return _render_verification_page(True, "身份驗證完成", success_msg, status_badge=status_badge_text) | |
| def test_send_security_alert(): | |
| """ | |
| 測試助手: | |
| - 直接觸發為 FIXED_TEST_EMAIL 發送安全警報 | |
| - 返回生成的 verify token(token 也存儲在 session_cache 中) | |
| """ | |
| anomaly_timestamp = datetime.datetime.now(datetime.timezone(datetime.timedelta(hours=8))).strftime("%Y-%m-%d %H:%M:%S") | |
| # 測試時以 FIXED_TEST_EMAIL 同時作為 account_key 與收件人 | |
| token, success = trigger_identity_verification(FIXED_TEST_EMAIL, FIXED_TEST_EMAIL, anomaly_timestamp, reason="手動觸發測試警報") | |
| if not token: | |
| return {"ok": False, "error": "Already blocked or session not available"} | |
| return {"ok": True, "user_email": FIXED_TEST_EMAIL, "token": token, "email_sent": success} | |
| def explain_abnormal_log(log_text): | |
| """使用 Groq AI 解釋異常日誌""" | |
| if not log_text or log_text.strip() == "": | |
| return "⚠️ 請先提供異常 Log 資訊以進行分析。" | |
| api_key = os.getenv("GROQ_API_KEY") | |
| if not api_key: | |
| return "⚠️ 未偵測到 GROQ_API_KEY,請在 .env 檔案中設定以啟用 AI 分析。" | |
| url = "https://api.groq.com/openai/v1/chat/completions" | |
| headers = { | |
| "Authorization": f"Bearer {api_key}", | |
| "Content-Type": "application/json" | |
| } | |
| prompt = f""" | |
| 你是一個資安分析助手。請分析以下這條異常 Log,並給出可能的攻擊類型、風險等級(低/中/高/極高)以及建議的處理動作。 | |
| 請用繁體中文回答,並保持語氣專業且簡潔。 | |
| 異常 Log: | |
| {log_text} | |
| """ | |
| payload = { | |
| "model": "llama-3.3-70b-versatile", | |
| "messages": [ | |
| {"role": "system", "content": "你是一位資深資安專家,專長於行為分析與入侵偵測。"}, | |
| {"role": "user", "content": prompt} | |
| ], | |
| "temperature": 0.5, | |
| "max_tokens": 1024 | |
| } | |
| try: | |
| response = requests.post(url, headers=headers, json=payload, timeout=15) | |
| response.raise_for_status() | |
| result = response.json() | |
| analysis = result['choices'][0]['message']['content'] | |
| return analysis | |
| except Exception as e: | |
| return f"❌ AI 分析請求失敗: {str(e)}" |