"""认证:手机号 / 邮箱 + 密码,邮箱验证码注册,手机验证码可选。 当前**默认关掉短信验证码**(`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)})