feat(auth): 短信登录默认关,换成手机号/邮箱 + 密码,发信邮箱进后台
CI / backend (push) Canceled after 0s

用户要求:内测的固定验证码 123456 等于"知道手机号就能进任何账号",先关掉;
登录改用账号密码,并多一种"邮箱当账号名"的方式;发信邮箱要能在后台改。

短信那条路(默认关):
- `SMS_LOGIN_ENABLED=false`,`/auth/sms/send` 与 `/auth/login` 统一回 2017,不是静默失败;
- 生产校验改成"开着短信登录就必须配真通道",不开时 provider 是 dev 也不拦;
- 新增 `GET /auth/methods`,登录页据此渲染,关着的路不出现(少一次"点进去才发现不能用")。

邮箱登录:
- `app_user.email` + `uq_app_user_email`(`lower(email)` 部分索引),登录与绑定都归一成小写;
- `POST /auth/login/password` 收 `phone` 或 `email`(二选一),失败统一 2013、连错 10 次锁 15 分钟;
- 绑定在 `PATCH /auth/me`:格式错 2018、被占用 2019、留空串表示解绑。

系统设置(跨租户,只有平台管理员能改):
- 新表 `system_setting`(不挂租户、无 RLS,写权限由 `deps.platform_admin` 把关);
- `GET/PUT /settings/smtp`、`POST /settings/smtp/test`,授权码只写不读,留空表示沿用旧值;
- `services/mail.py` 只用标准库 smtplib:465 走 SSL、587 走 STARTTLS,认证/拒收/网络失败都归 2020 但消息不同;
- Web 新增「系统设置」页(状态卡 + 测试发信),侧栏只对平台管理员显示入口;账号页补「邮箱」。

验证:pytest 130 passed、ruff check/format 全绿、tsc 通过、vite build 通过;
`web/e2e/login-email-smtp.mjs` 15 项全绿(含用 126 授权码真发一封测试邮件)。

顺带记下的两个坑:`platform_admin_phones` 是 list,pydantic-settings 会按 JSON 解析,
要用 `NoDecode` 交给 before-validator 拆逗号;迁移号写成 0009 会撞既有的 0009,一并改成 0010。
This commit is contained in:
Steven
2026-11-03 00:00:11 +08:00
parent 23687d6e37
commit 7558637567
29 changed files with 1702 additions and 93 deletions
+202
View File
@@ -0,0 +1,202 @@
"""发信(SMTP)。
三个刻意的取舍:
1. **配置以数据库里的为准**。发信账号是平台级的运维参数,改它不该要人登服务器
改 env 再重启;所以「系统设置」页写库,这里每次读库、读不到才回落到环境变量。
2. **只用标准库 smtplib**。发信不是性能路径,引一个三方 SDK 换来的只是多一条升级债;
SSL / STARTTLS 两种握手都覆盖了(465 走 SSL,587 走 STARTTLS)。
3. **失败要能说清是哪种失败**。连不上、认证不过、被对方拒收,对使用者是三件不同的
事;统一报"发信失败"会让人只能去翻服务器日志。所以异常分类后带进错误码 2020。
"""
from __future__ import annotations
import asyncio
import contextlib
import smtplib
import ssl
from dataclasses import dataclass
from email.header import Header
from email.message import EmailMessage
from email.utils import formataddr, formatdate
from sqlalchemy.ext.asyncio import AsyncSession
from ..config import get_settings
from ..errors import AppError, ErrorCode
from ..models import SystemSetting
SMTP_KEY = "smtp"
# 存库的字段名与对外字段一致,避免来回翻译出现第三个名字
SMTP_FIELDS = ("host", "port", "username", "password", "from_name", "use_ssl")
@dataclass(frozen=True)
class SmtpConfig:
host: str
port: int
username: str
password: str
from_name: str = "达道Task"
use_ssl: bool = True
@property
def sender(self) -> str:
return formataddr((str(Header(self.from_name, "utf-8")), self.username))
@property
def configured(self) -> bool:
return bool(self.host and self.username and self.password)
def _from_settings() -> SmtpConfig | None:
s = get_settings()
if not (s.smtp_host and s.smtp_username and s.smtp_password):
return None
return SmtpConfig(
host=s.smtp_host,
port=s.smtp_port,
username=s.smtp_username,
password=s.smtp_password,
from_name=s.smtp_from_name,
use_ssl=s.smtp_use_ssl,
)
async def load_config(session: AsyncSession) -> SmtpConfig | None:
"""先看库里有没有人配过,没有再回落到环境变量。"""
row = await session.get(SystemSetting, SMTP_KEY)
data = dict(row.value or {}) if row else {}
if not (data.get("host") and data.get("username") and data.get("password")):
return _from_settings()
return SmtpConfig(
host=str(data["host"]),
port=int(data.get("port") or 465),
username=str(data["username"]),
# 授权码里可能有多余空格,粘贴时最常见的就是把尾巴带上
password=str(data["password"]).strip(),
from_name=str(data.get("from_name") or "达道Task"),
use_ssl=bool(data.get("use_ssl", True)),
)
async def save_config(session: AsyncSession, *, values: dict, updated_by) -> SmtpConfig:
"""写库。password 传空表示"沿用原来的",不传等于没改。"""
row = await session.get(SystemSetting, SMTP_KEY)
current = dict(row.value or {}) if row else {}
merged = {**current, **{k: v for k, v in values.items() if v is not None}}
if not values.get("password"):
merged["password"] = current.get("password", "")
if not (merged.get("host") and merged.get("username") and merged.get("password")):
raise AppError(ErrorCode.VALIDATION_FAILED, "SMTP 服务器、账号、授权码都要填")
if row is None:
row = SystemSetting(key=SMTP_KEY, value=merged, updated_by=updated_by)
session.add(row)
else:
row.value = merged
row.updated_by = updated_by
await session.flush()
cfg = SmtpConfig(
host=str(merged["host"]),
port=int(merged.get("port") or 465),
username=str(merged["username"]),
password=str(merged["password"]).strip(),
from_name=str(merged.get("from_name") or "达道Task"),
use_ssl=bool(merged.get("use_ssl", True)),
)
return cfg
def _send_sync(cfg: SmtpConfig, to: str, subject: str, text: str, html: str | None) -> None:
msg = EmailMessage()
msg["Subject"] = str(Header(subject, "utf-8"))
msg["From"] = cfg.sender
msg["To"] = to
msg["Date"] = formatdate(localtime=True)
msg.set_content(text)
if html:
msg.add_alternative(html, subtype="html")
s = get_settings()
timeout = s.smtp_timeout_seconds
context = ssl.create_default_context()
if cfg.use_ssl:
server: smtplib.SMTP = smtplib.SMTP_SSL(
cfg.host, cfg.port, timeout=timeout, context=context
)
else:
server = smtplib.SMTP(cfg.host, cfg.port, timeout=timeout)
server.starttls(context=context)
try:
server.login(cfg.username, cfg.password)
server.send_message(msg)
finally:
# 信已经发出去了,QUIT 失败不该把成功回报成失败
with contextlib.suppress(Exception):
server.quit()
async def send_mail(
session_or_cfg: AsyncSession | SmtpConfig,
*,
to: str,
subject: str,
text: str,
html: str | None = None,
) -> None:
"""发一封纯文本(可带 HTML 替代体)的信。失败一律抛 2020,并说清是哪一步。"""
cfg = (
await load_config(session_or_cfg)
if isinstance(session_or_cfg, AsyncSession)
else session_or_cfg
)
if cfg is None or not cfg.configured:
raise AppError(ErrorCode.MAIL_SEND_FAILED, "还没有配置发信邮箱,请先在系统设置里填 SMTP")
try:
await asyncio.to_thread(_send_sync, cfg, to, subject, text, html)
except smtplib.SMTPAuthenticationError as exc:
raise AppError(
ErrorCode.MAIL_SEND_FAILED,
"SMTP 认证失败:账号或授权码不对(126/163 要用客户端授权码,不是登录密码)",
) from exc
except smtplib.SMTPRecipientsRefused as exc:
raise AppError(ErrorCode.MAIL_SEND_FAILED, f"收件地址被拒绝:{to}") from exc
except (smtplib.SMTPException, OSError) as exc:
raise AppError(ErrorCode.MAIL_SEND_FAILED, f"发信失败:{exc}") from exc
async def config_payload(session: AsyncSession) -> dict:
"""给设置页看的形状:授权码只回"设没设",不回原值。"""
row = await session.get(SystemSetting, SMTP_KEY)
data = dict(row.value or {}) if row else {}
cfg = await load_config(session)
if cfg is None:
return {
"host": data.get("host") or "",
"port": int(data.get("port") or 465),
"username": data.get("username") or "",
"password_set": bool(data.get("password")),
"from_name": data.get("from_name") or "达道Task",
"use_ssl": bool(data.get("use_ssl", True)),
"configured": False,
"source": "none",
"updated_at": row.updated_at if row else None,
}
return {
"host": cfg.host,
"port": cfg.port,
"username": cfg.username,
"password_set": True,
"from_name": cfg.from_name,
"use_ssl": cfg.use_ssl,
"configured": cfg.configured,
"source": "db" if row else "env",
"updated_at": row.updated_at if row else None,
}