用户要求:内测的固定验证码 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:
+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})
|
||||
Reference in New Issue
Block a user