BrianChuan commited on
Commit
e3f9d17
·
1 Parent(s): 604ea61

gmail 顯示畫面調整

Browse files
__pycache__/models.cpython-311.pyc CHANGED
Binary files a/__pycache__/models.cpython-311.pyc and b/__pycache__/models.cpython-311.pyc differ
 
__pycache__/security.cpython-311.pyc CHANGED
Binary files a/__pycache__/security.cpython-311.pyc and b/__pycache__/security.cpython-311.pyc differ
 
models.py CHANGED
@@ -263,7 +263,7 @@ def record_log(ip, cookie, account, role, action, status, time_mode, custom_time
263
  from security import trigger_identity_verification
264
  from config import FIXED_TEST_EMAIL
265
  # 以帳戶名稱作為識別 key,信件收件人統一使用 FIXED_TEST_EMAIL
266
- trigger_identity_verification(account, FIXED_TEST_EMAIL, log_time)
267
  print(f"🚨 [Security Alert Triggered] Account: {account}, Status: {status}")
268
  except Exception as e:
269
  print(f"❌ [Security Alert Error] {e}")
 
263
  from security import trigger_identity_verification
264
  from config import FIXED_TEST_EMAIL
265
  # 以帳戶名稱作為識別 key,信件收件人統一使用 FIXED_TEST_EMAIL
266
+ trigger_identity_verification(account, FIXED_TEST_EMAIL, log_time, reason=status)
267
  print(f"🚨 [Security Alert Triggered] Account: {account}, Status: {status}")
268
  except Exception as e:
269
  print(f"❌ [Security Alert Error] {e}")
security.py CHANGED
@@ -16,7 +16,7 @@ import os
16
  from config import SMTP_CONFIG, FIXED_TEST_EMAIL, RESEND_API_KEY, EMAIL_PROVIDER
17
  from auth import _get_or_create_session_entry, session_cache_lock, session_cache, _verify_token_to_user
18
  from utils import get_current_time
19
- from fastapi.responses import JSONResponse
20
 
21
  def _send_via_resend(recipient_email: str, subject: str, html_body: str) -> bool:
22
  """
@@ -102,12 +102,14 @@ def _send_via_smtp(recipient_email: str, subject: str, html_body: str) -> bool:
102
  return False
103
 
104
 
105
- def send_security_alert(recipient_email: str, verify_token: str, account_name: str = ""):
106
  """
107
  當偵測到異常時,發送包含兩個鏈接的郵件:
108
  - 這是我本人(信任)
109
  - 這不是我(封鎖)
110
  account_name: 觸發異常的帳戶名稱(用於顯示在信件中)
 
 
111
 
112
  發送優先順序:
113
  1. Resend(若 RESEND_API_KEY 已設定,適合 HF 部署)
@@ -124,21 +126,75 @@ def send_security_alert(recipient_email: str, verify_token: str, account_name: s
124
  print(f"🔗 [Security Alert] account={account_name}, block_url={block_url}")
125
  print(f"📧 [Security Alert] 使用發送方式: {EMAIL_PROVIDER.upper()}")
126
 
127
- # 帳號顯示名稱(若有提供則加入說明)
128
- account_info = f"<p><strong>⚠️ 警示帳號:{account_name}</strong></p>" if account_name else ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
 
130
  subject = f"[數位保鏢] 安全提醒:帳號 {account_name} 偵測到異常操作" if account_name else "安全提醒:請確認是否為本人操作"
131
  html_body = f"""
132
- <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e0e0e0; border-radius: 8px;">
133
- <h2 style="color: #d32f2f;">🔐 數位保鏢安全警示通知</h2>
134
- {account_info}
135
- <p>系統偵測到此帳號可能發生異常登入或操作行為,請確認是否為本人所為。</p>
136
- <p>請於 <strong>5 分鐘內</strong> 點選以下其中一項進行確認:</p>
137
- <div style="margin: 20px 0;">
138
- <a href="{trust_url}" style="display: inline-block; padding: 12px 24px; background-color: #2e7d32; color: white; text-decoration: none; border-radius: 6px; margin-right: 12px;">✅ 這是我本人(繼續使用)</a>
139
- <a href="{block_url}" style="display: inline-block; padding: 12px 24px; background-color: #c62828; color: white; text-decoration: none; border-radius: 6px;">🚫 這不是我(立即封鎖)</a>
140
  </div>
141
- <p style="color: #757575; font-size: 12px;">若您未主動進行任何操作,請立即點選「這不是我(立即封鎖)」以保護帳號安全。逾時未確認將自動封鎖帳號。</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  </div>
143
  """
144
  html_body = html_body.replace("\xa0", " ")
@@ -157,11 +213,14 @@ def send_security_alert(recipient_email: str, verify_token: str, account_name: s
157
 
158
  return success
159
 
160
- def trigger_identity_verification(account_key: str, recipient_email: str, anomaly_timestamp: str):
 
161
  """
162
  觸發身份驗證
163
  account_key: 用於 session_cache 的識別 key(帳戶名稱)
164
  recipient_email: 安全警報信件的收件人(統一使用 FIXED_TEST_EMAIL)
 
 
165
  """
166
  # 如果已經封鎖,保持更強的決定
167
  entry = _get_or_create_session_entry(account_key)
@@ -177,20 +236,152 @@ def trigger_identity_verification(account_key: str, recipient_email: str, anomal
177
  })
178
 
179
  # 寄送信件時傳入帳戶名稱,讓收件人知道是哪個帳號觸發警示
180
- success = send_security_alert(recipient_email, verify_token, account_name=account_key)
181
  return verify_token, success
182
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
183
  def verify_identity(token: str, choice: str):
184
  """郵件鏈接回調處理"""
185
  token = token.strip()
186
  user_email = _verify_token_to_user(token)
187
  if not user_email:
188
  print(f"❌ [Verification Failed] Invalid token: {token}")
189
- return JSONResponse({"ok": False, "error": "Invalid or expired token"}, status_code=400)
190
 
191
  # 處理選擇
192
  choice_norm = choice.lower().strip()
193
- active_choices = {"trust", "allow", "yes", "這是我本人(信任)"}
194
  blocked_choices = {"block", "deny", "blocked", "封鎖", "不是我", "這不是我(封鎖)"}
195
 
196
  if choice_norm in active_choices:
@@ -198,24 +389,22 @@ def verify_identity(token: str, choice: str):
198
  elif choice_norm in blocked_choices:
199
  next_status = "blocked"
200
  else:
201
- return JSONResponse({"ok": False, "error": "Invalid choice"}, status_code=400)
202
 
203
  with session_cache_lock:
204
  # 確保 key 經過正規化
205
  user_email = user_email.strip().replace("\xa0", " ")
206
  entry = session_cache.get(user_email)
207
  if not entry:
208
- return JSONResponse({"ok": False, "error": "Session not found"}, status_code=404)
209
 
210
  entry["status"] = next_status
211
  # Token 是單次使用:決定後清除
212
  entry["pending_token"] = None
213
 
214
- return {
215
- "ok": True,
216
- "user_email": user_email,
217
- "status": next_status,
218
- }
219
 
220
  def test_send_security_alert():
221
  """
@@ -225,7 +414,7 @@ def test_send_security_alert():
225
  """
226
  anomaly_timestamp = datetime.datetime.now(datetime.timezone(datetime.timedelta(hours=8))).strftime("%Y-%m-%d %H:%M:%S")
227
  # 測試時以 FIXED_TEST_EMAIL 同時作為 account_key 與收件人
228
- token, success = trigger_identity_verification(FIXED_TEST_EMAIL, FIXED_TEST_EMAIL, anomaly_timestamp)
229
  if not token:
230
  return {"ok": False, "error": "Already blocked or session not available"}
231
  return {"ok": True, "user_email": FIXED_TEST_EMAIL, "token": token, "email_sent": success}
 
16
  from config import SMTP_CONFIG, FIXED_TEST_EMAIL, RESEND_API_KEY, EMAIL_PROVIDER
17
  from auth import _get_or_create_session_entry, session_cache_lock, session_cache, _verify_token_to_user
18
  from utils import get_current_time
19
+ from fastapi.responses import JSONResponse, HTMLResponse
20
 
21
  def _send_via_resend(recipient_email: str, subject: str, html_body: str) -> bool:
22
  """
 
102
  return False
103
 
104
 
105
+ def send_security_alert(recipient_email: str, verify_token: str, account_name: str = "", anomaly_timestamp: str = "", reason: str = ""):
106
  """
107
  當偵測到異常時,發送包含兩個鏈接的郵件:
108
  - 這是我本人(信任)
109
  - 這不是我(封鎖)
110
  account_name: 觸發異常的帳戶名稱(用於顯示在信件中)
111
+ anomaly_timestamp: 異常發生的時間
112
+ reason: 異常原因
113
 
114
  發送優先順序:
115
  1. Resend(若 RESEND_API_KEY 已設定,適合 HF 部署)
 
126
  print(f"🔗 [Security Alert] account={account_name}, block_url={block_url}")
127
  print(f"📧 [Security Alert] 使用發送方式: {EMAIL_PROVIDER.upper()}")
128
 
129
+ # 計算有效截止時間 (5分鐘後)
130
+ expiration_time = ""
131
+ if anomaly_timestamp:
132
+ try:
133
+ ts_str = anomaly_timestamp.strip()
134
+ dt = datetime.datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S")
135
+ exp_dt = dt + datetime.timedelta(minutes=5)
136
+ expiration_time = exp_dt.strftime("%Y-%m-%d %H:%M:%S")
137
+ except Exception:
138
+ try:
139
+ import pandas as pd
140
+ dt = pd.to_datetime(ts_str).to_pydatetime()
141
+ exp_dt = dt + datetime.timedelta(minutes=5)
142
+ expiration_time = exp_dt.strftime("%Y-%m-%d %H:%M:%S")
143
+ except Exception:
144
+ expiration_time = "收到信件後 5 分鐘內"
145
+ else:
146
+ now_dt = datetime.datetime.now(datetime.timezone(datetime.timedelta(hours=8)))
147
+ anomaly_timestamp = now_dt.strftime("%Y-%m-%d %H:%M:%S")
148
+ expiration_time = (now_dt + datetime.timedelta(minutes=5)).strftime("%Y-%m-%d %H:%M:%S")
149
+
150
+ # 清理原因前綴
151
+ clean_reason = reason.lstrip("⚠️").replace("異常_", "") if reason else "偵測到異常操作行為"
152
 
153
  subject = f"[數位保鏢] 安全提醒:帳號 {account_name} 偵測到異常操作" if account_name else "安全提醒:請確認是否為本人操作"
154
  html_body = f"""
155
+ <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;">
156
+ <div style="text-align: center; margin-bottom: 20px;">
157
+ <h2 style="color: #dc2626; margin: 0 0 10px 0; font-size: 22px;">🔐 數位保鏢安全警示通知</h2>
158
+ <div style="height: 3px; background: linear-gradient(90deg, #ef4444, #f87171); border-radius: 2px;"></div>
 
 
 
 
159
  </div>
160
+
161
+ <p style="color: #374151; font-size: 15px; line-height: 1.6;">
162
+ 系統偵測到您的帳號發生異常登入或敏感操作行為。為了保護您的帳戶安全,已暫時限制您的操作,請確認此行為是否為本人所為:
163
+ </p>
164
+
165
+ <!-- Information Table Card -->
166
+ <div style="background-color: #f9fafb; border-left: 4px solid #dc2626; padding: 15px; margin: 20px 0; border-radius: 6px;">
167
+ <table style="width: 100%; border-collapse: collapse; font-size: 14px;">
168
+ <tr>
169
+ <td style="padding: 6px 0; color: #6b7280; width: 110px; font-weight: bold;">⚠️ 警示帳號:</td>
170
+ <td style="padding: 6px 0; color: #111827; font-weight: bold;">{account_name}</td>
171
+ </tr>
172
+ <tr>
173
+ <td style="padding: 6px 0; color: #6b7280; font-weight: bold;">🕒 發生時間:</td>
174
+ <td style="padding: 6px 0; color: #111827;">{anomaly_timestamp}</td>
175
+ </tr>
176
+ <tr>
177
+ <td style="padding: 6px 0; color: #6b7280; font-weight: bold;">⏳ 信件有效期:</td>
178
+ <td style="padding: 6px 0; color: #b91c1c; font-weight: bold;">{expiration_time} (5 分鐘內有效)</td>
179
+ </tr>
180
+ <tr>
181
+ <td style="padding: 6px 0; color: #6b7280; font-weight: bold;">🔍 異常原因:</td>
182
+ <td style="padding: 6px 0; color: #111827; font-weight: bold;">{clean_reason}</td>
183
+ </tr>
184
+ </table>
185
+ </div>
186
+
187
+ <!-- Verification Actions -->
188
+ <div style="margin: 25px 0; text-align: center;">
189
+ <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>
190
+ <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>
191
+ </div>
192
+
193
+ <hr style="border: 0; border-top: 1px solid #f3f4f6; margin: 20px 0;">
194
+ <p style="color: #6b7280; font-size: 12px; line-height: 1.5; margin: 0;">
195
+ ※ 若您未主動進行任何操作,請立即點選「這不是我(立即封鎖)」以保護帳號安全。<br>
196
+ ※ 逾時(5 分鐘)未確認,系統將基於安全考量自動封鎖該帳號。
197
+ </p>
198
  </div>
199
  """
200
  html_body = html_body.replace("\xa0", " ")
 
213
 
214
  return success
215
 
216
+
217
+ def trigger_identity_verification(account_key: str, recipient_email: str, anomaly_timestamp: str, reason: str = "偵測到異常操作"):
218
  """
219
  觸發身份驗證
220
  account_key: 用於 session_cache 的識別 key(帳戶名稱)
221
  recipient_email: 安全警報信件的收件人(統一使用 FIXED_TEST_EMAIL)
222
+ anomaly_timestamp: 異常發生時間
223
+ reason: 異常原因
224
  """
225
  # 如果已經封鎖,保持更強的決定
226
  entry = _get_or_create_session_entry(account_key)
 
236
  })
237
 
238
  # 寄送信件時傳入帳戶名稱,讓收件人知道是哪個帳號觸發警示
239
+ success = send_security_alert(recipient_email, verify_token, account_name=account_key, anomaly_timestamp=anomaly_timestamp, reason=reason)
240
  return verify_token, success
241
 
242
+ def _render_verification_page(success: bool, title: str, message: str, status_badge: str = ""):
243
+ icon_class = "success-icon" if success else "error-icon"
244
+ svg_icon = """
245
+ <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>
246
+ """ if success else """
247
+ <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>
248
+ """
249
+
250
+ status_html = ""
251
+ if status_badge:
252
+ badge_class = "status-active" if "啟用" in status_badge or "解封" in status_badge else "status-blocked"
253
+ status_html = f'<div class="status-badge {badge_class}">{status_badge}</div>'
254
+
255
+ html = f"""<!DOCTYPE html>
256
+ <html lang="zh-TW">
257
+ <head>
258
+ <meta charset="UTF-8">
259
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
260
+ <title>數位保鏢 - 身份驗證</title>
261
+ <style>
262
+ body {{
263
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
264
+ background-color: #f3f4f6;
265
+ margin: 0;
266
+ display: flex;
267
+ align-items: center;
268
+ justify-content: center;
269
+ min-height: 100vh;
270
+ color: #1f2937;
271
+ }}
272
+ .card {{
273
+ background-color: #ffffff;
274
+ border-radius: 16px;
275
+ box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
276
+ padding: 40px 30px;
277
+ width: 90%;
278
+ max-width: 400px;
279
+ text-align: center;
280
+ box-sizing: border-box;
281
+ }}
282
+ .icon-container {{
283
+ width: 80px;
284
+ height: 80px;
285
+ border-radius: 50%;
286
+ margin: 0 auto 24px;
287
+ display: flex;
288
+ align-items: center;
289
+ justify-content: center;
290
+ }}
291
+ .success-icon {{
292
+ background-color: #ecfdf5;
293
+ color: #10b981;
294
+ }}
295
+ .error-icon {{
296
+ background-color: #fef2f2;
297
+ color: #ef4444;
298
+ }}
299
+ h1 {{
300
+ font-size: 22px;
301
+ margin: 0 0 12px;
302
+ font-weight: 700;
303
+ }}
304
+ p {{
305
+ font-size: 14px;
306
+ color: #4b5563;
307
+ line-height: 1.6;
308
+ margin: 0 0 20px;
309
+ }}
310
+ .status-badge {{
311
+ display: inline-block;
312
+ padding: 6px 16px;
313
+ border-radius: 20px;
314
+ font-size: 14px;
315
+ font-weight: 600;
316
+ margin-bottom: 24px;
317
+ }}
318
+ .status-active {{
319
+ background-color: #d1fae5;
320
+ color: #065f46;
321
+ }}
322
+ .status-blocked {{
323
+ background-color: #fee2e2;
324
+ color: #991b1b;
325
+ }}
326
+ .button {{
327
+ display: inline-block;
328
+ background-color: #3b82f6;
329
+ color: #ffffff;
330
+ text-decoration: none;
331
+ padding: 12px 24px;
332
+ border-radius: 8px;
333
+ font-weight: 600;
334
+ font-size: 14px;
335
+ transition: background-color 0.2s;
336
+ cursor: pointer;
337
+ border: none;
338
+ outline: none;
339
+ width: 100%;
340
+ box-sizing: border-box;
341
+ }}
342
+ .button:hover {{
343
+ background-color: #2563eb;
344
+ }}
345
+ .footer-note {{
346
+ font-size: 12px;
347
+ color: #9ca3af;
348
+ margin-top: 24px;
349
+ margin-bottom: 0;
350
+ }}
351
+ </style>
352
+ </head>
353
+ <body>
354
+ <div class="card">
355
+ <div class="icon-container {icon_class}">
356
+ {svg_icon}
357
+ </div>
358
+ <h1>{title}</h1>
359
+ <p>{message}</p>
360
+ {status_html}
361
+ <button onclick="window.close()" class="button">關閉視窗</button>
362
+ <p class="footer-note">此頁面將於 3 秒後自動嘗試關閉</p>
363
+ </div>
364
+ <script>
365
+ setTimeout(function() {{
366
+ window.close();
367
+ }}, 3000);
368
+ </script>
369
+ </body>
370
+ </html>"""
371
+ return HTMLResponse(content=html, status_code=200 if success else 400)
372
+
373
+
374
  def verify_identity(token: str, choice: str):
375
  """郵件鏈接回調處理"""
376
  token = token.strip()
377
  user_email = _verify_token_to_user(token)
378
  if not user_email:
379
  print(f"❌ [Verification Failed] Invalid token: {token}")
380
+ return _render_verification_page(False, "驗證失敗", "此連結已失效、過期或無效,請確認是否已點擊過。")
381
 
382
  # 處理選擇
383
  choice_norm = choice.lower().strip()
384
+ active_choices = {"trust", "allow", "yes", "這幕是我本人(信任)", "這是我本人(信任)", "這是我本人(繼續使用)"}
385
  blocked_choices = {"block", "deny", "blocked", "封鎖", "不是我", "這不是我(封鎖)"}
386
 
387
  if choice_norm in active_choices:
 
389
  elif choice_norm in blocked_choices:
390
  next_status = "blocked"
391
  else:
392
+ return _render_verification_page(False, "驗證失敗", "無效的操作選項,請重新由警示信件點選。")
393
 
394
  with session_cache_lock:
395
  # 確保 key 經過正規化
396
  user_email = user_email.strip().replace("\xa0", " ")
397
  entry = session_cache.get(user_email)
398
  if not entry:
399
+ return _render_verification_page(False, "驗證失敗", "找不到相關的登入會話,可能已被系統清除。")
400
 
401
  entry["status"] = next_status
402
  # Token 是單次使用:決定後清除
403
  entry["pending_token"] = None
404
 
405
+ status_badge_text = "帳號狀態:已啟用 (解封)" if next_status == "active" else "帳號狀態:已封鎖 (安全防護)"
406
+ success_msg = f"已成功驗證帳號:{user_email}"
407
+ return _render_verification_page(True, "身份驗證完成", success_msg, status_badge=status_badge_text)
 
 
408
 
409
  def test_send_security_alert():
410
  """
 
414
  """
415
  anomaly_timestamp = datetime.datetime.now(datetime.timezone(datetime.timedelta(hours=8))).strftime("%Y-%m-%d %H:%M:%S")
416
  # 測試時以 FIXED_TEST_EMAIL 同時作為 account_key 與收件人
417
+ token, success = trigger_identity_verification(FIXED_TEST_EMAIL, FIXED_TEST_EMAIL, anomaly_timestamp, reason="手動觸發測試警報")
418
  if not token:
419
  return {"ok": False, "error": "Already blocked or session not available"}
420
  return {"ok": True, "user_email": FIXED_TEST_EMAIL, "token": token, "email_sent": success}