短信通道挂了人就进不来,所以给账号加第二把自助的钥匙:设过密码的用密码进, 没设过的照旧只用验证码,不强制。 - app_user.password_hash(迁移 0008 加列)+ scrypt 哈希(标准库实现, n=2^14/r=8/p=1,存 scrypt$n$r$p$salt$hash),不引 passlib - POST /auth/login/password:没设过密码与密码错都回 2013,不区分账号是否存在; 15 分钟内连错 10 次 → 2016,期间正确密码也挡住 - POST /auth/password:设/改共用,改要先验旧密码,新旧相同算弱密码 2015 - PATCH /auth/me 改昵称;用户输出走白名单 _user_out,密码哈希一个字节都不出去 - 登录页顶部「验证码 / 密码」两段切换 tests/test_account.py 10 项;全量 pytest 113 passed,ruff 全绿。
248 lines
8.6 KiB
Python
248 lines
8.6 KiB
Python
"""认证:手机号 + 验证码,可选登录密码。
|
|
|
|
M0 只有验证码登录;密码是**可选**的第二条路:用户自己在账号设置里设一次,
|
|
之后登录页可以选「密码」;没设过密码的人只有验证码这一条,不受影响。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hmac
|
|
import secrets
|
|
|
|
from fastapi import APIRouter
|
|
from pydantic import BaseModel, Field
|
|
from sqlalchemy import select
|
|
|
|
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 ..stores import get_store, sms_code_key, sms_throttle_key
|
|
|
|
router = APIRouter(prefix="/auth", tags=["认证"])
|
|
settings = get_settings()
|
|
|
|
PHONE_PATTERN = r"^1[3-9]\d{9}$"
|
|
|
|
# 密码登录连错这么多次就先锁一会儿:暴力猜密码的成本必须比猜验证码高
|
|
PASSWORD_FAIL_LIMIT = 10
|
|
PASSWORD_FAIL_TTL = 900
|
|
|
|
|
|
def mask_phone(phone: str) -> str:
|
|
return f"{phone[:3]}****{phone[-4:]}" if len(phone) == 11 else "***"
|
|
|
|
|
|
def _user_out(user: AppUser) -> dict:
|
|
"""对外只给脱敏手机号;密码本身一个字节都不出去,只告诉客户端「设没设」。"""
|
|
return {
|
|
"id": str(user.id),
|
|
"phone": mask_phone(user.phone),
|
|
"nickname": user.nickname,
|
|
"avatar_url": user.avatar_url,
|
|
"has_password": bool(user.password_hash),
|
|
"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: str = Field(pattern=PHONE_PATTERN)
|
|
password: str = Field(min_length=1, max_length=64)
|
|
|
|
|
|
class ProfilePatch(BaseModel):
|
|
nickname: str | None = Field(default=None, min_length=1, max_length=32)
|
|
|
|
|
|
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.post("/sms/send", summary="发送登录验证码")
|
|
async def send_code(body: SendCodeIn) -> dict:
|
|
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:
|
|
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 秒;成功即清零,正常用户不会感知。
|
|
"""
|
|
store = get_store()
|
|
fail_key = f"pwd_fail:{body.phone}"
|
|
if int(await store.get(fail_key) or 0) >= PASSWORD_FAIL_LIMIT:
|
|
raise AppError(
|
|
ErrorCode.LOGIN_TOO_MANY,
|
|
f"密码错误次数过多,请 {PASSWORD_FAIL_TTL // 60} 分钟后再试,或用验证码登录",
|
|
)
|
|
|
|
user = (
|
|
await session.execute(select(AppUser).where(AppUser.phone == body.phone))
|
|
).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, "手机号或密码不对")
|
|
|
|
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
|
|
|
|
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})
|