用户要求:内测的固定验证码 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:
@@ -0,0 +1,67 @@
|
||||
"""邮箱登录 + 平台设置(M1 账号功能)
|
||||
|
||||
Revision ID: 0010
|
||||
Revises: 0009
|
||||
Create Date: 2026-11-02
|
||||
|
||||
两件事:
|
||||
|
||||
1. `app_user.email`:邮箱当账号名,配合密码登录。唯一性用 `lower(email)` 的部分
|
||||
唯一索引——邮箱大小写不敏感,`Steven@x.com` 和 `steven@x.com` 是同一个人;
|
||||
允许为空(老用户先不绑)。
|
||||
|
||||
2. `system_setting`:平台级键值表,第一项就是 SMTP。**这张表刻意不挂 workspace_id、
|
||||
也不开 RLS**:发信账号是整台服务共用的,属于平台而不是某个租户,开 RLS 反而要
|
||||
编一个"当前租户"出来。写权限在 API 层用平台管理员白名单把关(deps.platform_admin),
|
||||
应用角色只有表的读写权,没有建表/改结构的权。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
from app.roles import APP_ROLE
|
||||
|
||||
revision: str = "0010"
|
||||
down_revision: str = "0009"
|
||||
branch_labels: Sequence[str] | None = None
|
||||
depends_on: Sequence[str] | None = None
|
||||
|
||||
|
||||
CREATE_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS system_setting (
|
||||
key VARCHAR(64) PRIMARY KEY,
|
||||
value JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
updated_by UUID REFERENCES app_user(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
version INTEGER NOT NULL DEFAULT 1
|
||||
)
|
||||
"""
|
||||
|
||||
INDEX_STATEMENTS = (
|
||||
"ALTER TABLE app_user ADD COLUMN IF NOT EXISTS email VARCHAR(255)",
|
||||
"CREATE UNIQUE INDEX uq_app_user_email ON app_user (lower(email)) WHERE email IS NOT NULL",
|
||||
"COMMENT ON TABLE system_setting IS '平台级设置(SMTP 等),不挂租户、无 RLS'",
|
||||
)
|
||||
|
||||
GRANT_SQL = (
|
||||
f"GRANT SELECT, INSERT, UPDATE, DELETE ON system_setting TO {APP_ROLE}",
|
||||
f"GRANT SELECT, UPDATE ON app_user TO {APP_ROLE}",
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(CREATE_SQL)
|
||||
for statement in INDEX_STATEMENTS:
|
||||
op.execute(statement)
|
||||
for statement in GRANT_SQL:
|
||||
op.execute(statement)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS uq_app_user_email")
|
||||
op.execute("ALTER TABLE app_user DROP COLUMN IF EXISTS email")
|
||||
op.execute("DROP TABLE IF EXISTS system_setting")
|
||||
+94
-18
@@ -1,17 +1,20 @@
|
||||
"""认证:手机号 + 验证码,可选登录密码。
|
||||
"""认证:手机号 / 邮箱 + 密码,手机验证码可选。
|
||||
|
||||
M0 只有验证码登录;密码是**可选**的第二条路:用户自己在账号设置里设一次,
|
||||
之后登录页可以选「密码」;没设过密码的人只有验证码这一条,不受影响。
|
||||
当前**默认关掉短信验证码**(`SMS_LOGIN_ENABLED=false`):内测阶段的固定验证码
|
||||
等于"知道手机号就能进任意账号"。关掉后 `/auth/sms/send` 与 `/auth/login` 直接
|
||||
回 2017,登录走「手机号 或 邮箱 + 密码」。登录页先问 `/auth/methods`,只显示
|
||||
服务端真开着的登录方式,免得点进去才报错。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import re
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from ..config import get_settings
|
||||
from ..deps import CurrentUser, SessionDep
|
||||
@@ -31,6 +34,8 @@ router = APIRouter(prefix="/auth", tags=["认证"])
|
||||
settings = get_settings()
|
||||
|
||||
PHONE_PATTERN = r"^1[3-9]\d{9}$"
|
||||
# 邮箱只做形状校验:真正的可达性靠发信验证,正则再严也证明不了地址存在
|
||||
EMAIL_RE = re.compile(r"^[^@\s]{1,64}@[^@\s]{1,190}\.[A-Za-z]{2,}$")
|
||||
|
||||
# 密码登录连错这么多次就先锁一会儿:暴力猜密码的成本必须比猜验证码高
|
||||
PASSWORD_FAIL_LIMIT = 10
|
||||
@@ -41,14 +46,24 @@ def mask_phone(phone: str) -> str:
|
||||
return f"{phone[:3]}****{phone[-4:]}" if len(phone) == 11 else "***"
|
||||
|
||||
|
||||
def normalize_email(email: str) -> str:
|
||||
"""邮箱大小写不敏感:统一小写存,登录时也按小写比。"""
|
||||
return email.strip().lower()
|
||||
|
||||
|
||||
def _user_out(user: AppUser) -> dict:
|
||||
"""对外只给脱敏手机号;密码本身一个字节都不出去,只告诉客户端「设没设」。"""
|
||||
"""对外只给脱敏手机号;密码本身一个字节都不出去,只告诉客户端「设没设」。
|
||||
|
||||
邮箱是用户自己的账号名,给全值(账号页要显示、要能改)。
|
||||
"""
|
||||
return {
|
||||
"id": str(user.id),
|
||||
"phone": mask_phone(user.phone),
|
||||
"email": user.email,
|
||||
"nickname": user.nickname,
|
||||
"avatar_url": user.avatar_url,
|
||||
"has_password": bool(user.password_hash),
|
||||
"platform_admin": user.phone in settings.platform_admin_phones,
|
||||
"created_at": user.created_at,
|
||||
}
|
||||
|
||||
@@ -72,12 +87,23 @@ class LoginIn(BaseModel):
|
||||
|
||||
|
||||
class PasswordLoginIn(BaseModel):
|
||||
phone: str = Field(pattern=PHONE_PATTERN)
|
||||
"""手机号或邮箱 + 密码。两个都给也行,手机号优先(老客户端只发 phone)。"""
|
||||
|
||||
phone: str | None = Field(default=None, pattern=PHONE_PATTERN)
|
||||
email: str | None = Field(default=None, max_length=255)
|
||||
password: str = Field(min_length=1, max_length=64)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _one_account(self) -> PasswordLoginIn:
|
||||
if not self.phone and not (self.email and self.email.strip()):
|
||||
raise ValueError("手机号或邮箱填一个")
|
||||
return self
|
||||
|
||||
|
||||
class ProfilePatch(BaseModel):
|
||||
nickname: str | None = Field(default=None, min_length=1, max_length=32)
|
||||
# 传空串表示解绑;不传表示不动
|
||||
email: str | None = Field(default=None, max_length=255)
|
||||
|
||||
|
||||
class PasswordIn(BaseModel):
|
||||
@@ -91,8 +117,25 @@ class RefreshIn(BaseModel):
|
||||
refresh_token: str = Field(min_length=10)
|
||||
|
||||
|
||||
@router.get("/methods", summary="这台服务现在开放哪些登录方式")
|
||||
async def methods() -> dict:
|
||||
"""登录页据此渲染,别让用户点进去才发现这条路关着。"""
|
||||
return ok(
|
||||
{
|
||||
"password": True,
|
||||
"email_password": True,
|
||||
"sms_code": settings.sms_login_enabled,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/sms/send", summary="发送登录验证码")
|
||||
async def send_code(body: SendCodeIn) -> dict:
|
||||
if not settings.sms_login_enabled:
|
||||
raise AppError(
|
||||
ErrorCode.LOGIN_METHOD_DISABLED,
|
||||
"短信验证码登录暂未开放,请用手机号或邮箱 + 密码登录",
|
||||
)
|
||||
store = get_store()
|
||||
if settings.sms_throttle_seconds > 0:
|
||||
hits = await store.incr(sms_throttle_key(body.phone), settings.sms_throttle_seconds)
|
||||
@@ -115,6 +158,11 @@ async def send_code(body: SendCodeIn) -> dict:
|
||||
|
||||
@router.post("/login", summary="验证码登录(不存在则自动注册)")
|
||||
async def login(body: LoginIn, session: SessionDep) -> dict:
|
||||
if not settings.sms_login_enabled:
|
||||
raise AppError(
|
||||
ErrorCode.LOGIN_METHOD_DISABLED,
|
||||
"短信验证码登录暂未开放,请用手机号或邮箱 + 密码登录",
|
||||
)
|
||||
store = get_store()
|
||||
stored = await store.get(sms_code_key(body.phone))
|
||||
if stored is None:
|
||||
@@ -135,28 +183,36 @@ async def login(body: LoginIn, session: SessionDep) -> dict:
|
||||
return ok({"user": _user_out(user), "tokens": _tokens_out(tokens)})
|
||||
|
||||
|
||||
@router.post("/login/password", summary="密码登录")
|
||||
@router.post("/login/password", summary="手机号 / 邮箱 + 密码登录")
|
||||
async def login_password(body: PasswordLoginIn, session: SessionDep) -> dict:
|
||||
"""手机号 + 密码登录。
|
||||
"""手机号或邮箱 + 密码登录。
|
||||
|
||||
两个细节:
|
||||
- **不区分"没这个人"和"密码错"**,同一个错误码,别让登录接口变成手机号探测工具。
|
||||
三个细节:
|
||||
- **不区分"没这个人"和"密码错"**,同一个错误码,别让登录接口变成账号探测工具。
|
||||
- 连错 PASSWORD_FAIL_LIMIT 次锁 PASSWORD_FAIL_TTL 秒;成功即清零,正常用户不会感知。
|
||||
失败计数按"填的那个账号"记,换手机号/邮箱轮着试也照样累计。
|
||||
- 邮箱大小写不敏感(存的时候已经统一小写,这里再 lower 一次兜住历史数据)。
|
||||
"""
|
||||
store = get_store()
|
||||
fail_key = f"pwd_fail:{body.phone}"
|
||||
account_key = body.phone or normalize_email(body.email or "")
|
||||
fail_key = f"pwd_fail:{account_key}"
|
||||
if int(await store.get(fail_key) or 0) >= PASSWORD_FAIL_LIMIT:
|
||||
raise AppError(
|
||||
ErrorCode.LOGIN_TOO_MANY,
|
||||
f"密码错误次数过多,请 {PASSWORD_FAIL_TTL // 60} 分钟后再试,或用验证码登录",
|
||||
f"密码错误次数过多,请 {PASSWORD_FAIL_TTL // 60} 分钟后再试",
|
||||
)
|
||||
|
||||
user = (
|
||||
await session.execute(select(AppUser).where(AppUser.phone == body.phone))
|
||||
).scalar_one_or_none()
|
||||
if body.phone:
|
||||
stmt = select(AppUser).where(AppUser.phone == body.phone)
|
||||
hint = "手机号或密码不对"
|
||||
else:
|
||||
stmt = select(AppUser).where(func.lower(AppUser.email) == account_key)
|
||||
hint = "邮箱或密码不对"
|
||||
|
||||
user = (await session.execute(stmt)).scalar_one_or_none()
|
||||
if user is None or not verify_password(body.password, user.password_hash):
|
||||
await store.incr(fail_key, PASSWORD_FAIL_TTL)
|
||||
raise AppError(ErrorCode.PASSWORD_INVALID, "手机号或密码不对")
|
||||
raise AppError(ErrorCode.PASSWORD_INVALID, hint)
|
||||
|
||||
if user.status != "active":
|
||||
raise AppError(ErrorCode.ACCOUNT_DISABLED, "账号已停用")
|
||||
@@ -207,7 +263,7 @@ async def me(user: CurrentUser, session: SessionDep) -> dict:
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/me", summary="改自己的昵称")
|
||||
@router.patch("/me", summary="改自己的昵称与邮箱")
|
||||
async def update_me(body: ProfilePatch, user: CurrentUser, session: SessionDep) -> dict:
|
||||
row = await session.get(AppUser, user.id)
|
||||
if row is None:
|
||||
@@ -219,6 +275,26 @@ async def update_me(body: ProfilePatch, user: CurrentUser, session: SessionDep)
|
||||
raise AppError(ErrorCode.VALIDATION_FAILED, "昵称不能为空")
|
||||
row.nickname = nickname
|
||||
|
||||
if body.email is not None:
|
||||
raw = body.email.strip()
|
||||
if not raw:
|
||||
# 解绑:邮箱只是"多一条登录方式",不是必填项
|
||||
row.email = None
|
||||
else:
|
||||
email = normalize_email(raw)
|
||||
if not EMAIL_RE.match(email):
|
||||
raise AppError(ErrorCode.EMAIL_INVALID, "邮箱格式不对")
|
||||
taken = (
|
||||
await session.execute(
|
||||
select(AppUser.id).where(
|
||||
func.lower(AppUser.email) == email, AppUser.id != user.id
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if taken is not None:
|
||||
raise AppError(ErrorCode.EMAIL_TAKEN, "这个邮箱已经绑在别的账号上了")
|
||||
row.email = email
|
||||
|
||||
await session.flush()
|
||||
return ok({"user": _user_out(row)})
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""系统设置:平台级的运维参数(目前只有 SMTP)。
|
||||
|
||||
为什么单独一个路由而不是塞进 auth:这些设置**不属于任何工作区**,
|
||||
权限也不是租户角色,而是「平台管理员」白名单(`deps.platform_admin`)。
|
||||
把它和租户接口分开,读代码的人一眼就知道这里改的是整台服务的配置。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..deps import PlatformAdminDep, SessionDep
|
||||
from ..errors import AppError, ErrorCode
|
||||
from ..models import AppUser
|
||||
from ..schemas import ok
|
||||
from ..services import mail
|
||||
|
||||
router = APIRouter(prefix="/settings", tags=["系统设置"])
|
||||
|
||||
|
||||
class SmtpIn(BaseModel):
|
||||
"""授权码留空表示"沿用原来的",不传等于没改——避免每次保存都要重新粘贴一遍。"""
|
||||
|
||||
host: str = Field(min_length=1, max_length=255)
|
||||
port: int = Field(default=465, ge=1, le=65535)
|
||||
username: str = Field(min_length=1, max_length=255)
|
||||
password: str | None = Field(default=None, max_length=255)
|
||||
from_name: str = Field(default="达道Task", max_length=64)
|
||||
use_ssl: bool = True
|
||||
|
||||
|
||||
class TestMailIn(BaseModel):
|
||||
# 不填就发给自己(账号里绑的邮箱),方便先确认通道通不通
|
||||
to: str | None = Field(default=None, max_length=255)
|
||||
|
||||
|
||||
@router.get("/smtp", summary="读发信配置(授权码只回是否已设)")
|
||||
async def get_smtp(admin: PlatformAdminDep, session: SessionDep) -> dict:
|
||||
return ok(await mail.config_payload(session))
|
||||
|
||||
|
||||
@router.put("/smtp", summary="改发信配置,存库即时生效")
|
||||
async def put_smtp(body: SmtpIn, admin: PlatformAdminDep, session: SessionDep) -> dict:
|
||||
await mail.save_config(
|
||||
session,
|
||||
values={
|
||||
"host": body.host.strip(),
|
||||
"port": body.port,
|
||||
"username": body.username.strip(),
|
||||
"password": (body.password or "").strip() or None,
|
||||
"from_name": body.from_name.strip() or "达道Task",
|
||||
"use_ssl": body.use_ssl,
|
||||
},
|
||||
updated_by=admin.id,
|
||||
)
|
||||
return ok(await mail.config_payload(session))
|
||||
|
||||
|
||||
@router.post("/smtp/test", summary="发一封测试邮件")
|
||||
async def test_smtp(body: TestMailIn, admin: PlatformAdminDep, session: SessionDep) -> dict:
|
||||
"""真发一封。配错授权码、端口不通这类问题,只有真连一次才知道。"""
|
||||
to = (body.to or "").strip()
|
||||
if not to:
|
||||
row = await session.get(AppUser, admin.id)
|
||||
to = (row.email if row else None) or ""
|
||||
if not to:
|
||||
raise AppError(ErrorCode.VALIDATION_FAILED, "填一个收件邮箱,或先在自己的账号里绑一个")
|
||||
|
||||
cfg = await mail.load_config(session)
|
||||
if cfg is None:
|
||||
raise AppError(ErrorCode.MAIL_SEND_FAILED, "还没有配置发信邮箱,请先填上面的 SMTP")
|
||||
|
||||
await mail.send_mail(
|
||||
cfg,
|
||||
to=to,
|
||||
subject="达道Task 测试邮件",
|
||||
text=(
|
||||
"这封信是从达道Task的「系统设置」里发出来的。\n\n"
|
||||
"收到它说明 SMTP 配置可用:以后的找回密码、任务提醒都会走这条通道。\n"
|
||||
),
|
||||
)
|
||||
return ok({"sent": True, "to": to})
|
||||
+31
-7
@@ -4,10 +4,10 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
from functools import lru_cache
|
||||
from typing import Literal
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
|
||||
|
||||
DEV_JWT_SECRET = "dev-only-change-me" # noqa: S105 - 仅开发默认值,生产启动时会被校验拦下
|
||||
|
||||
@@ -60,6 +60,30 @@ class Settings(BaseSettings):
|
||||
# 内测开关:生产默认禁止固定验证码。确实要在内网试用时显式打开,
|
||||
# 启动会打 WARNING(见 main.py),其余生产校验照旧——绝不能配合公网暴露。
|
||||
internal_test_login: bool = False
|
||||
# 登录方式开关:手机验证码这条**默认关掉**。内测期的固定验证码等于"知道手机号就能进任意账号",
|
||||
# 关掉后 /auth/sms/send 与 /auth/login 统一回 2017,登录走「手机号 / 邮箱 + 密码」。
|
||||
# 将来接上真短信通道把它打开即可,登录页会按 /auth/methods 自动列出可用的方式。
|
||||
sms_login_enabled: bool = False
|
||||
|
||||
# 平台管理员:能改系统设置(SMTP 等)。按手机号白名单,不挂工作区角色——
|
||||
# 系统设置是**跨租户**的,某个工作区的 owner 也不该看到平台的发信配置。
|
||||
# `NoDecode` + 下面的 before-validator:这个值环境变量里是逗号分隔的一串,
|
||||
# 不让 pydantic-settings 先按 JSON 解析(那样 `1360...,1390...` 会直接启动失败)。
|
||||
platform_admin_phones: Annotated[list[str], NoDecode] = Field(
|
||||
default_factory=lambda: ["13605513683"]
|
||||
)
|
||||
|
||||
# ── 邮件(SMTP)────────────────────────────────────────
|
||||
# 以「系统设置」页里存的值为准(DB),这里只是首次启动前的兜底。
|
||||
# 改 SMTP 不该要人登服务器改 env 再重启——所以真配置在 DB 里(见 services/mail.py)。
|
||||
smtp_host: str | None = None
|
||||
smtp_port: int = 465
|
||||
smtp_username: str | None = None
|
||||
smtp_password: str | None = None
|
||||
smtp_from_name: str = "达道Task"
|
||||
# 465 走 SSL,587 走 STARTTLS;126/163 这类用 465 + SSL
|
||||
smtp_use_ssl: bool = True
|
||||
smtp_timeout_seconds: int = 10
|
||||
|
||||
# ── 幂等 ──────────────────────────────────────────────
|
||||
idempotency_enabled: bool = True
|
||||
@@ -117,10 +141,10 @@ class Settings(BaseSettings):
|
||||
build_commit: str = "unknown"
|
||||
build_time: str = "unknown"
|
||||
|
||||
@field_validator("cors_origins", mode="before")
|
||||
@field_validator("cors_origins", "platform_admin_phones", mode="before")
|
||||
@classmethod
|
||||
def _split_origins(cls, value: object) -> object:
|
||||
"""支持 CORS_ORIGINS="a,b" 这种写法。"""
|
||||
"""支持 CORS_ORIGINS="a,b" 这种写法(平台管理员白名单同理)。"""
|
||||
if isinstance(value, str) and not value.startswith("["):
|
||||
return [item.strip() for item in value.split(",") if item.strip()]
|
||||
return value
|
||||
@@ -134,10 +158,10 @@ class Settings(BaseSettings):
|
||||
problems.append("JWT_SECRET 仍是开发默认值或长度不足 32")
|
||||
if "dadao_dev_pw" in self.database_url:
|
||||
problems.append("DATABASE_URL 仍在用开发默认口令")
|
||||
if self.sms_provider == "dev" and not self.internal_test_login:
|
||||
if self.sms_login_enabled and self.sms_provider == "dev":
|
||||
problems.append(
|
||||
"生产环境不允许 SMS_PROVIDER=dev(任意验证码可登录);"
|
||||
"内网试用确需固定验证码时显式设置 INTERNAL_TEST_LOGIN=true"
|
||||
"短信验证码登录开着(SMS_LOGIN_ENABLED=true),但 SMS_PROVIDER=dev 只会发固定验证码"
|
||||
"(等于知道手机号就能进);接真短信通道之前请把 SMS_LOGIN_ENABLED 关掉"
|
||||
)
|
||||
if problems:
|
||||
raise RuntimeError("配置校验失败:" + ";".join(problems))
|
||||
|
||||
@@ -14,6 +14,7 @@ from fastapi import Depends, Header, Path
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from .config import get_settings
|
||||
from .db import bind_tenant, bind_user, get_session
|
||||
from .errors import AppError, ErrorCode
|
||||
from .logging_setup import user_id_ctx, workspace_id_ctx
|
||||
@@ -112,3 +113,18 @@ def require_role(minimum: str):
|
||||
|
||||
OwnerDep = Annotated[TenantCtx, Depends(require_role("owner"))]
|
||||
AdminDep = Annotated[TenantCtx, Depends(require_role("admin"))]
|
||||
|
||||
|
||||
async def platform_admin(user: CurrentUser) -> AuthUser:
|
||||
"""平台管理员:能改 SMTP 这类**跨租户**的系统设置。
|
||||
|
||||
刻意不复用工作区角色:某个工作区的 owner 只说明他管得了自己的工作区,
|
||||
不代表他能看到整台服务的发信账号(那里面存着别人的授权码)。
|
||||
名单来自配置 `PLATFORM_ADMIN_PHONES`,换人不用发版。
|
||||
"""
|
||||
if user.phone not in get_settings().platform_admin_phones:
|
||||
raise AppError(ErrorCode.INSUFFICIENT_ROLE, "只有平台管理员能改系统设置")
|
||||
return user
|
||||
|
||||
|
||||
PlatformAdminDep = Annotated[AuthUser, Depends(platform_admin)]
|
||||
|
||||
@@ -29,6 +29,10 @@ class ErrorCode(IntEnum):
|
||||
PASSWORD_NOT_SET = 2014
|
||||
PASSWORD_WEAK = 2015
|
||||
LOGIN_TOO_MANY = 2016
|
||||
LOGIN_METHOD_DISABLED = 2017
|
||||
EMAIL_INVALID = 2018
|
||||
EMAIL_TAKEN = 2019
|
||||
MAIL_SEND_FAILED = 2020
|
||||
|
||||
# 3xxx 租户 / 权限
|
||||
WORKSPACE_NOT_FOUND = 3001
|
||||
@@ -94,6 +98,10 @@ _HTTP_STATUS: dict[ErrorCode, int] = {
|
||||
ErrorCode.PASSWORD_NOT_SET: 400,
|
||||
ErrorCode.PASSWORD_WEAK: 422,
|
||||
ErrorCode.LOGIN_TOO_MANY: 429,
|
||||
ErrorCode.LOGIN_METHOD_DISABLED: 403,
|
||||
ErrorCode.EMAIL_INVALID: 422,
|
||||
ErrorCode.EMAIL_TAKEN: 409,
|
||||
ErrorCode.MAIL_SEND_FAILED: 502,
|
||||
ErrorCode.WORKSPACE_NOT_FOUND: 404,
|
||||
ErrorCode.NOT_A_MEMBER: 403,
|
||||
ErrorCode.INSUFFICIENT_ROLE: 403,
|
||||
|
||||
+4
-2
@@ -17,6 +17,7 @@ from starlette.staticfiles import StaticFiles
|
||||
from starlette.types import Scope
|
||||
|
||||
from .api import app_release, attachments, auth, items, knowledge, tickets, workspaces
|
||||
from .api import settings as settings_api
|
||||
from .config import get_settings
|
||||
from .db import SessionFactory, current_app_role, engine
|
||||
from .errors import AppError, ErrorCode, http_status_for
|
||||
@@ -31,9 +32,9 @@ logger = get_logger("app")
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
settings.validate_runtime()
|
||||
if settings.env == "prod" and settings.internal_test_login:
|
||||
if settings.env == "prod" and settings.sms_login_enabled:
|
||||
logger.warning(
|
||||
"内测登录已开启:任意手机号可用固定验证码登录,只能在内网访问,切勿暴露公网",
|
||||
"短信验证码登录开着,只能配真通道;用 dev 固定码等于知道手机号就能进任何账号",
|
||||
extra={"extra_fields": {"sms_provider": settings.sms_provider}},
|
||||
)
|
||||
logger.info(
|
||||
@@ -116,6 +117,7 @@ app.include_router(tickets.notify_router, prefix=settings.api_prefix)
|
||||
app.include_router(knowledge.router, prefix=settings.api_prefix)
|
||||
app.include_router(attachments.router, prefix=settings.api_prefix)
|
||||
app.include_router(app_release.router, prefix=settings.api_prefix)
|
||||
app.include_router(settings_api.router, prefix=settings.api_prefix)
|
||||
|
||||
|
||||
@app.get("/health", tags=["运维"], summary="存活探针")
|
||||
|
||||
@@ -47,6 +47,25 @@ class AppUser(Base, Timestamps):
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
|
||||
# 登录密码(scrypt 串,见 security.hash_password)。为空表示只用验证码登录。
|
||||
password_hash: Mapped[str | None] = mapped_column(String(255))
|
||||
# 邮箱:既是「邮箱 + 密码」登录的账号名,也是以后找回密码/发通知的去处。
|
||||
# 可空(老用户还没有),唯一性由迁移里的 lower(email) 部分唯一索引保证。
|
||||
email: Mapped[str | None] = mapped_column(String(255))
|
||||
|
||||
|
||||
class SystemSetting(Base, Timestamps):
|
||||
"""平台级键值设置(SMTP 等),**不挂租户、没有 RLS**。
|
||||
|
||||
刻意不做成租户表:发信账号是整台服务共用的,某个工作区的 owner 不该看到
|
||||
平台的 SMTP 授权码。写权限由 API 层的「平台管理员」依赖把关(见 deps.py)。
|
||||
"""
|
||||
|
||||
__tablename__ = "system_setting"
|
||||
|
||||
key: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
value: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
||||
updated_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||
PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="SET NULL")
|
||||
)
|
||||
|
||||
|
||||
class Workspace(Base, Timestamps):
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -13,6 +13,9 @@ os.environ["ENV"] = "test"
|
||||
os.environ.setdefault("SMS_DEV_CODE", "123456")
|
||||
# 测试里关掉发码节流,否则"同一手机号再登录一次"要等 60 秒
|
||||
os.environ.setdefault("SMS_THROTTLE_SECONDS", "0")
|
||||
# 线上默认关掉了短信验证码登录(固定码等于谁都能进),但测试夹具 `login()` 靠它批量造用户。
|
||||
# 这里显式打开;想验证"关掉之后回 2017"的用例自己 monkeypatch 一下 settings 即可。
|
||||
os.environ.setdefault("SMS_LOGIN_ENABLED", "true")
|
||||
# 测试库由 config 的 test_database_url / test_migration_database_url 决定(见 backend/.env),
|
||||
# 这里不再改 os.environ —— 配置对象自己会读 .env。
|
||||
|
||||
@@ -36,7 +39,7 @@ from app.stores import reset_store_for_tests
|
||||
settings = get_settings()
|
||||
DEV_CODE = settings.sms_dev_code
|
||||
|
||||
_TABLES = "audit_log, invitation, member, workspace, app_user"
|
||||
_TABLES = "system_setting, audit_log, invitation, member, workspace, app_user"
|
||||
|
||||
|
||||
def _run_migrations() -> None:
|
||||
|
||||
@@ -29,9 +29,11 @@ async def test_me_reports_profile_without_leaking_password(client: AsyncClient)
|
||||
assert set(data["user"]) == {
|
||||
"id",
|
||||
"phone",
|
||||
"email",
|
||||
"nickname",
|
||||
"avatar_url",
|
||||
"has_password",
|
||||
"platform_admin",
|
||||
"created_at",
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""生产配置校验:固定验证码必须显式开关,否则启动即失败。
|
||||
|
||||
这两条是「部署纪律」的守门员:生产一旦误用 dev 验证码,等于任意手机号可登录。
|
||||
现在的规则更简单——**短信验证码登录默认关着**,关着时 SMS_PROVIDER 是 dev 也无所谓,
|
||||
因为那条路根本不通;只有在 SMS_LOGIN_ENABLED=true 时才要求真通道。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -24,14 +26,18 @@ def _prod(**overrides: object) -> Settings:
|
||||
return Settings(**values)
|
||||
|
||||
|
||||
def test_prod_rejects_dev_sms_without_switch() -> None:
|
||||
with pytest.raises(RuntimeError, match="SMS_PROVIDER=dev"):
|
||||
_prod(sms_provider="dev").validate_runtime()
|
||||
def test_prod_rejects_dev_sms_when_login_enabled() -> None:
|
||||
with pytest.raises(RuntimeError, match="SMS_LOGIN_ENABLED"):
|
||||
_prod(sms_provider="dev", sms_login_enabled=True).validate_runtime()
|
||||
|
||||
|
||||
def test_prod_allows_dev_sms_with_internal_test_login() -> None:
|
||||
# 内测模式:显式开关后放行,启动时 main.lifespan 会打 WARNING
|
||||
_prod(sms_provider="dev", internal_test_login=True).validate_runtime()
|
||||
def test_prod_is_fine_with_sms_login_closed() -> None:
|
||||
# 默认就是这条:短信登录关着,provider 配成 dev 也不会有人能用它进来
|
||||
_prod(sms_provider="dev", sms_login_enabled=False).validate_runtime()
|
||||
|
||||
|
||||
def test_prod_allows_real_sms_provider_when_enabled() -> None:
|
||||
_prod(sms_provider="aliyun", sms_login_enabled=True).validate_runtime()
|
||||
|
||||
|
||||
def test_prod_still_rejects_weak_jwt_secret() -> None:
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"""登录方式的开关,以及用邮箱登录。
|
||||
|
||||
三条是这个功能的重点,也是测试的重点:
|
||||
|
||||
1. **短信验证码能整体关掉**。关掉后连"发码"这一步都不该通——否则等于留了个后门:
|
||||
内置验证码仍然对得上,只是没人去点。
|
||||
2. **邮箱是账号名,不是联系方式**:绑定之后能用它登录,改绑立刻生效、解绑立刻失效。
|
||||
3. **邮箱唯一且大小写不敏感**:`Shufen@126.com` 和 `shufen@126.com` 是同一个人,
|
||||
不能让第二个账号绑走。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
from .conftest import login, unwrap
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
async def _set_password(client: AsyncClient, user: dict, password: str) -> None:
|
||||
unwrap(
|
||||
await client.post(
|
||||
"/api/v1/auth/password", json={"password": password}, headers=user["headers"]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def _bind_email(client: AsyncClient, user: dict, email: str) -> dict:
|
||||
return unwrap(
|
||||
await client.patch("/api/v1/auth/me", json={"email": email}, headers=user["headers"])
|
||||
)
|
||||
|
||||
|
||||
async def test_methods_lists_what_is_open(client: AsyncClient) -> None:
|
||||
data = unwrap(await client.get("/api/v1/auth/methods"))
|
||||
assert data["password"] is True
|
||||
assert data["email_password"] is True
|
||||
# 测试环境把短信开着(夹具要靠它造用户);线上默认是 false
|
||||
assert data["sms_code"] is True
|
||||
|
||||
|
||||
async def test_sms_login_can_be_closed(client: AsyncClient, monkeypatch) -> None:
|
||||
monkeypatch.setattr(settings, "sms_login_enabled", False)
|
||||
|
||||
methods = unwrap(await client.get("/api/v1/auth/methods"))
|
||||
assert methods["sms_code"] is False
|
||||
|
||||
sent = await client.post("/api/v1/auth/sms/send", json={"phone": "13800138000"})
|
||||
assert sent.json()["code"] == 2017
|
||||
|
||||
# 就算手里还捏着旧验证码,登录这一步也必须关死
|
||||
logged = await client.post(
|
||||
"/api/v1/auth/login", json={"phone": "13800138000", "code": settings.sms_dev_code}
|
||||
)
|
||||
assert logged.json()["code"] == 2017
|
||||
|
||||
|
||||
async def test_bind_email_and_login_with_it(client: AsyncClient) -> None:
|
||||
user = await login(client)
|
||||
await _set_password(client, user, "Dadao2026pw")
|
||||
|
||||
data = await _bind_email(client, user, " Shufen@126.com ")
|
||||
# 存的是小写,避免大小写绕出第二个账号
|
||||
assert data["user"]["email"] == "shufen@126.com"
|
||||
|
||||
logged = unwrap(
|
||||
await client.post(
|
||||
"/api/v1/auth/login/password",
|
||||
json={"email": "SHUFEN@126.COM", "password": "Dadao2026pw"},
|
||||
)
|
||||
)
|
||||
assert logged["user"]["id"] == user["user_id"]
|
||||
|
||||
|
||||
async def test_email_login_rejects_wrong_password(client: AsyncClient) -> None:
|
||||
user = await login(client)
|
||||
await _set_password(client, user, "Dadao2026pw")
|
||||
await _bind_email(client, user, "shufen@126.com")
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/login/password", json={"email": "shufen@126.com", "password": "nope-nope-1"}
|
||||
)
|
||||
assert resp.json()["code"] == 2013
|
||||
|
||||
|
||||
async def test_email_is_validated_and_unique(client: AsyncClient) -> None:
|
||||
first = await login(client)
|
||||
bad = await client.patch(
|
||||
"/api/v1/auth/me", json={"email": "not-an-email"}, headers=first["headers"]
|
||||
)
|
||||
assert bad.json()["code"] == 2018
|
||||
|
||||
await _bind_email(client, first, "shufen@126.com")
|
||||
|
||||
second = await login(client)
|
||||
taken = await client.patch(
|
||||
"/api/v1/auth/me", json={"email": "SHUFEN@126.com"}, headers=second["headers"]
|
||||
)
|
||||
assert taken.json()["code"] == 2019
|
||||
|
||||
|
||||
async def test_unbind_email_then_login_by_email_fails(client: AsyncClient) -> None:
|
||||
user = await login(client)
|
||||
await _set_password(client, user, "Dadao2026pw")
|
||||
await _bind_email(client, user, "shufen@126.com")
|
||||
|
||||
cleared = await _bind_email(client, user, "")
|
||||
assert cleared["user"]["email"] is None
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/login/password",
|
||||
json={"email": "shufen@126.com", "password": "Dadao2026pw"},
|
||||
)
|
||||
assert resp.json()["code"] == 2013
|
||||
|
||||
|
||||
async def test_phone_login_still_works(client: AsyncClient) -> None:
|
||||
"""老客户端/安卓只发 phone,这条路不能因为加了邮箱就断。"""
|
||||
user = await login(client)
|
||||
await _set_password(client, user, "Dadao2026pw")
|
||||
|
||||
logged = unwrap(
|
||||
await client.post(
|
||||
"/api/v1/auth/login/password",
|
||||
json={"phone": user["phone"], "password": "Dadao2026pw"},
|
||||
)
|
||||
)
|
||||
assert logged["user"]["id"] == user["user_id"]
|
||||
@@ -0,0 +1,130 @@
|
||||
"""系统设置(SMTP):只有平台管理员能改,授权码不回显,改完立刻生效。
|
||||
|
||||
为什么值得单独测:这是全站唯一一个**跨租户**的写接口。租户隔离测试管不到它,
|
||||
一旦漏了权限判断,任何登录用户都能改掉全平台发信账号(等于劫持找回密码邮件)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.config import get_settings
|
||||
from app.services import mail
|
||||
|
||||
from .conftest import login, unwrap
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
SMTP = {
|
||||
"host": "smtp.126.com",
|
||||
"port": 465,
|
||||
"username": "shufenhf@126.com",
|
||||
"password": "CH46ngqQ7Z2XE2eZ",
|
||||
"from_name": "达道Task",
|
||||
"use_ssl": True,
|
||||
}
|
||||
|
||||
|
||||
async def _admin(client: AsyncClient) -> dict:
|
||||
"""白名单里的手机号登录,就是平台管理员。"""
|
||||
user = await login(client, phone="13605513683")
|
||||
return user
|
||||
|
||||
|
||||
async def test_settings_require_platform_admin(client: AsyncClient, monkeypatch) -> None:
|
||||
monkeypatch.setattr(settings, "platform_admin_phones", ["13605513683"])
|
||||
outsider = await login(client)
|
||||
|
||||
read = await client.get("/api/v1/settings/smtp", headers=outsider["headers"])
|
||||
assert read.json()["code"] == 3004
|
||||
|
||||
write = await client.put("/api/v1/settings/smtp", json=SMTP, headers=outsider["headers"])
|
||||
assert write.json()["code"] == 3004
|
||||
|
||||
|
||||
async def test_platform_admin_flag_comes_back_in_me(client: AsyncClient, monkeypatch) -> None:
|
||||
monkeypatch.setattr(settings, "platform_admin_phones", ["13605513683"])
|
||||
admin = await _admin(client)
|
||||
outsider = await login(client)
|
||||
|
||||
me_admin = unwrap(await client.get("/api/v1/auth/me", headers=admin["headers"]))
|
||||
me_outsider = unwrap(await client.get("/api/v1/auth/me", headers=outsider["headers"]))
|
||||
assert me_admin["user"]["platform_admin"] is True
|
||||
assert me_outsider["user"]["platform_admin"] is False
|
||||
|
||||
|
||||
async def test_save_smtp_hides_password_and_persists(client: AsyncClient, monkeypatch) -> None:
|
||||
monkeypatch.setattr(settings, "platform_admin_phones", ["13605513683"])
|
||||
admin = await _admin(client)
|
||||
|
||||
empty = unwrap(await client.get("/api/v1/settings/smtp", headers=admin["headers"]))
|
||||
assert empty["configured"] is False
|
||||
|
||||
saved = unwrap(await client.put("/api/v1/settings/smtp", json=SMTP, headers=admin["headers"]))
|
||||
assert saved["configured"] is True
|
||||
assert saved["source"] == "db"
|
||||
assert saved["host"] == "smtp.126.com"
|
||||
assert saved["password_set"] is True
|
||||
# 授权码一个字节都不能回给页面
|
||||
assert "password" not in saved
|
||||
assert "CH46ngqQ7Z2XE2eZ" not in str(saved)
|
||||
|
||||
again = unwrap(await client.get("/api/v1/settings/smtp", headers=admin["headers"]))
|
||||
assert again["username"] == "shufenhf@126.com"
|
||||
assert again["port"] == 465
|
||||
|
||||
|
||||
async def test_saving_without_password_keeps_old_one(client: AsyncClient, monkeypatch) -> None:
|
||||
"""授权码留空 = 沿用原来的。否则每次改端口都得重新粘贴一遍授权码。"""
|
||||
monkeypatch.setattr(settings, "platform_admin_phones", ["13605513683"])
|
||||
admin = await _admin(client)
|
||||
await client.put("/api/v1/settings/smtp", json=SMTP, headers=admin["headers"])
|
||||
|
||||
payload = {**SMTP, "port": 587, "use_ssl": False, "password": ""}
|
||||
resp = await client.put("/api/v1/settings/smtp", json=payload, headers=admin["headers"])
|
||||
saved = unwrap(resp)
|
||||
assert saved["port"] == 587
|
||||
assert saved["password_set"] is True
|
||||
|
||||
from app.db import SessionFactory
|
||||
|
||||
async with SessionFactory() as session:
|
||||
cfg = await mail.load_config(session)
|
||||
assert cfg is not None
|
||||
assert cfg.password == "CH46ngqQ7Z2XE2eZ"
|
||||
assert cfg.port == 587
|
||||
|
||||
|
||||
async def test_test_mail_reports_missing_config(client: AsyncClient, monkeypatch) -> None:
|
||||
monkeypatch.setattr(settings, "platform_admin_phones", ["13605513683"])
|
||||
admin = await _admin(client)
|
||||
resp = await client.post(
|
||||
"/api/v1/settings/smtp/test", json={"to": "steven@example.com"}, headers=admin["headers"]
|
||||
)
|
||||
assert resp.json()["code"] == 2020
|
||||
|
||||
|
||||
async def test_test_mail_actually_sends(client: AsyncClient, monkeypatch) -> None:
|
||||
"""真发信在这里被替换掉——测的是"取到配置、走对收件人",不是 SMTP 本身。"""
|
||||
monkeypatch.setattr(settings, "platform_admin_phones", ["13605513683"])
|
||||
admin = await _admin(client)
|
||||
await client.put("/api/v1/settings/smtp", json=SMTP, headers=admin["headers"])
|
||||
|
||||
sent: list[tuple] = []
|
||||
|
||||
def fake_send(cfg, to, subject, text, html): # noqa: ANN001 - 与真实签名一致
|
||||
sent.append((cfg.host, cfg.username, to, subject, text))
|
||||
|
||||
monkeypatch.setattr(mail, "_send_sync", fake_send)
|
||||
|
||||
data = unwrap(
|
||||
await client.post(
|
||||
"/api/v1/settings/smtp/test",
|
||||
json={"to": "steven@example.com"},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
)
|
||||
assert data["sent"] is True
|
||||
assert data["to"] == "steven@example.com"
|
||||
assert sent and sent[0][0] == "smtp.126.com"
|
||||
assert sent[0][1] == "shufenhf@126.com"
|
||||
Reference in New Issue
Block a user