diff --git a/2.Design/API.md b/2.Design/API.md index 1f8cc2d..c85675e 100644 --- a/2.Design/API.md +++ b/2.Design/API.md @@ -37,6 +37,8 @@ | 2018 | 422 | 邮箱格式不对 | 就地提示 | | 2019 | 409 | 邮箱已被别的账号占用 | 换一个 | | 2020 | 502 | 发信失败(SMTP 认证/收件人/网络) | 去「系统设置」核对授权码 | +| 2021 | 400 | 邮箱验证码不对 / 已过期 / 试错次数用尽 | 重新获取验证码 | +| 2022 | 429 | 验证码发送过于频繁 | 等 60 秒再发 | | 3001 | 404 | 工作区不存在 | — | | 3003 | 403 | 不是该工作区成员 | **不要**提示"工作区不存在" | | 3004 | 403 | 角色权限不足 | 隐藏对应按钮 | @@ -69,19 +71,53 @@ ## 1. 认证 `/auth` -登录方式有三种,**默认只开密码那一种**。服务端把"现在开着哪些"告诉前端,前端据此渲染, -免得用户点进一条关着的路: +登录方式有四种,**默认只开「密码」和「邮箱注册」**。服务端把"现在开着哪些"告诉前端, +前端据此渲染,免得用户点进一条关着的路: ### `GET /auth/methods` ```json -{ "password": true, "email_password": true, "sms_code": false } +{ "password": true, "email_password": true, "sms_code": false, "email_register": true } ``` `SMS_LOGIN_ENABLED=false`(默认)时,`POST /auth/sms/send` 与 `POST /auth/login` 都回 `2017` 「短信验证码登录暂未开放」。理由:dev 环境的固定码等于"知道手机号就能进任何账号", 这条路必须显式打开,生产打开时还会强制要求真短信通道(见 `deploy` 文档的启动校验)。 +### `POST /auth/email/code` + +```json +{ "email": "someone@example.com" } +``` + +发一封注册验证码(6 位数字,`EMAIL_CODE_TTL_SECONDS` 默认 600 秒有效)。 +邮箱统一按小写处理;格式不对 → `2018`;**这个邮箱已经注册过 → `2019`**(让用户在填完密码前就知道); +60 秒内重复请求 → `2022`;发信配置没填或发不出去 → `2020`(不会假装发出去); +`EMAIL_REGISTER_ENABLED=false` → `2017`。 + +响应 `data: { sent: true, expires_in: 600 }`。**真发信时不回显验证码**; +只有本机联调(`EMAIL_CODE_DEV_ECHO=true`)时才带 `dev_code` 且不发信——这个开关在生产会直接拒绝启动。 + +### `POST /auth/register` + +```json +{ "email": "someone@example.com", "code": "123456", "password": "dadao2026", "nickname": "大道" } +``` + +```json +{ "user": { "id": "…", "phone": "", "email": "someone@example.com", "email_verified": true, … }, + "tokens": { "access_token": "…", "refresh_token": "…", "token_type": "bearer", "expires_in": 1800 } } +``` + +注册成功即登录态(直接返回 `tokens`)。规则: + +- 验证码不对 / 过期 / 试错超过 `EMAIL_CODE_MAX_ATTEMPTS`(默认 5)次 → `2021`;用完即作废,不能重放 +- 密码强度不够 → `2015`;邮箱已被占用 → `2019`;`EMAIL_REGISTER_ENABLED=false` → `2017` +- 昵称不填就取邮箱 `@` 前面那段 +- 建出来的账号**没有手机号**(`app_user.phone` 可空),所以 `user.phone` 回空串,不是星号串 + +邮箱是这类账号的唯一账号名与联系方式,所以注册必须真验一次码——"随手填一个"会让账号变成没人能找回的孤岛。 + ### `POST /auth/sms/send` ```json @@ -153,8 +189,8 @@ ### `GET /auth/me` 返回 `{ user, workspaces[] }`,`workspaces[].role` 是当前用户在该工作区的角色。 -`user` 里带 `has_password`(前端据此显示"设置密码"还是"修改密码")、`email`、`platform_admin` -(能不能进「系统设置」)。 +`user` 里带 `has_password`(前端据此显示"设置密码"还是"修改密码")、`email`、 +`email_verified`(这个邮箱是不是真验过)、`platform_admin`(能不能进「系统设置」)。 --- diff --git a/2.Design/ROADMAP-M0-M1.md b/2.Design/ROADMAP-M0-M1.md index f9ad756..a9ef962 100644 --- a/2.Design/ROADMAP-M0-M1.md +++ b/2.Design/ROADMAP-M0-M1.md @@ -603,3 +603,48 @@ 自动注册"造出来,关掉连 e2e 都造不出测试用户。生产是关的,所以生产**暂时没有注册入口**—— 要开放注册得先做「邮箱验证码注册 / 邀请注册」,这是下一步的事。 - 生产实拍:`2.Design/screens/prod-account-email.png`、`prod-settings-smtp.png`。 + +## 10. 邮箱验证码注册(2026-11-03) + +### 10.1 需求 + +手机上短信登录关掉后,生产没有注册入口。用户原话:"默认用邮件验证码注册吧;" + +### 10.2 决定 + +- **先发码再注册**:`POST /auth/email/code` → `POST /auth/register`,两步而不是"注册时顺带发信"。 + 理由:用户填完密码才被告知"这个邮箱注册过了"最气人,所以发码前就查重(`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()` 拒绝启动。 + +### 10.3 后端 + +| 位置 | 内容 | +|---|---| +| `app/config.py` | `email_register_enabled`、`email_code_ttl_seconds=600`、`email_code_throttle_seconds=60`、`email_code_max_attempts=5`、`email_code_dev_echo`(生产校验拒绝) | +| `app/errors.py` | `EMAIL_CODE_INVALID=2021`(400)、`EMAIL_CODE_TOO_FREQUENT=2022`(429) | +| `app/models.py` | `AppUser.phone` 可空、`email_verified_at`;`AuthUser.phone: str \| None` | +| `alembic/versions/0011_email_signup.py` | `phone DROP NOT NULL` + `email_verified_at` + GRANT(downgrade 先给空手机号补占位值) | +| `app/stores.py` | `email_code_key` / `email_tries_key` / `email_throttle_key` | +| `app/services/mail.py` | `send_verify_code(session, to=…, code=…, minutes=…)`,纯文本 + HTML 两版 | +| `app/api/auth.py` | `POST /auth/email/code`、`POST /auth/register`、`/auth/methods` 加 `email_register` | + +### 10.4 Web + +- `pages/Register.tsx`(新):邮箱 / 验证码(60 秒倒计时)/ 密码(前端先按同一套规则提示)/ 确认密码 / 昵称(可不填,留空用邮箱前缀); + 注册成功直接写令牌进首页。服务端关了注册时整页只显示"联系管理员开通"。 +- `pages/Login.tsx`:`/auth/methods` 里 `email_register` 为真才显示「还没有账号?用邮箱注册」。 +- `App.tsx`:新增 `/register` 路由。 + +### 10.5 验证 + +- `backend/tests/test_email_signup.py`(9 例):真走 SMTP(monkeypatch 记账)、dev echo 不发信、 + 注册即登录、码用完不能重放、错码/过期、试错上限、邮箱占用、发码节流、弱密码、注册关闭时两条接口都回 2017。 +- e2e `web/e2e/register.mjs`:登录页进注册页 → 拿码(dev echo)→ 注册 → 落进首页 → 退出用「邮箱 + 密码」登回来 → + 同一个邮箱再发码回 `2019`。 diff --git a/2.Design/screens/web-register.png b/2.Design/screens/web-register.png new file mode 100644 index 0000000..e13d78f Binary files /dev/null and b/2.Design/screens/web-register.png differ diff --git a/backend/alembic/versions/0011_email_signup.py b/backend/alembic/versions/0011_email_signup.py new file mode 100644 index 0000000..0980990 --- /dev/null +++ b/backend/alembic/versions/0011_email_signup.py @@ -0,0 +1,41 @@ +"""app_user 支持邮箱注册:放开 phone 非空、记录邮箱已验证时间 + +手机验证码登录关掉以后,新用户是「邮箱 + 验证码」注册进来的,此时还没有手机号; +`app_user.phone` 原来 NOT NULL,不放开就没法插这类账号。 + +`email_verified_at` 用来区分"填了个邮箱字符串"和"这个地址确实验证过": +注册流程发码成功后写入;账号页手工绑邮箱时同一套流程。 + +Revision ID: 0011 +Revises: 0010 +""" + +from __future__ import annotations + +from alembic import op + +from app.roles import APP_ROLE + +revision = "0011" +down_revision = "0010" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # 邮箱注册的账号没有手机号:老账号都有值,放开非空不影响它们 + op.execute("ALTER TABLE app_user ALTER COLUMN phone DROP NOT NULL") + op.execute("ALTER TABLE app_user ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMPTZ") + op.execute( + "COMMENT ON COLUMN app_user.email_verified_at IS " + "'邮箱验证通过的时间;为空表示还没验证过这个地址'" + ) + # 本表全局(不挂租户),只补列不新增表,权限照旧:应用角色要能读写这些列 + op.execute(f"GRANT SELECT, UPDATE ON app_user TO {APP_ROLE}") + + +def downgrade() -> None: + # 回滚前先把没有手机号的账号补上占位值,否则加回 NOT NULL 会失败 + op.execute("UPDATE app_user SET phone = 'redeem-' || replace(id::text, '-', '') WHERE phone IS NULL") + op.execute("ALTER TABLE app_user DROP COLUMN IF EXISTS email_verified_at") + op.execute("ALTER TABLE app_user ALTER COLUMN phone SET NOT NULL") diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index dadda16..9d00c57 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -1,9 +1,12 @@ -"""认证:手机号 / 邮箱 + 密码,手机验证码可选。 +"""认证:手机号 / 邮箱 + 密码,邮箱验证码注册,手机验证码可选。 当前**默认关掉短信验证码**(`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 @@ -11,10 +14,12 @@ 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 @@ -28,7 +33,15 @@ from ..security import ( password_problem, verify_password, ) -from ..stores import get_store, sms_code_key, sms_throttle_key +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() @@ -42,7 +55,9 @@ PASSWORD_FAIL_LIMIT = 10 PASSWORD_FAIL_TTL = 900 -def mask_phone(phone: str) -> str: +def mask_phone(phone: str | None) -> str: + if not phone: + return "" return f"{phone[:3]}****{phone[-4:]}" if len(phone) == 11 else "***" @@ -60,10 +75,11 @@ def _user_out(user: AppUser) -> dict: "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": user.phone in settings.platform_admin_phones, + "platform_admin": bool(user.phone) and user.phone in settings.platform_admin_phones, "created_at": user.created_at, } @@ -125,6 +141,7 @@ async def methods() -> dict: "password": True, "email_password": True, "sms_code": settings.sms_login_enabled, + "email_register": settings.email_register_enabled, } ) @@ -321,3 +338,126 @@ async def set_password(body: PasswordIn, user: CurrentUser, session: SessionDep) 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)}) diff --git a/backend/app/api/workspaces.py b/backend/app/api/workspaces.py index 1ba9d81..332242b 100644 --- a/backend/app/api/workspaces.py +++ b/backend/app/api/workspaces.py @@ -132,7 +132,8 @@ async def list_members( "role": m.role, "status": m.status, "nickname": u.nickname, - "phone": f"{u.phone[:3]}****{u.phone[-4:]}", + # 邮箱注册的账号没有手机号:留空比显示一串星号诚实 + "phone": f"{u.phone[:3]}****{u.phone[-4:]}" if u.phone else "", "joined_at": m.joined_at, } for m, u in rows diff --git a/backend/app/config.py b/backend/app/config.py index 231c99d..0d67ddb 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -85,6 +85,20 @@ class Settings(BaseSettings): smtp_use_ssl: bool = True smtp_timeout_seconds: int = 10 + # ── 邮箱注册 ────────────────────────────────────────── + # 手机验证码关掉以后,新用户靠「邮箱 + 验证码」自己注册进来。 + # 注册必须真的发出验证码(发信配置在「系统设置」里),所以这个开关跟 SMTP 是否配好绑定: + # 没配 SMTP 时发码接口会回 2020,而不是假装发出去。 + email_register_enabled: bool = True + email_code_ttl_seconds: int = 600 + # 同一邮箱两次发码的最小间隔;测试里设为 0 可关闭节流 + email_code_throttle_seconds: int = 60 + # 一个邮箱最多试错几次验证码,超了就作废(防止把 6 位数当彩票刮) + email_code_max_attempts: int = 5 + # 开发/联调用:把验证码直接回给调用方,**不发真信**。 + # 生产打开等于任何人都能注册任意邮箱,所以 validate_runtime() 会直接拒绝启动。 + email_code_dev_echo: bool = False + # ── 幂等 ────────────────────────────────────────────── idempotency_enabled: bool = True idempotency_ttl_seconds: int = 86_400 @@ -163,6 +177,11 @@ class Settings(BaseSettings): "短信验证码登录开着(SMS_LOGIN_ENABLED=true),但 SMS_PROVIDER=dev 只会发固定验证码" "(等于知道手机号就能进);接真短信通道之前请把 SMS_LOGIN_ENABLED 关掉" ) + if self.email_code_dev_echo: + problems.append( + "EMAIL_CODE_DEV_ECHO=true 会把注册验证码直接回给调用方," + "等于任何人都能注册任意邮箱;生产必须关掉(只在本地联调时打开)" + ) if problems: raise RuntimeError("配置校验失败:" + ";".join(problems)) diff --git a/backend/app/deps.py b/backend/app/deps.py index 987a672..27174ba 100644 --- a/backend/app/deps.py +++ b/backend/app/deps.py @@ -27,7 +27,9 @@ ROLE_RANK: dict[str, int] = {"owner": 4, "admin": 3, "member": 2, "guest": 1} @dataclass(frozen=True) class AuthUser: id: uuid.UUID - phone: str + # 邮箱注册的账号还没有手机号,所以可空;用手机号的地方(平台管理员白名单)按 + # "空就不匹配" 处理,别拿 None 去比字符串。 + phone: str | None nickname: str diff --git a/backend/app/errors.py b/backend/app/errors.py index 9ca9538..7164b2a 100644 --- a/backend/app/errors.py +++ b/backend/app/errors.py @@ -33,6 +33,8 @@ class ErrorCode(IntEnum): EMAIL_INVALID = 2018 EMAIL_TAKEN = 2019 MAIL_SEND_FAILED = 2020 + EMAIL_CODE_INVALID = 2021 + EMAIL_CODE_TOO_FREQUENT = 2022 # 3xxx 租户 / 权限 WORKSPACE_NOT_FOUND = 3001 @@ -102,6 +104,8 @@ _HTTP_STATUS: dict[ErrorCode, int] = { ErrorCode.EMAIL_INVALID: 422, ErrorCode.EMAIL_TAKEN: 409, ErrorCode.MAIL_SEND_FAILED: 502, + ErrorCode.EMAIL_CODE_INVALID: 400, + ErrorCode.EMAIL_CODE_TOO_FREQUENT: 429, ErrorCode.WORKSPACE_NOT_FOUND: 404, ErrorCode.NOT_A_MEMBER: 403, ErrorCode.INSUFFICIENT_ROLE: 403, diff --git a/backend/app/models.py b/backend/app/models.py index 7b3d6eb..663d9ae 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -41,7 +41,9 @@ class AppUser(Base, Timestamps): __tablename__ = "app_user" id: Mapped[uuid.UUID] = mapped_column(PgUUID(as_uuid=True), primary_key=True, default=new_uuid) - phone: Mapped[str] = mapped_column(String(32), nullable=False, unique=True) + # 手机号可空:短信登录关掉以后,新账号是「邮箱 + 验证码」注册的,没有手机号。 + # 老账号照旧有;将来绑手机号(换绑/找回)再补。 + phone: Mapped[str | None] = mapped_column(String(32), unique=True) nickname: Mapped[str] = mapped_column(String(64), nullable=False, default="") avatar_url: Mapped[str | None] = mapped_column(String(512)) status: Mapped[str] = mapped_column(String(16), nullable=False, default="active") @@ -50,6 +52,9 @@ class AppUser(Base, Timestamps): # 邮箱:既是「邮箱 + 密码」登录的账号名,也是以后找回密码/发通知的去处。 # 可空(老用户还没有),唯一性由迁移里的 lower(email) 部分唯一索引保证。 email: Mapped[str | None] = mapped_column(String(255)) + # 邮箱验过的时间:注册时发码验证过就有值;账号页手工绑邮箱时也走同一个验证流程。 + # 用"有没有值"区分"填了个字符串"和"这个地址真是他的"。 + email_verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) class SystemSetting(Base, Timestamps): diff --git a/backend/app/services/mail.py b/backend/app/services/mail.py index 1ce8703..e222f92 100644 --- a/backend/app/services/mail.py +++ b/backend/app/services/mail.py @@ -200,3 +200,24 @@ async def config_payload(session: AsyncSession) -> dict: "source": "db" if row else "env", "updated_at": row.updated_at if row else None, } + + +async def send_verify_code(session: AsyncSession, *, to: str, code: str, minutes: int) -> None: + """发注册/验证用的数字验证码。 + + 模板写得朴素但说清楚了"谁在什么地方注册"和"不是你就算了"——收件箱里躺着一条 + 没头没尾的六位数,用户只会当成钓鱼。 + """ + text = ( + f"你的达道Task验证码是 {code}\n\n" + f"{minutes} 分钟内有效。如果不是本人操作,忽略这封邮件即可,账号不会有任何变化。" + ) + html = ( + '
' + "

你的达道Task验证码是:

" + f'

{code}

' + f"

{minutes} 分钟内有效。如果不是本人操作,忽略这封邮件即可,账号不会有任何变化。

" + "
" + ) + await send_mail(session, to=to, subject="达道Task 验证码", text=text, html=html) diff --git a/backend/app/stores.py b/backend/app/stores.py index 71c729e..d8ce20b 100644 --- a/backend/app/stores.py +++ b/backend/app/stores.py @@ -107,5 +107,19 @@ def sms_throttle_key(phone: str) -> str: return f"sms:throttle:{phone}" +# ── 邮箱注册 ──────────────────────────────────────────── +def email_code_key(email: str) -> str: + return f"email:code:{email}" + + +def email_tries_key(email: str) -> str: + """试错计数:错一次算一次,超过上限就把验证码作废,别让人拿 6 位数当彩票刮。""" + return f"email:tries:{email}" + + +def email_throttle_key(email: str) -> str: + return f"email:throttle:{email}" + + def idempotency_key(user_id: str, key: str) -> str: return f"idem:{user_id}:{key}" diff --git a/backend/tests/test_account.py b/backend/tests/test_account.py index 83dabb2..33e702f 100644 --- a/backend/tests/test_account.py +++ b/backend/tests/test_account.py @@ -30,6 +30,7 @@ async def test_me_reports_profile_without_leaking_password(client: AsyncClient) "id", "phone", "email", + "email_verified", "nickname", "avatar_url", "has_password", diff --git a/backend/tests/test_config_prod.py b/backend/tests/test_config_prod.py index 554383b..e7eb49a 100644 --- a/backend/tests/test_config_prod.py +++ b/backend/tests/test_config_prod.py @@ -23,7 +23,9 @@ def _prod(**overrides: object) -> Settings: "sms_provider": "aliyun", } values.update(overrides) - return Settings(**values) + # _env_file=None:本机 backend/.env 里有 EMAIL_CODE_DEV_ECHO=true(跑 e2e 用的), + # 那条在生产是硬错误;这里要验的是 prod 自己的规则,不能被开发机的 .env 带偏。 + return Settings(_env_file=None, **values) def test_prod_rejects_dev_sms_when_login_enabled() -> None: diff --git a/backend/tests/test_email_signup.py b/backend/tests/test_email_signup.py new file mode 100644 index 0000000..97a91d5 --- /dev/null +++ b/backend/tests/test_email_signup.py @@ -0,0 +1,217 @@ +"""邮箱注册:发码 → 验码 → 建账号 → 直接登录。 + +手机验证码关掉以后这是唯一的新用户入口,所以三条都要真的走通: +① 发码真的走到 SMTP(不是"调了函数就算");② 验证码错了/过期了要报错; +③ 建出来的账号没有手机号也能正常用(`app_user.phone` 因此可空)。 + +本机联调用的 `EMAIL_CODE_DEV_ECHO` 只在 dev 生效,生产打开会被 validate_runtime 拒掉。 +""" + +from __future__ import annotations + +from typing import Any + +import pytest +from httpx import AsyncClient + +from app.api import auth as auth_api +from app.errors import ErrorCode +from app.services import mail +from tests.conftest import unwrap + + +def _settings() -> Any: + """settings 是模块级的缓存对象,monkeypatch 改它、用例结束自动还原。""" + return auth_api.settings + + +@pytest.fixture +async def sent(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]: + """把真发信换成记账:记下收件人与验证码,不碰网络。""" + box: list[dict[str, Any]] = [] + + async def fake_send(session: Any, *, to: str, code: str, minutes: int) -> None: + box.append({"to": to, "code": code, "minutes": minutes}) + + monkeypatch.setattr(_settings(), "email_code_dev_echo", False) + monkeypatch.setattr(_settings(), "email_code_throttle_seconds", 0) + monkeypatch.setattr(mail, "send_verify_code", fake_send) + return box + + +async def _send(client: AsyncClient, email: str) -> dict[str, Any]: + return unwrap(await client.post("/api/v1/auth/email/code", json={"email": email})) + + +async def test_send_code_goes_through_smtp(client: AsyncClient, sent: list[dict[str, Any]]) -> None: + """发码必须真的交给发信服务,并且邮箱统一小写。""" + data = await _send(client, " Newcomer@Example.COM ") + assert data["sent"] is True + assert data["expires_in"] > 0 + # 不带 dev_code:真发信时把码回给客户端等于白验 + assert "dev_code" not in data + assert sent == [{"to": "newcomer@example.com", "code": sent[0]["code"], "minutes": 10}] + assert len(sent[0]["code"]) == 6 and sent[0]["code"].isdigit() + + +async def test_dev_echo_returns_code_without_sending( + client: AsyncClient, monkeypatch: pytest.MonkeyPatch +) -> None: + """本机联调:不回真信,把码回给调用方(生产会拒绝启动)。""" + called: list[str] = [] + + async def fake_send(session: Any, *, to: str, code: str, minutes: int) -> None: + called.append(to) + + monkeypatch.setattr(_settings(), "email_code_dev_echo", True) + monkeypatch.setattr(_settings(), "email_code_throttle_seconds", 0) + monkeypatch.setattr(mail, "send_verify_code", fake_send) + data = await _send(client, "dev@example.com") + assert data["sent"] is True + assert len(data["dev_code"]) == 6 + assert called == [] + + +async def test_register_creates_account_and_logs_in( + client: AsyncClient, sent: list[dict[str, Any]] +) -> None: + """注册完就是登录态:能拿到令牌,/auth/me 也认。""" + email = "fresh@example.com" + await _send(client, email) + code = sent[-1]["code"] + + data = unwrap( + await client.post( + "/api/v1/auth/register", + json={"email": " FRESH@example.com ", "code": code, "password": "dadao2026"}, + ) + ) + + assert data["user"]["email"] == email + assert data["user"]["email_verified"] is True + assert data["user"]["has_password"] is True + # 邮箱注册没有手机号:脱敏字段留空,别拼出一串星号 + assert data["user"]["phone"] == "" + # 没填昵称就取 @ 前面那段,别让新账号顶着一个空名字 + assert data["user"]["nickname"] == "fresh" + + headers = {"Authorization": f"Bearer {data['tokens']['access_token']}"} + me = unwrap(await client.get("/api/v1/auth/me", headers=headers)) + assert me["user"]["id"] == data["user"]["id"] + + # 验证码用过就作废,不能拿同一个码再注册一次 + replay = await client.post( + "/api/v1/auth/register", + json={"email": email, "code": code, "password": "dadao2026"}, + ) + assert replay.json()["code"] == ErrorCode.EMAIL_CODE_INVALID + + # 注册完可以用「邮箱 + 密码」登录 + login = unwrap( + await client.post( + "/api/v1/auth/login/password", + json={"email": email, "password": "dadao2026"}, + ) + ) + assert login["user"]["id"] == data["user"]["id"] + + +async def test_register_rejects_wrong_or_missing_code( + client: AsyncClient, sent: list[dict[str, Any]] +) -> None: + email = "oops@example.com" + await _send(client, email) + wrong = await client.post( + "/api/v1/auth/register", + json={"email": email, "code": "000000", "password": "dadao2026"}, + ) + assert wrong.json()["code"] == ErrorCode.EMAIL_CODE_INVALID + + # 压根没发过码:同样"过期",不告诉对方"这个邮箱存不存在" + never = await client.post( + "/api/v1/auth/register", + json={"email": "never@example.com", "code": "123456", "password": "dadao2026"}, + ) + assert never.json()["code"] == ErrorCode.EMAIL_CODE_INVALID + + +async def test_register_code_burns_out_after_too_many_tries( + client: AsyncClient, sent: list[dict[str, Any]], monkeypatch: pytest.MonkeyPatch +) -> None: + """6 位数可以被刮,所以错够次数就把码作废。""" + monkeypatch.setattr(_settings(), "email_code_max_attempts", 2) + email = "guess@example.com" + await _send(client, email) + good = sent[-1]["code"] + wrong = "000000" if good != "000000" else "111111" + + for _ in range(2): + resp = await client.post( + "/api/v1/auth/register", + json={"email": email, "code": wrong, "password": "dadao2026"}, + ) + assert resp.json()["code"] == ErrorCode.EMAIL_CODE_INVALID + + burned = await client.post( + "/api/v1/auth/register", + json={"email": email, "code": good, "password": "dadao2026"}, + ) + assert burned.json()["code"] == ErrorCode.EMAIL_CODE_INVALID + assert "重新获取" in burned.json()["message"] + + +async def test_send_code_refuses_registered_email( + client: AsyncClient, sent: list[dict[str, Any]] +) -> None: + email = "taken@example.com" + await _send(client, email) + unwrap( + await client.post( + "/api/v1/auth/register", + json={"email": email, "code": sent[-1]["code"], "password": "dadao2026"}, + ) + ) + again = await client.post("/api/v1/auth/email/code", json={"email": email}) + assert again.json()["code"] == ErrorCode.EMAIL_TAKEN + + +async def test_send_code_throttles(client: AsyncClient, sent: list[dict[str, Any]]) -> None: + _settings().email_code_throttle_seconds = 60 + try: + await _send(client, "throttle@example.com") + again = await client.post("/api/v1/auth/email/code", json={"email": "throttle@example.com"}) + assert again.json()["code"] == ErrorCode.EMAIL_CODE_TOO_FREQUENT + finally: + _settings().email_code_throttle_seconds = 0 + + +async def test_register_validates_password_and_email( + client: AsyncClient, sent: list[dict[str, Any]] +) -> None: + await _send(client, "weak@example.com") + weak = await client.post( + "/api/v1/auth/register", + json={"email": "weak@example.com", "code": sent[-1]["code"], "password": "short"}, + ) + assert weak.json()["code"] == ErrorCode.PASSWORD_WEAK + + bad = await client.post("/api/v1/auth/email/code", json={"email": "not-an-email"}) + assert bad.json()["code"] == ErrorCode.EMAIL_INVALID + + +async def test_registration_can_be_closed( + client: AsyncClient, sent: list[dict[str, Any]], monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(_settings(), "email_register_enabled", False) + assert (await client.post("/api/v1/auth/email/code", json={"email": "x@example.com"})).json()[ + "code" + ] == ErrorCode.LOGIN_METHOD_DISABLED + assert ( + await client.post( + "/api/v1/auth/register", + json={"email": "x@example.com", "code": "123456", "password": "dadao2026"}, + ) + ).json()["code"] == ErrorCode.LOGIN_METHOD_DISABLED + # /auth/methods 也要如实告诉前端这条路关着 + methods = unwrap(await client.get("/api/v1/auth/methods")) + assert methods["email_register"] is False diff --git a/web/e2e/register.mjs b/web/e2e/register.mjs new file mode 100644 index 0000000..3e78bbd --- /dev/null +++ b/web/e2e/register.mjs @@ -0,0 +1,98 @@ +/** + * 注册的验收:从登录页走到注册页,把账号建出来,再用它登回来。 + * + * 这里验的是"一个人真能自己进来",所以每一步都从界面走: + * ① 登录页有「用邮箱注册」(服务端开着才会出现) + * ② 注册页拿到验证码(本机 EMAIL_CODE_DEV_ECHO 会把码回给页面,不发真信) + * ③ 提交后直接进首页,说明注册即登录 + * ④ 退出、用「邮箱 + 密码」再登一次,证明邮箱真的是账号名 + * ⑤ 同一个邮箱再发码,必须被挡(2019),而不是又建一个号 + */ +import { chromium } from "playwright-core"; + +const WEB = process.env.WEB ?? "http://127.0.0.1:5180"; +const API = process.env.API ?? "http://127.0.0.1:8100/api/v1"; +const CHROME = + process.env.CHROME ?? + `${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1223\\chrome-win64\\chrome.exe`; + +let pass = 0; +let fail = 0; +function check(name, ok, extra = "") { + if (ok) { + pass += 1; + console.log(`PASS ${name}`); + } else { + fail += 1; + console.log(`FAIL ${name}${extra ? ` — ${extra}` : ""}`); + } +} + +const email = `reg${Date.now()}@example.com`; +const password = `dadao${Math.floor(Math.random() * 900000 + 1000)}`; + +const browser = await chromium.launch({ executablePath: CHROME }); +const page = await browser.newPage({ viewport: { width: 1440, height: 960 } }); +const errors = []; +page.on("console", (m) => m.type() === "error" && errors.push(m.text())); +page.on("pageerror", (e) => errors.push(String(e))); + +try { + await page.goto(`${WEB}/login`); + await page.waitForSelector("#account"); + + const registerLink = page.getByRole("link", { name: "用邮箱注册" }); + check("登录页有「用邮箱注册」入口", (await registerLink.count()) > 0); + + await registerLink.click(); + await page.waitForURL("**/register"); + await page.waitForSelector("#email"); + check("能进注册页", true); + + await page.locator("#email").fill(email); + await page.getByRole("button", { name: "获取验证码" }).click(); + await page.waitForSelector("text=开发环境验证码", { timeout: 15000 }); + const notice = await page.locator("text=开发环境验证码").first().innerText(); + const code = notice.match(/\d{6}/)?.[0] ?? ""; + check("本机拿得到验证码(生产只发到邮箱)", code.length === 6, notice); + + await page.locator("#code").fill(code); + await page.locator("#password").fill(password); + await page.locator("#confirm").fill(password); + await page.locator("#nickname").fill("注册验收"); + await page.getByRole("button", { name: "注册并登录" }).click(); + + const landed = await page + .waitForURL(`${WEB}/`, { timeout: 15000 }) + .then(() => true) + .catch(() => false); + check("注册完直接进首页(注册即登录)", landed, page.url()); + + // 退出后用「邮箱 + 密码」登回来:邮箱真是账号名 + await page.getByRole("button", { name: "退出登录" }).last().click(); + await page.waitForURL("**/login"); + await page.getByRole("tab", { name: "邮箱" }).click(); + await page.locator("#account").fill(email); + await page.locator("#password").fill(password); + await page.getByRole("button", { name: "登录", exact: true }).click(); + const back = await page + .waitForURL(`${WEB}/`, { timeout: 15000 }) + .then(() => true) + .catch(() => false); + check("邮箱 + 密码能登回来", back); + + // 同一个邮箱再发码:必须拦住,别再建一个号 + const again = await page.request.post(`${API}/auth/email/code`, { data: { email } }); + const body = await again.json(); + check("已注册的邮箱再发码回 2019", body.code === 2019, JSON.stringify(body)); + + check("控制台无报错", errors.length === 0, errors.slice(0, 2).join(" | ")); +} catch (err) { + fail += 1; + console.log(`FAIL 脚本异常 — ${err}`); +} finally { + await browser.close(); +} + +console.log(`\n${fail === 0 ? "ALL PASS" : "FAILED"} — pass ${pass}, fail ${fail}`); +process.exit(fail === 0 ? 0 : 1); diff --git a/web/e2e/shots-register.mjs b/web/e2e/shots-register.mjs new file mode 100644 index 0000000..e8475a4 --- /dev/null +++ b/web/e2e/shots-register.mjs @@ -0,0 +1,32 @@ +/** + * 拍注册页的截图(文档用)。需要前端 5180、后端 8100 起着。 + * + * 用本机 dev 的 EMAIL_CODE_DEV_ECHO:把码回给页面,截图里能看到"开发环境验证码"那一行, + * 正好说明生产只会发到邮箱。真提交一次再拍,页面右侧是"下一步会怎样"的说明,不必留成功态。 + */ +import { chromium } from "playwright-core"; +import { mkdirSync } from "node:fs"; + +const WEB = process.env.WEB ?? "http://127.0.0.1:5180"; +const OUT = "e2e/artifacts"; +mkdirSync(OUT, { recursive: true }); + +const browser = await chromium.launch({ + executablePath: + process.env.CHROME ?? + `${process.env.LOCALAPPDATA}/ms-playwright/chromium-1223/chrome-win64/chrome.exe`, +}); +const page = await browser.newPage({ viewport: { width: 1440, height: 960 } }); + +await page.goto(`${WEB}/register`); +await page.waitForSelector("#email"); +await page.locator("#email").fill("shufen@126.com"); +await page.locator("#password").fill("dadao2026"); +await page.locator("#confirm").fill("dadao2026"); +await page.locator("#nickname").fill("淑芬"); +await page.getByRole("button", { name: "获取验证码" }).click(); +await page.waitForSelector("text=开发环境验证码", { timeout: 15000 }); +await page.screenshot({ path: `${OUT}/register.png` }); + +await browser.close(); +console.log(`${OUT}/register.png`); diff --git a/web/src/App.tsx b/web/src/App.tsx index 42971ee..967d0ab 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -5,6 +5,7 @@ import { UpdatePrompt } from "./components/UpdatePrompt"; import { api, authExpired, session } from "./lib/client"; import type { User, Workspace } from "./lib/types"; import { LoginPage } from "./pages/Login"; +import { RegisterPage } from "./pages/Register"; import { HomePage } from "./pages/Home"; import { WorkspacePage } from "./pages/Workspace"; import { TasksPage } from "./pages/Tasks"; @@ -89,6 +90,7 @@ export function App() { } /> + } /> } /> } /> } /> diff --git a/web/src/pages/Login.tsx b/web/src/pages/Login.tsx index 26b7bc4..c393915 100644 --- a/web/src/pages/Login.tsx +++ b/web/src/pages/Login.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { useNavigate, useSearchParams } from "react-router-dom"; +import { Link, useNavigate, useSearchParams } from "react-router-dom"; import { useAuth } from "../App"; import { Book, Check, Logo, TicketIcon } from "../components/icons"; import { Button, Field, InlineAlert } from "../components/ui"; @@ -34,7 +34,12 @@ const PHONE_RE = /^1[3-9]\d{9}$/; const EMAIL_RE = /^[^@\s]{1,64}@[^@\s]{1,190}\.[A-Za-z]{2,}$/; /** 服务端现在开放哪些登录方式。先问再渲染,免得用户点进去才发现这条路关着。 */ -type Methods = { password: boolean; email_password: boolean; sms_code: boolean }; +type Methods = { + password: boolean; + email_password: boolean; + sms_code: boolean; + email_register: boolean; +}; export function LoginPage() { const { reload } = useAuth(); @@ -46,7 +51,12 @@ export function LoginPage() { const [code, setCode] = useState(""); const [password, setPassword] = useState(""); const [mode, setMode] = useState<"code" | "password">("password"); - const [methods, setMethods] = useState({ password: true, email_password: true, sms_code: false }); + const [methods, setMethods] = useState({ + password: true, + email_password: true, + sms_code: false, + email_register: true, + }); const [devCode, setDevCode] = useState(null); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); @@ -312,6 +322,15 @@ export function LoginPage() { + {methods.email_register && ( +

+ 还没有账号? + + 用邮箱注册 + +

+ )} +

登录即表示同意《服务条款》与《隐私政策》。我们不会把你的内容用于训练模型。

diff --git a/web/src/pages/Register.tsx b/web/src/pages/Register.tsx new file mode 100644 index 0000000..b027b9b --- /dev/null +++ b/web/src/pages/Register.tsx @@ -0,0 +1,240 @@ +import { useEffect, useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { useAuth } from "../App"; +import { Logo } from "../components/icons"; +import { Button, Field, InlineAlert } from "../components/ui"; +import { ApiError, api, session, type Tokens } from "../lib/client"; +import type { User } from "../lib/types"; + +const EMAIL_RE = /^[^@\s]{1,64}@[^@\s]{1,190}\.[A-Za-z]{2,}$/; + +/** 跟后端 `password_problem` 同一套规则;本地只做"别白跑一趟"的提示,真判定在服务端。 */ +function passwordHint(password: string): string | null { + if (password.length < 8) return "至少 8 位"; + if (password.length > 64) return "最多 64 位"; + if (!/[A-Za-z]/.test(password) || !/\d/.test(password)) return "要同时有字母和数字"; + return null; +} + +/** + * 注册:邮箱 + 验证码 + 密码。 + * + * 为什么必须先验一次邮箱:短信登录关掉以后,邮箱是账号名也是唯一联系方式, + * "随手填一个" 会让账号变成没人能找回的孤岛。 + */ +export function RegisterPage() { + const { reload } = useAuth(); + const navigate = useNavigate(); + const [email, setEmail] = useState(""); + const [code, setCode] = useState(""); + const [password, setPassword] = useState(""); + const [confirm, setConfirm] = useState(""); + const [nickname, setNickname] = useState(""); + const [countdown, setCountdown] = useState(0); + const [sent, setSent] = useState(false); + const [devCode, setDevCode] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const [open, setOpen] = useState(true); + + useEffect(() => { + let alive = true; + api + .get<{ email_register: boolean }>("/auth/methods") + .then((data) => { + if (alive) setOpen(data.email_register); + }) + .catch(() => { + /* 拿不到就按开着渲染,提交时服务端会给出真答案 */ + }); + return () => { + alive = false; + }; + }, []); + + useEffect(() => { + if (countdown <= 0) return; + const timer = setTimeout(() => setCountdown((v) => v - 1), 1000); + return () => clearTimeout(timer); + }, [countdown]); + + const emailValid = EMAIL_RE.test(email.trim()); + const weak = password ? passwordHint(password) : null; + const mismatch = confirm.length > 0 && confirm !== password; + const canSubmit = + !busy && emailValid && code.length >= 4 && !weak && password.length > 0 && confirm === password; + + async function sendCode() { + setError(null); + setBusy(true); + try { + const data = await api.post<{ dev_code?: string }>("/auth/email/code", { + email: email.trim(), + }); + setDevCode(data.dev_code ?? null); + setSent(true); + setCountdown(60); + } catch (err) { + setError(err instanceof ApiError ? err.message : "发送失败"); + } finally { + setBusy(false); + } + } + + async function submit(event: React.FormEvent) { + event.preventDefault(); + if (!canSubmit) return; + setError(null); + setBusy(true); + try { + const data = await api.post<{ user: User; tokens: Tokens }>("/auth/register", { + email: email.trim(), + code, + password, + ...(nickname.trim() ? { nickname: nickname.trim() } : {}), + }); + session.save(data.tokens); + await reload(); + navigate("/", { replace: true }); + } catch (err) { + setError(err instanceof ApiError ? err.message : "注册失败"); + } finally { + setBusy(false); + } + } + + return ( +
+
+
+ +
+
达道Task
+
任务 · 工单 · 知识
+
+
+ +

创建账号

+

+ 用邮箱注册:先收一个验证码,再设一个密码。邮箱就是你的登录账号。 +

+ + {!open && ( +
+ 这台服务暂未开放自助注册,请联系管理员开通账号。 +
+ )} + +
+ + setEmail(e.target.value)} + maxLength={255} + disabled={!open} + /> + + + +
+ setCode(e.target.value.trim())} + maxLength={6} + disabled={!open} + /> + +
+
+ + + setPassword(e.target.value)} + maxLength={64} + disabled={!open} + /> + {weak && {weak}} + + + + setConfirm(e.target.value)} + maxLength={64} + disabled={!open} + /> + {mismatch && 两次输入不一致} + + + + setNickname(e.target.value)} + maxLength={32} + disabled={!open} + /> + + + {devCode && ( +
+ 开发环境验证码:{devCode} + (生产环境只发到邮箱,不回显) +
+ )} + {error && {error}} + + +
+ +

+ 已经有账号了? + + 去登录 + +

+
+
+ ); +}