CI / backend (push) Canceled after 0s
短信登录关掉后,生产没有任何注册入口——这一版补上最普通的那条路:
填邮箱、收验证码、设密码,注册完直接进首页。
先发码再注册,不是"注册时顺带发信"
POST /auth/email/code 只做三件事:查重、限流、发码。
填完密码才被告知"这个邮箱注册过了"最气人,所以占用(2019)在发码前就回。
邮箱既是账号名也是唯一联系方式,所以必须真验一次码
验证通过时间落 app_user.email_verified_at,/auth/me 回 email_verified。
"随手填一个"会让账号变成没人能找回的孤岛。
app_user.phone 改为可空(迁移 0011)
邮箱注册的账号本来就没有手机号。user.phone 对这类账号回空串,
成员列表同理,不再伪装成一串星号。
防刷
同邮箱 60 秒一次(2022)、验证码 10 分钟有效、错 5 次即作废(2021)。
6 位数可以被刮,不设上限等于把注册口子交给脚本。
本机联调开关 EMAIL_CODE_DEV_ECHO
为 true 时把码回给调用方且不发真信——否则每跑一次 e2e 就往真实收件箱塞一封。
生产打开会让 validate_runtime() 拒绝启动。
前端
新增 /register(邮箱、验证码、密码、确认、昵称;60 秒倒计时;
服务端关着注册时整页只说"联系管理员开通"),登录页按 /auth/methods
决定要不要显示「还没有账号?用邮箱注册」。
验证
pytest 139 passed、ruff check / format 全绿
tsc --noEmit 0、vite build 通过
e2e register.mjs 7 项:登录页入口 → 拿码 → 注册落进首页 → 退出用邮箱+密码登回来
→ 同一邮箱再发码回 2019 → 控制台零报错
e2e login-email-smtp.mjs 15 项回归通过(含真发一封测试信)
e2e account.mjs 14 项、knowledge.mjs 全绿
顺带修的两处测试
test_account 的字段白名单补 email_verified(接口确实多了这个字段);
test_config_prod 用 Settings(_env_file=None, ...) 构造,别被本机 .env 里的
EMAIL_CODE_DEV_ECHO=true 带偏——那个开关在生产是硬错误,本机却是跑 e2e 必需的。
464 lines
17 KiB
Python
464 lines
17 KiB
Python
"""认证:手机号 / 邮箱 + 密码,邮箱验证码注册,手机验证码可选。
|
||
|
||
当前**默认关掉短信验证码**(`SMS_LOGIN_ENABLED=false`):内测阶段的固定验证码
|
||
等于"知道手机号就能进任意账号"。关掉后 `/auth/sms/send` 与 `/auth/login` 直接
|
||
回 2017,登录走「手机号 或 邮箱 + 密码」。登录页先问 `/auth/methods`,只显示
|
||
服务端真开着的登录方式,免得点进去才报错。
|
||
|
||
新用户走「邮箱 + 验证码」注册(`POST /auth/email/code` → `POST /auth/register`):
|
||
短信一关,就没有别的注册入口了。注册出来的账号没有手机号,`app_user.phone` 因此可空。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import hmac
|
||
import re
|
||
import secrets
|
||
from datetime import datetime, timezone
|
||
|
||
from fastapi import APIRouter
|
||
from pydantic import BaseModel, Field, model_validator
|
||
from sqlalchemy import func, select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from ..config import get_settings
|
||
from ..deps import CurrentUser, SessionDep
|
||
from ..errors import AppError, ErrorCode
|
||
from ..models import AppUser, Member, Workspace
|
||
from ..schemas import ok
|
||
from ..security import (
|
||
create_token_pair,
|
||
decode_token,
|
||
hash_password,
|
||
password_problem,
|
||
verify_password,
|
||
)
|
||
from ..services import mail
|
||
from ..stores import (
|
||
email_code_key,
|
||
email_throttle_key,
|
||
email_tries_key,
|
||
get_store,
|
||
sms_code_key,
|
||
sms_throttle_key,
|
||
)
|
||
|
||
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
|
||
PASSWORD_FAIL_TTL = 900
|
||
|
||
|
||
def mask_phone(phone: str | None) -> str:
|
||
if not phone:
|
||
return ""
|
||
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,
|
||
"email_verified": user.email_verified_at is not None,
|
||
"nickname": user.nickname,
|
||
"avatar_url": user.avatar_url,
|
||
"has_password": bool(user.password_hash),
|
||
"platform_admin": bool(user.phone) and user.phone in settings.platform_admin_phones,
|
||
"created_at": user.created_at,
|
||
}
|
||
|
||
|
||
def _tokens_out(tokens) -> dict:
|
||
return {
|
||
"access_token": tokens.access_token,
|
||
"refresh_token": tokens.refresh_token,
|
||
"token_type": "bearer",
|
||
"expires_in": tokens.expires_in,
|
||
}
|
||
|
||
|
||
class SendCodeIn(BaseModel):
|
||
phone: str = Field(pattern=PHONE_PATTERN, examples=["13800138000"])
|
||
|
||
|
||
class LoginIn(BaseModel):
|
||
phone: str = Field(pattern=PHONE_PATTERN)
|
||
code: str = Field(min_length=4, max_length=8)
|
||
|
||
|
||
class PasswordLoginIn(BaseModel):
|
||
"""手机号或邮箱 + 密码。两个都给也行,手机号优先(老客户端只发 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):
|
||
"""old_password 只在「已经设过密码」时必填;第一次设密码不需要。"""
|
||
|
||
password: str = Field(min_length=1, max_length=64)
|
||
old_password: str | None = Field(default=None, max_length=64)
|
||
|
||
|
||
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,
|
||
"email_register": settings.email_register_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)
|
||
if hits > 1:
|
||
raise AppError(
|
||
ErrorCode.SMS_TOO_FREQUENT,
|
||
f"发送过于频繁,请 {settings.sms_throttle_seconds} 秒后重试",
|
||
)
|
||
|
||
if settings.sms_provider == "dev":
|
||
code = settings.sms_dev_code
|
||
else:
|
||
code = f"{secrets.randbelow(1_000_000):06d}"
|
||
# TODO(M0 遗留):接入阿里云/腾讯云短信;当前直接在响应里不回显验证码
|
||
raise AppError(ErrorCode.INTERNAL, "短信通道尚未接入", http_status=501)
|
||
|
||
await store.set(sms_code_key(body.phone), code, settings.sms_code_ttl_seconds)
|
||
return ok({"sent": True, "expires_in": settings.sms_code_ttl_seconds, "dev_code": code})
|
||
|
||
|
||
@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:
|
||
raise AppError(ErrorCode.SMS_CODE_INVALID, "验证码已过期,请重新获取")
|
||
if not hmac.compare_digest(stored, body.code):
|
||
raise AppError(ErrorCode.SMS_CODE_INVALID, "验证码错误")
|
||
await store.delete(sms_code_key(body.phone))
|
||
|
||
user = (
|
||
await session.execute(select(AppUser).where(AppUser.phone == body.phone))
|
||
).scalar_one_or_none()
|
||
if user is None:
|
||
user = AppUser(phone=body.phone, nickname=f"用户{body.phone[-4:]}")
|
||
session.add(user)
|
||
await session.flush()
|
||
|
||
tokens = create_token_pair(user.id)
|
||
return ok({"user": _user_out(user), "tokens": _tokens_out(tokens)})
|
||
|
||
|
||
@router.post("/login/password", summary="手机号 / 邮箱 + 密码登录")
|
||
async def login_password(body: PasswordLoginIn, session: SessionDep) -> dict:
|
||
"""手机号或邮箱 + 密码登录。
|
||
|
||
三个细节:
|
||
- **不区分"没这个人"和"密码错"**,同一个错误码,别让登录接口变成账号探测工具。
|
||
- 连错 PASSWORD_FAIL_LIMIT 次锁 PASSWORD_FAIL_TTL 秒;成功即清零,正常用户不会感知。
|
||
失败计数按"填的那个账号"记,换手机号/邮箱轮着试也照样累计。
|
||
- 邮箱大小写不敏感(存的时候已经统一小写,这里再 lower 一次兜住历史数据)。
|
||
"""
|
||
store = get_store()
|
||
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} 分钟后再试",
|
||
)
|
||
|
||
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, hint)
|
||
|
||
if user.status != "active":
|
||
raise AppError(ErrorCode.ACCOUNT_DISABLED, "账号已停用")
|
||
|
||
await store.delete(fail_key)
|
||
tokens = create_token_pair(user.id)
|
||
return ok({"user": _user_out(user), "tokens": _tokens_out(tokens)})
|
||
|
||
|
||
@router.post("/refresh", summary="刷新访问令牌")
|
||
async def refresh(body: RefreshIn) -> dict:
|
||
user_id = decode_token(body.refresh_token, "refresh")
|
||
tokens = create_token_pair(user_id)
|
||
# 这里的形状保持扁平(不带 tokens 信封),老客户端与安卓端都按这个取
|
||
return ok(_tokens_out(tokens))
|
||
|
||
|
||
@router.get("/me", summary="当前用户与所属工作区")
|
||
async def me(user: CurrentUser, session: SessionDep) -> dict:
|
||
row = await session.get(AppUser, user.id)
|
||
if row is None:
|
||
raise AppError(ErrorCode.UNAUTHORIZED, "账号不存在")
|
||
|
||
rows = (
|
||
await session.execute(
|
||
select(Workspace, Member.role)
|
||
.join(Member, Member.workspace_id == Workspace.id)
|
||
.where(Member.user_id == user.id, Member.status == "active")
|
||
.order_by(Workspace.created_at.desc())
|
||
)
|
||
).all()
|
||
|
||
return ok(
|
||
{
|
||
"user": _user_out(row),
|
||
"workspaces": [
|
||
{
|
||
"id": str(ws.id),
|
||
"name": ws.name,
|
||
"slug": ws.slug,
|
||
"plan": ws.plan,
|
||
"role": role,
|
||
"created_at": ws.created_at,
|
||
}
|
||
for ws, role in rows
|
||
],
|
||
}
|
||
)
|
||
|
||
|
||
@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:
|
||
raise AppError(ErrorCode.UNAUTHORIZED, "账号不存在")
|
||
|
||
if body.nickname is not None:
|
||
nickname = body.nickname.strip()
|
||
if not nickname:
|
||
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)})
|
||
|
||
|
||
@router.post("/password", summary="设置或修改登录密码")
|
||
async def set_password(body: PasswordIn, user: CurrentUser, session: SessionDep) -> dict:
|
||
"""已经设过密码的,必须先给旧密码——否则拿到一个没锁屏的浏览器就能把密码换掉。"""
|
||
row = await session.get(AppUser, user.id)
|
||
if row is None:
|
||
raise AppError(ErrorCode.UNAUTHORIZED, "账号不存在")
|
||
|
||
if row.password_hash:
|
||
if not body.old_password:
|
||
raise AppError(ErrorCode.PASSWORD_INVALID, "请输入当前密码")
|
||
if not verify_password(body.old_password, row.password_hash):
|
||
raise AppError(ErrorCode.PASSWORD_INVALID, "当前密码不对")
|
||
if hmac.compare_digest(body.old_password, body.password):
|
||
raise AppError(ErrorCode.PASSWORD_WEAK, "新密码不能和当前密码一样")
|
||
|
||
problem = password_problem(body.password)
|
||
if problem:
|
||
raise AppError(ErrorCode.PASSWORD_WEAK, problem)
|
||
|
||
row.password_hash = hash_password(body.password)
|
||
await session.flush()
|
||
return ok({"has_password": True})
|
||
|
||
|
||
# ── 邮箱注册 ────────────────────────────────────────────
|
||
class EmailCodeIn(BaseModel):
|
||
email: str = Field(max_length=255, examples=["someone@example.com"])
|
||
|
||
|
||
class RegisterIn(BaseModel):
|
||
"""邮箱 + 验证码 + 密码,一步把账号建出来。
|
||
|
||
昵称不填就取邮箱 @ 前面那段,别让新用户第一眼看到一个空名字。
|
||
"""
|
||
|
||
email: str = Field(max_length=255)
|
||
code: str = Field(min_length=4, max_length=8)
|
||
password: str = Field(min_length=1, max_length=64)
|
||
nickname: str | None = Field(default=None, max_length=32)
|
||
|
||
|
||
async def _email_taken(session: AsyncSession, email: str) -> bool:
|
||
stmt = select(AppUser.id).where(func.lower(AppUser.email) == email)
|
||
return (await session.execute(stmt)).first() is not None
|
||
|
||
|
||
@router.post("/email/code", summary="发送邮箱注册验证码")
|
||
async def send_email_code(body: EmailCodeIn, session: SessionDep) -> dict:
|
||
"""发码前先查邮箱是否已被占用:让用户填完密码才发现"这个邮箱注册过了"最气人。"""
|
||
if not settings.email_register_enabled:
|
||
raise AppError(
|
||
ErrorCode.LOGIN_METHOD_DISABLED,
|
||
"邮箱注册暂未开放,请联系管理员开通账号",
|
||
)
|
||
|
||
email = normalize_email(body.email)
|
||
if not EMAIL_RE.match(email):
|
||
raise AppError(ErrorCode.EMAIL_INVALID, "邮箱格式不对")
|
||
if await _email_taken(session, email):
|
||
raise AppError(ErrorCode.EMAIL_TAKEN, "这个邮箱已经注册过了,直接登录即可")
|
||
|
||
store = get_store()
|
||
if settings.email_code_throttle_seconds > 0:
|
||
hits = await store.incr(email_throttle_key(email), settings.email_code_throttle_seconds)
|
||
if hits > 1:
|
||
raise AppError(
|
||
ErrorCode.EMAIL_CODE_TOO_FREQUENT,
|
||
f"发送过于频繁,请 {settings.email_code_throttle_seconds} 秒后重试",
|
||
)
|
||
|
||
code = f"{secrets.randbelow(1_000_000):06d}"
|
||
await store.set(email_code_key(email), code, settings.email_code_ttl_seconds)
|
||
await store.delete(email_tries_key(email))
|
||
|
||
minutes = max(1, settings.email_code_ttl_seconds // 60)
|
||
if settings.email_code_dev_echo:
|
||
# 本机联调:不发真信,直接把码回给调用方(生产打开会被 validate_runtime 拒掉)
|
||
return ok(
|
||
{
|
||
"sent": True,
|
||
"expires_in": settings.email_code_ttl_seconds,
|
||
"dev_code": code,
|
||
}
|
||
)
|
||
|
||
await mail.send_verify_code(session, to=email, code=code, minutes=minutes)
|
||
return ok({"sent": True, "expires_in": settings.email_code_ttl_seconds})
|
||
|
||
|
||
@router.post("/register", summary="邮箱注册(验证码 + 密码)")
|
||
async def register(body: RegisterIn, session: SessionDep) -> dict:
|
||
"""邮箱注册:验码 → 建账号 → 直接发令牌,注册完就是登录状态。
|
||
|
||
账号没有手机号(`phone` 为空),所以邮件是唯一联系方式——这也是为什么注册必须
|
||
真验一次码,而不是"填个邮箱就算"。
|
||
"""
|
||
if not settings.email_register_enabled:
|
||
raise AppError(
|
||
ErrorCode.LOGIN_METHOD_DISABLED,
|
||
"邮箱注册暂未开放,请联系管理员开通账号",
|
||
)
|
||
|
||
email = normalize_email(body.email)
|
||
if not EMAIL_RE.match(email):
|
||
raise AppError(ErrorCode.EMAIL_INVALID, "邮箱格式不对")
|
||
|
||
store = get_store()
|
||
stored = await store.get(email_code_key(email))
|
||
if not stored:
|
||
raise AppError(ErrorCode.EMAIL_CODE_INVALID, "验证码已过期,请重新获取")
|
||
|
||
tries = await store.incr(email_tries_key(email), settings.email_code_ttl_seconds)
|
||
if tries > settings.email_code_max_attempts:
|
||
await store.delete(email_code_key(email))
|
||
raise AppError(ErrorCode.EMAIL_CODE_INVALID, "验证码错太多次,请重新获取")
|
||
|
||
if not hmac.compare_digest(stored, body.code.strip()):
|
||
raise AppError(ErrorCode.EMAIL_CODE_INVALID, "验证码不对")
|
||
|
||
problem = password_problem(body.password)
|
||
if problem:
|
||
raise AppError(ErrorCode.PASSWORD_WEAK, problem)
|
||
|
||
# 拿到验证码之后这段时间里,别人可能已经用同一个邮箱注册完了
|
||
if await _email_taken(session, email):
|
||
raise AppError(ErrorCode.EMAIL_TAKEN, "这个邮箱已经注册过了,直接登录即可")
|
||
|
||
nickname = (body.nickname or "").strip() or email.split("@", 1)[0][:32]
|
||
user = AppUser(
|
||
email=email,
|
||
phone=None,
|
||
nickname=nickname,
|
||
password_hash=hash_password(body.password),
|
||
email_verified_at=datetime.now(timezone.utc),
|
||
status="active",
|
||
)
|
||
session.add(user)
|
||
await session.flush()
|
||
|
||
await store.delete(email_code_key(email))
|
||
await store.delete(email_tries_key(email))
|
||
await store.delete(email_throttle_key(email))
|
||
|
||
tokens = create_token_pair(user.id)
|
||
return ok({"user": _user_out(user), "tokens": _tokens_out(tokens)})
|