Files
taiji-AI-PAD/services/mcp-server/app/magic_link.py
T

324 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Heicode magic-link 邮箱登录 —— Redis 一次性 token/code 存取 + 登录链接邮件发送
本模块为 Heicode magic-link 登录新增的独立支撑层,契约见
`Docs/Heicode-magic-link邮箱登录-给mcp-server的对接需求.md`(§2/§7/§9/§11/§12)。
设计要点(与既有逻辑零耦合,绝不改动密码登录/注册验证码链路):
- **复用** `email_verification` 的 SMTP 通道(smtp.189.cn / taijiagent@189.cn)发送
登录链接邮件;
- **复用** `state.redis_client` 存一次性凭证,沿用现有验证码同款 one-time 模式
(`setex` 落地 + 命中即 `delete`,含 Redis 集群 MOVED 重定向重试);
- token / code 均为短 TTL、一次性:
- magic-link token:TTL 600s(邮件链接里的凭证)
- 一次性 code:TTL 120s(landing 校验通过后换取登录态的凭证)
"""
from __future__ import annotations
import json
import secrets
from typing import Optional
import structlog
from config import settings
from app.state import get_state
# 复用现有 SMTP 通道与同步发信实现,不另起炉灶
from app.email_verification import (
SMTP_EMAIL,
SMTP_PASSWORD,
_send_email_sync,
)
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
logger = structlog.get_logger(__name__)
# ===== TTL / Redis key 约定(契约 §2 / §7.1)=====
MAGIC_LINK_TOKEN_TTL_SECONDS = 600 # 邮件链接 token:10 分钟
MAGIC_LINK_CODE_TTL_SECONDS = 120 # 一次性 code:≤2 分钟
_TOKEN_KEY_PREFIX = "magic_link_token:"
_CODE_KEY_PREFIX = "magic_link_code:"
# 邮箱维度限流(§13.2「邮箱限流」)。**独立**于注册/忘记密码共用的
# verification_rate_limit:{email},避免 magic-link 与那两条流程互相误伤。
_EMAIL_RATE_LIMIT_KEY_PREFIX = "magic_link_rate_limit:"
EMAIL_RATE_LIMIT_SECONDS = 60 # 同一邮箱 60s 内只接受一次申请
_MAX_REDIS_RETRIES = 3
def generate_magic_link_token() -> str:
"""生成 magic-link token(URL-safe,随邮件链接下发)。"""
return secrets.token_urlsafe(32)
def generate_one_time_code() -> str:
"""生成一次性 code(URL-safe,landing 302 回跳给客户端)。"""
return secrets.token_urlsafe(24)
async def _redis_get(key: str) -> Optional[str]:
"""带 Redis 集群 MOVED 重定向重试的 GET(与 email_verification 同款)。"""
state = get_state()
if not state.redis_client:
logger.warning("magic_link_redis_unavailable", op="get")
return None
for attempt in range(_MAX_REDIS_RETRIES):
try:
return await state.redis_client.get(key)
except Exception as e: # noqa: BLE001
if "MOVED" in str(e) and attempt < _MAX_REDIS_RETRIES - 1:
import asyncio
await asyncio.sleep(0.1)
continue
raise
return None
async def _redis_delete(key: str) -> None:
"""带 MOVED 重试的 DELETE;删除失败不致命(一次性消费已凭 get 判定)。"""
state = get_state()
if not state.redis_client:
return
for attempt in range(_MAX_REDIS_RETRIES):
try:
await state.redis_client.delete(key)
return
except Exception as e: # noqa: BLE001
if "MOVED" in str(e) and attempt < _MAX_REDIS_RETRIES - 1:
import asyncio
await asyncio.sleep(0.1)
continue
logger.warning("magic_link_redis_delete_failed", key_prefix=key[:24], error=str(e))
return
async def _setex(key: str, ttl: int, value: str) -> bool:
state = get_state()
if not state.redis_client:
logger.warning("magic_link_redis_unavailable", op="setex")
return False
try:
await state.redis_client.setex(key, ttl, value)
return True
except Exception as e: # noqa: BLE001
logger.error("magic_link_redis_setex_failed", key_prefix=key[:24], error=str(e))
return False
# ===== 邮箱维度限流(§13.2)=====
async def check_email_rate_limit(email: str) -> tuple[bool, int]:
"""检查同一邮箱的申请频率限制。
Returns: (是否可以申请, 剩余等待秒数)。Redis 不可用时放行(不阻塞用户),
与 email_verification.check_rate_limit 的容错口径一致。
"""
state = get_state()
if not state.redis_client:
return True, 0
try:
ttl = await state.redis_client.ttl(_EMAIL_RATE_LIMIT_KEY_PREFIX + email)
if ttl and ttl > 0:
return False, ttl
return True, 0
except Exception as e: # noqa: BLE001
logger.error("magic_link_email_rate_limit_check_failed", email=email, error=str(e))
return True, 0
async def set_email_rate_limit(email: str) -> None:
"""对该邮箱设置 60s 申请冷却。**对存在/不存在的邮箱一律设置**——否则
「存在→后续 429 / 不存在→后续 200」会泄漏邮箱存在性,破坏防枚举(D-2)。"""
state = get_state()
if not state.redis_client:
return
try:
await state.redis_client.setex(
_EMAIL_RATE_LIMIT_KEY_PREFIX + email, EMAIL_RATE_LIMIT_SECONDS, "1"
)
except Exception as e: # noqa: BLE001
logger.error("magic_link_email_rate_limit_set_failed", email=email, error=str(e))
# ===== token:email + state 绑定 =====
async def store_magic_link_token(
token: str,
email: str,
state: str,
web_callback: Optional[str] = None,
accept_url: Optional[str] = None,
ttl: int = MAGIC_LINK_TOKEN_TTL_SECONDS,
) -> bool:
"""存 magic-link token → {email, state[, web_callback, accept_url]},TTL 默认 600s。
- 桌面登录:只传 email/state(landing 302 到 heicode://)。
- 企业邀请 web 模式(Q2-B):额外传 web_callback + accept_url(landing 302 到
web_callback?code=&state=&redirect=accept_url),并可用更长 ttl(邀请信可能隔时点)。
"""
data = {"email": email, "state": state}
if web_callback:
data["web_callback"] = web_callback
if accept_url:
data["accept_url"] = accept_url
return await _setex(_TOKEN_KEY_PREFIX + token, ttl, json.dumps(data))
async def consume_magic_link_token(token: str) -> Optional[dict]:
"""一次性消费 token:命中则返回 {email, state} 并删除;否则 None。"""
if not token:
return None
key = _TOKEN_KEY_PREFIX + token
raw = await _redis_get(key)
if not raw:
return None
await _redis_delete(key)
try:
return json.loads(raw)
except (ValueError, TypeError):
logger.warning("magic_link_token_payload_corrupt")
return None
# ===== code:user_id + email + state 绑定 =====
async def store_one_time_code(code: str, user_id: str, email: str, state: str) -> bool:
"""存一次性 code → {user_id, email, state},TTL ≤120s。"""
payload = json.dumps({"user_id": user_id, "email": email, "state": state})
return await _setex(_CODE_KEY_PREFIX + code, MAGIC_LINK_CODE_TTL_SECONDS, payload)
async def consume_one_time_code(code: str) -> Optional[dict]:
"""一次性消费 code:命中则返回 {user_id, email, state} 并删除;否则 None。"""
if not code:
return None
key = _CODE_KEY_PREFIX + code
raw = await _redis_get(key)
if not raw:
return None
await _redis_delete(key)
try:
return json.loads(raw)
except (ValueError, TypeError):
logger.warning("magic_link_code_payload_corrupt")
return None
# ===== 登录链接邮件(复用现有 SMTP 通道)=====
async def send_magic_link_email(email: str, link_url: str) -> bool:
"""发送 magic-link 登录链接邮件。
复用 email_verification 的 SMTP 通道。两道闸:
1. `MAGIC_LINK_EMAIL_ENABLED`(默认 False)—— 契约 §13.2「D-5 签字前默认不外发」,
未置 true 一律 mock(不外发),即便 SMTP 可用也不发,防止端点被滥用。
2. `SMTP_PASSWORD` 未注入 —— 通道不可用。
任一不满足都返回 False、不抛异常,让上层 `request` 端点照常返回 200(防枚举)。
敏感链接(含 token)仅在 DEBUG 下打印,避免普通日志留存凭证(§1.4)。
"""
import os
debug = os.getenv("DEBUG", "false").lower() == "true" or \
os.getenv("ENABLE_TEST_MODE", "false").lower() == "true"
# 闸 1:未签字 → mock 不外发
if not settings.magic_link_email_enabled:
if debug:
logger.warning("magic_link_email_mock", email=email, link_url=link_url,
hint="MAGIC_LINK_EMAIL_ENABLED=false → mock,不外发(§13.2)")
else:
logger.info("magic_link_email_mock", email=email,
hint="MAGIC_LINK_EMAIL_ENABLED=false → mock,不外发(链接仅 DEBUG 打印)")
return False
# 闸 2:通道不可用
if not SMTP_PASSWORD:
logger.error("magic_link_email_smtp_unconfigured", email=email)
return False
try:
msg = MIMEMultipart()
msg["From"] = SMTP_EMAIL
msg["To"] = email
msg["Subject"] = "Taiji AI-PAD 登录链接"
body = f"""
尊敬的用户:
您正在登录 HeiCode 客户端。请在 10 分钟内,**在已安装 HeiCode 的同一台设备上**
点击下面的链接完成登录:
{link_url}
提示:此链接仅用于本次登录,点击后会自动回跳到本机 HeiCode 客户端。
请务必在安装了 HeiCode 的同一台设备上打开此链接,否则客户端无法收到回跳。
如果您没有发起登录,请忽略此邮件,您的账户仍然安全。
此邮件由系统自动发送,请勿回复。
---
Taiji AI-PAD 团队
"""
msg.attach(MIMEText(body, "plain", "utf-8"))
import asyncio
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, _send_email_sync, msg)
logger.info("magic_link_email_sent", email=email)
return True
except Exception as e: # noqa: BLE001
logger.error("magic_link_email_send_failed", email=email, error=str(e),
error_type=type(e).__name__)
return False
async def send_org_invite_email(email: str, accept_link: str, org_name: Optional[str] = None) -> bool:
"""发送企业邀请信(Q2-B)。链接指向 mcp landing(web 模式),点击后经 HM 回调建会话并入组。
与 magic-link 同一发信总闸(`MAGIC_LINK_EMAIL_ENABLED`)+ SMTP 通道。任一不满足返回 False、不抛异常。
"""
if not settings.magic_link_email_enabled:
logger.info("org_invite_email_mock", email=email,
hint="MAGIC_LINK_EMAIL_ENABLED=false → mock,不外发")
return False
if not SMTP_PASSWORD:
logger.error("org_invite_email_smtp_unconfigured", email=email)
return False
org = (org_name or "").strip()
org_line = f"您被邀请加入组织 **{org}**。" if org else "您收到一封企业邀请。"
try:
msg = MIMEMultipart()
msg["From"] = SMTP_EMAIL
msg["To"] = email
msg["Subject"] = (f"Heicode 企业邀请 - {org}" if org else "Heicode 企业邀请")
body = f"""
您好:
{org_line}
请点击下面的链接完成登录并接受邀请:
{accept_link}
点击后会自动完成身份验证并跳转到接受邀请页面。链接有时效,请尽快点击。
如果您并未预期收到此邀请,请忽略此邮件。
此邮件由系统自动发送,请勿回复。
---
Heicode 团队
"""
msg.attach(MIMEText(body, "plain", "utf-8"))
import asyncio
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, _send_email_sync, msg)
logger.info("org_invite_email_sent", email=email, org=org or None)
return True
except Exception as e: # noqa: BLE001
logger.error("org_invite_email_send_failed", email=email, error=str(e),
error_type=type(e).__name__)
return False