funauth
funauth 是一个可复用的登录与注册底座:密码登录、两级角色、邀请码注册、
微信 / QQ 扫码、邮箱 / 短信验证码。表结构以 mixin 形式提供,具体表长在宿主
项目自己的 Base 上 —— 一套 MetaData、一条迁移链、外键照常写。
版本变化见 CHANGELOG.md。
目录
- 特性 · 不负责什么 · 环境要求 · 安装
- 快速开始:建表 · 建单例 · 用
- 登录方式:外部身份(微信 / QQ) · 邮箱 / 短信验证码
- 账号管理:改角色 · 最后一个管理员 · 列表与分页 · 改名与改密
- 异常与建议状态码
- FastAPI:验证码路由 · 两道门 · 人员管理路由 · 会话方案 · 为什么不起独立服务
- 设计说明:邀请码为什么是一条 UPDATE · 往后再加登录方式
- 从 0.2.1 及更早升级 · 源码导览 · 开发
特性
四条登录路,按需取用 —— 不用的那条不传对应模型即可,没有空表也没有空列:
| 方式 | 入口 | 需要的模型 |
|---|---|---|
| 用户名 + 密码 | authenticate |
user_model |
| 邀请码自助注册 | register_with_invite |
+ invite_model |
| 微信 / QQ 扫码 | login_with_identity |
+ identity_model |
| 邮箱 / 短信验证码 | login_with_code |
+ challenge_model |
一个账号可以同时挂多种方式(设了密码、绑了微信、验证过邮箱),User 表不会
因为多接一家而加列。
- 密码登录(bcrypt):用户名不存在 / 密码不对 / 账号停用 / 没设过密码共用 一句报错,而且耗时一致(失败路径也跑一次 bcrypt,不然按响应时间就能枚举)
- 两级角色
UserRole.ADMIN/GUEST,所有自助入口产出的账号恒为GUEST; 建号之后随时可改(set_role),但降不掉最后一个在用的管理员 - 邀请码:限次、限期、可单独吊销,名额消耗是一条条件 UPDATE,并发安全
- 外部身份:
(provider, external_id) -> user_id一张表,绝不按邮箱自动合并 账号(那是个账号接管漏洞,见下文) - 验证码:存哈希不存明文、一次性原子消耗、试错限次(计数活过 rollback)、 发送限频(短信是花钱的)
- 不依赖任何 web 框架,不管 session / cookie / JWT
- 列类型可移植:PostgreSQL 与 SQLite 上行为一致(时间列始终 UTC-aware)
- 账号管理:改角色、改名、自助改密、启停,列表带过滤和分页
- 可选的 FastAPI 适配(
funauth[fastapi]):现成端点 + 整站门禁 / 管理员两道门- 人员管理路由,宿主一行
include_router接上
- 人员管理路由,宿主一行
不负责什么
本包只回答「这个凭据对不对、这个人是什么角色」。怎么维持登录态是宿主的事
(Starlette 的签名 cookie、Redis session、JWT 都行)。抛出来的是
AuthError 的子类,宿主自己翻译成 HTTP 状态码。
每个方法收一个 AsyncSession,本包不自己建、不自己关 —— 宿主的会话生命周期各不
相同(FastAPI 一个请求一个、CLI 一条命令一个、后台任务一批一个)。
提交这件事分四档,不要靠猜:
- 公开写方法默认
commit=True自己提交;传commit=False则只 flush,提交 留给你 —— 要把账号操作和你自己的写入(建默认工作区之类)凑进同一个事务时 用这个。这一档包括create_user、set_password、set_active、set_role、set_role_many、set_username、change_password、issue_invite、revoke_invite、register_with_invite、login_with_identity、link_identity、unlink_identity、issue_verification_code、login_with_code。 consume_invite从不提交,也刻意没有commit参数(理由见下文「邀请码 为什么是一条 UPDATE」)。verify_code是个例外:它在校验结果出来之前就把试错计数单独提交掉,所以 即使在失败路径上它也会提交一次。这是安全权衡,不是疏漏 —— 理由见下文 「试错计数为什么单独提交」。因此请在请求早期调它。- 回滚始终是调用方的事。 本包抛异常时不 rollback —— 会话是你的,里面可能还 包着你自己的写入。FastAPI 宿主的标准写法正好覆盖这件事:
async def get_session() -> AsyncIterator[AsyncSession]:
async with maker() as s:
try:
yield s
except Exception:
await s.rollback()
raise
会话方案和状态码映射在 funauth.contrib.fastapi 里有一套现成的(见下文),但那是
可选的 extra,核心这层不认识 HTTP。
环境要求
- Python 3.12 或更高版本
- SQLAlchemy 2.0+(async)
安装
pip install funauth
# 要用现成的 FastAPI 路由与依赖
pip install "funauth[fastapi]"
快速开始
1. 在自己的 Base 上声明表
import sqlalchemy as sa
from sqlalchemy.orm import DeclarativeBase
from funauth.models import InviteCodeMixin, TimestampMixin, UserMixin
class Base(DeclarativeBase): ...
class User(UserMixin, TimestampMixin, Base):
__tablename__ = "user"
__table_args__ = (sa.UniqueConstraint("username", name="uq_user_username"),)
class InviteCode(InviteCodeMixin, TimestampMixin, Base):
__tablename__ = "invite_code"
__table_args__ = (sa.UniqueConstraint("code", name="uq_invite_code_code"),)
唯一约束刻意不放进 mixin:约束名会进迁移、进 ON CONFLICT,宿主得能看见也能
改名。宿主已经有自己的 TimestampMixin 就用自己的,两边 DDL 一致,混用不需要迁移。
要微信 / QQ 扫码或邮箱 / 短信验证码,再加这两张:
from funauth.models import ExternalIdentityMixin, VerificationCodeMixin
class Identity(ExternalIdentityMixin, TimestampMixin, Base):
__tablename__ = "identity"
__table_args__ = (
sa.UniqueConstraint("provider", "external_id", name="uq_identity_provider_external"),
sa.ForeignKeyConstraint(
["user_id"], ["user.id"], name="fk_identity_user", ondelete="CASCADE"
),
)
class VerificationCode(VerificationCodeMixin, TimestampMixin, Base):
__tablename__ = "verification_code"
(provider, external_id) 那条唯一约束不是装饰:它是「两个请求同时拿同一个
openid 进来」唯一的真实保障。login_with_identity 的「查不到就建」挡不住并发,
靠的就是撞上它之后重查。外键同样留给宿主 —— "user.id" 里那个表名是宿主定的。
2. 建一个单例
放在自己的绑定模块里(比如 myapp/services/account.py),别处 import 它:
from funauth import Accounts
accounts = Accounts(
user_model=User,
invite_model=InviteCode, # 要邀请码注册才传
identity_model=Identity, # 要微信/QQ/邮箱/短信登录才传
challenge_model=VerificationCode, # 要邮箱/短信验证码才传
)
后三个不传就是不启用那条路,对应方法调了会直接炸 —— 刻意不做优雅降级,否则 「忘了配」会变成登录相关的静默失败,而那是最糟的一种。
本包不自带表,所以得把宿主的模型类告诉它。用实例持有而不是模块级全局 +
configure():没有全局可变状态,import 顺序无关,同一个进程里也能接两套表
(测试、多租户)。
3. 用
from funauth import BadCredentials, UserRole
# 登录
user = await accounts.authenticate(session, "alice", "pw")
# 服务端建管理员
await accounts.create_user(session, "boss", "pw", UserRole.ADMIN)
# 签发邀请码
code = await accounts.issue_invite(session, max_uses=5, expires_in_days=7, note="给张三")
print(code.code) # K7F2M9QX
# 凭码自助注册(角色恒为 GUEST)
user = await accounts.register_with_invite(session, "newbie", "pw", code.code)
外部身份(微信 / QQ 扫码)
from funauth import AuthProvider
# 宿主先和第三方握完手,拿到一个稳定 id
openid = await my_wechat_client.exchange(code)
# funauth 只管「这个身份是哪个账号」,没绑过就当场开一个
user, created = await accounts.login_with_identity(
session, AuthProvider.WECHAT, openid, display_name=nickname
)
# 已登录用户主动绑定 / 解绑
await accounts.link_identity(session, user, AuthProvider.WECHAT, openid)
await accounts.unlink_identity(session, user, AuthProvider.WECHAT)
握手不在本包里。 code 换 token、state 防 CSRF、各家不同的回调格式、PC 扫码和 公众号授权的差异 —— 这些要 httpx、要 appid/secret、要跟着平台文档变。本包核心层 只依赖 sqlalchemy 和 bcrypt,把平台 SDK 拖进来,每个只用密码登录的宿主都得跟着背。
再接一家(抖音、飞书、GitHub OAuth)不需要改本包:它们都是「第三方给一个稳定
id」这个形状,宿主握完手直接调 login_with_identity,这边只是 AuthProvider 多
一个成员,没有任何 DDL 变化。
两条红线
不按邮箱 / 手机号自动合并账号。 匹配键只有 (provider, external_id)。第三方
返回的邮箱是用户自己填的 —— 要是能用它认领一个同邮箱的已有账号,攻击者只要在微信
侧把邮箱改成受害者的,扫一下码就直接登进了对方账号。要合并必须由已登录的用户
主动走 link_identity,那时候他已经证明了自己同时拥有两边。
不许解绑最后一种登录方式。 unlink_identity 会先数一遍(密码算一种,每个外部
身份算一种),只剩一种时抛 LastLoginMethod。用户点「解绑微信」时不会想到这是他
唯一的入口,而解完的后果是一个谁都进不去的账号,只能靠运维去库里改。
邮箱 / 短信验证码
这条链路整个在本包里 —— 和扫码相反。签发、哈希、原子消耗、试错限次、发送限频 每一条都是安全核心,让每个宿主自己写一遍必然有人写漏。宿主只提供一件事:把这段 文字发出去。
# 1. 签发。明文只在这里出现这一次,库里存的是 bcrypt 哈希
code, _ = await accounts.issue_verification_code(session, AuthProvider.EMAIL, email)
await my_mailer.send(email, f"验证码:{code},10 分钟内有效") # 宿主的通道
# 2. 用户把码填回来 —— 校验 + 登录 + 没注册过就建号,一步到位
user, created = await accounts.login_with_code(session, AuthProvider.EMAIL, email, code)
默认 10 分钟有效、同一标识 60 秒内只能发一次、最多试错 5 次。短信比邮件贵,真上 短信的时候建议把间隔调大。
issue_verification_code 会作废这个标识之前所有还没用的码:同时有三个有效码
(用户连点了三次「重新发送」)等于把爆破成功率翻三倍,而且「我该输哪个」对用户
也是困惑。
试错计数为什么单独提交
verify_code 在校验结果出来之前先把 attempts 递增并提交。这是故意的 ——
失败时调用方(FastAPI 宿主的会话依赖)会 rollback,次数要是跟着回滚,这个上限就
完全不存在:爆破方每次失败都顺手帮自己把计数清零。安全计数器必须活过它所记录的
那次失败。
代价是这个方法在失败路径上会提交一次,所以请在请求的早期调用它,别在同一个会话里 攒了一堆待写的东西之后才调。
账号管理
账号建好之后的那些操作,「人员管理」页要的一套都在这里。签名风格统一:
查无此人返回 False(不抛),commit 默认 True。
| 方法 | 干什么 |
|---|---|
set_role(session, username, role) |
改角色(提成管理员 / 降回访客) |
set_role_many(session, usernames, role) |
批量改角色,返回实际改到的条数 |
set_active(session, username, active) |
启用 / 停用 |
set_username(session, username, new_username) |
改用户名,撞车抛 UsernameTaken |
set_password(session, username, password) |
管理员重置密码,不问旧密码 |
change_password(session, username, old, new) |
自助改密,校验旧密码 |
list_users(session, *, role, is_active, keyword, offset, limit) |
列表,带过滤和分页 |
count_users(session, *, role, is_active, keyword) |
同样的过滤条件出总数 |
没有删除账号的方法,这是设计不是遗漏:停用走 is_active,删了行之后会话和
外部系统里存的 user_id 就是悬空引用,而那个 id 以后可能被新账号复用 —— 等于把
旧会话悄悄接到新账号上。
改角色
from funauth import UserRole
await accounts.set_role(session, "reader", UserRole.ADMIN) # 提成管理员
await accounts.set_role(session, "boss", UserRole.GUEST) # 降回访客
await accounts.set_role_many(session, ["a", "b"], UserRole.GUEST) # 勾选多行
最后一个管理员:不许降级,也不许停用
把最后一个在用的管理员降级或停用,会抛 LastAdmin:
await accounts.set_role(session, "boss", UserRole.GUEST)
# LastAdmin: 这是最后一个在用的管理员,请先把别人提成管理员再操作
await accounts.set_active(session, "boss", False) # 同样拦住
降完就没人进得去后台了,而点「降为访客」的人正在整理名单,完全不会想到这一层 —— 补救只能去命令行。两个方法行为一致:只拦住降级却放过停用,等于把同一个坑留了 另一个入口。
判定只算在用的管理员(role == ADMIN and is_active)—— 停用的管理员登不进来,
拿他给自己壮胆等于没有护栏。反过来,不会让这个数变少的操作一律不拦:提升、启用、
把管理员再设成管理员、改访客,都照常走(锁死之后那几条恰恰是唯一的救场路径)。
明知故犯有出口:
await accounts.set_role(session, "boss", UserRole.GUEST, allow_last_admin=True)
迁移脚本、拆站、或者你自己另有后门入口时用。它刻意没有被做成 HTTP 查询参数 —— 一个点一下就能关掉的安全护栏比没有护栏更糟。
要在界面上提前把按钮灰掉,自己数一下:
await accounts.count_users(session, role=UserRole.ADMIN, is_active=True)
这条护栏挡不住并发:两个请求同时各降一个管理员,两边都会看到对方还在。
和用户名查重不同,这里没有库侧的兜底(唯一约束拦得住重名,拦不住「管理员归零」)。
真撞上了,救场方式是命令行里 create_user(..., UserRole.ADMIN)。
列表、过滤与分页
total = await accounts.count_users(session, keyword="wang", is_active=True)
users = await accounts.list_users(session, keyword="wang", is_active=True, offset=0, limit=50)
keyword 是用户名的模糊匹配、不区分大小写(用 ILIKE,否则 SQLite 和
PostgreSQL 会搜出不同结果),% / _ / \ 一律当字面量 —— 不转义的话微信注册
出来的 wechat_3f9c1d2a 这种名字一搜就乱套,而一个 % 就能匹配全表。
不传参数就是「全部账号、按用户名排序」,和以前一样。管理页应该传 limit:
账号表迟早会长到一次拉全表就是一个慢查询加一个大响应。
改名与改密
# 微信扫码进来的账号叫 wechat_3f9c1d2a,让他换一个能记住的
await accounts.set_username(session, "wechat_3f9c1d2a", "alice")
# 自助改密:校验旧密码(共用登录那套含糊消息和时序补偿)
await accounts.change_password(session, "alice", "old-pw", "new-pw")
改名不会把人踢下线 —— 会话里存的是 user.id 不是用户名。
改密同样不会让别处已登录的会话失效:funauth 不持有会话,也没有「密码世代」
这种列。要做到「改密即全端下线」得你自己来(记一个 password_changed_at,
会话里存上,每个请求比一次)。用户普遍以为改密会踢人,这件事要在界面上说清楚。
没设过密码的账号(微信 / 验证码注册的)调 change_password 会抛
PasswordNotSet,而不是含糊的「用户名或密码不正确」—— 调用方是从会话里拿到
用户名的,他已经证明了自己就是这个账号,含糊其辞只会让他对着表单反复试一个
从来不存在的旧密码。那种「第一次设密码」的流程校验完会话直接调 set_password。
异常
全是 RuntimeError 的子类,不是 HTTP 异常:
| 异常 | 什么时候抛 | 建议状态码 |
|---|---|---|
BadCredentials |
用户名不存在 / 密码不对 / 账号停用 / 没设过密码 | 401 |
UsernameTaken |
用户名已被占用 | 409 |
InviteUnusable |
邀请码不存在 / 已吊销 / 已过期 / 已用完 | 400 |
PermissionDenied |
角色不满足要求 | 403 |
VerificationFailed |
验证码不存在 / 不对 / 已过期 / 已用过 / 试错超限 | 400 |
VerificationThrottled |
发送过于频繁 | 429 |
AccountDisabled |
凭据对,但账号被停用 | 403 |
SignupDisabled |
这个外部身份没绑过账号,且注册未开放 | 403 |
IdentityTaken |
这个外部身份已绑在别的账号上 | 409 |
LastLoginMethod |
不能解绑:这是最后一种登录方式 | 409 |
LastAdmin |
不能降级 / 停用:这是最后一个在用的管理员 | 409 |
PasswordNotSet |
改密时发现这个账号从来没设过密码 | 400 |
BadCredentials(四种)、InviteUnusable(四种)、VerificationFailed(五种)
各自共用一条消息,刻意不区分:分开报的话登录接口就是个用户名枚举器(拿字典
刷一遍,回「密码不对」的那些就是真实存在的账号),注册接口就是个「这个码存不存在」
的探测器。真正需要知道区别的是运维自己,而运维看得到数据库。
而 AccountDisabled / SignupDisabled / IdentityTaken / PasswordNotSet
刻意说实话 —— 能走到它们的前提是对方已经证明了自己拥有那个外部身份(或者
已经登录了),含糊其辞不多保护任何东西,只会让他去反复重试一个永远不会成功的
操作。
LastLoginMethod 和 LastAdmin 是另一类:它们不是「你错了」,是「这件事会把
人锁在门外,确认一下」。两条都可以关 —— 前者先设个密码,后者传
allow_last_admin=True。
FastAPI
pip install "funauth[fastapi]" 之后,路由和门禁都是现成的:
from funauth.contrib.fastapi import CookieSessionStore, make_auth_router, make_user_deps
store = CookieSessionStore()
guard = make_user_deps(accounts=accounts, session_dep=SessionDep, store=store)
app.add_middleware(SessionMiddleware, secret_key=settings.secret_key)
app.include_router(
make_auth_router(
accounts=accounts,
session_dep=SessionDep,
store=store,
registration_enabled_dep=RegistrationEnabledDep,
),
prefix="/api/v1",
)
make_auth_router 挂出五个端点,全部公开 —— 否则没登录的人连登录接口都打不开:
| 端点 | 说明 |
|---|---|
GET /auth/config |
{"registration_enabled": bool},前端据此决定要不要渲染注册入口 |
POST /auth/login |
成功写会话并返回用户;失败 401 |
POST /auth/logout |
204 |
GET /auth/me |
当前登录者,没登录返回 null 而不是 401 |
POST /auth/register |
凭邀请码注册,201 / 400 / 409;invite_model 为 None 时不挂这条 |
验证码登录的路由
make_code_login_router 同时服务邮箱和短信 —— 两者在本包里是同一条链路,只有
provider 和发送通道不同。要两种都上就调两次:
from funauth import AuthProvider
from funauth.contrib.fastapi import make_code_login_router
app.include_router(
make_code_login_router(
accounts=accounts,
session_dep=SessionDep,
store=store,
provider=AuthProvider.EMAIL,
sender=send_email, # -> /auth/email/*
registration_enabled_dep=RegistrationEnabledDep,
)
)
app.include_router(
make_code_login_router(
accounts=accounts,
session_dep=SessionDep,
store=store,
provider=AuthProvider.PHONE,
sender=send_sms, # -> /auth/phone/*
min_interval_seconds=120, # 短信更贵,隔久点
)
)
sender 是宿主提供的 async (target, code) -> None。别在里面记明文日志 ——
那等于把所有人的验证码写进日志系统。
| 端点 | 说明 |
|---|---|
POST /auth/{provider}/code |
签发并发送,204;限频 429(带 Retry-After);发送失败 502 |
POST /auth/{provider}/login |
校验 + 登录 + 没注册过就建号,200 / 400 / 403 |
两条都全部公开。/code 对任何标识都照发,不管它注册过没有 —— 验证码登录本身
就兼注册,「没注册」不是一种失败。注册开关关着时也照发,只在 /login 那步回 403:
看起来绕,但另一种做法(没注册就不发)等于把「这个邮箱注册过没有」做成一个公开
查询接口,而在 /login 泄漏只泄漏给能收到这封邮件的人 —— 也就是那个邮箱的主人。
发送失败时会把刚签发的那条记录删掉再回 502:不删的话用户既没收到码、又要被 限频挡 60 秒,而这完全是服务端的故障。
整站门禁 + 后台管理员,两道门
典型需求是「整站要登录才能看,后台还要管理员」。这是两个依赖,不是一个,
make_user_deps 一次给出来,直接写在 handler 签名里:
@router.get("/works")
async def list_works(session: SessionDep, _: guard.current_user): ... # 登录即可
@router.get("/stats")
async def stats(session: SessionDep, _: guard.admin_user): ... # 还要 ADMIN
匿名拿 401,登录了但不是管理员拿 403(不是 404 —— 路由表在前端代码里本来就是 公开的,装作「没有这个接口」除了让人困惑没有别的收益)。
门禁要落在后端。纯前端路由守卫挡不住直接 curl 接口 —— 静态文件服务器和反向
代理层通常没有鉴权,前端拦一道等于没拦。
人员管理路由
「列出账号、把访客提成管理员、停用一个号」这套后台页,make_admin_router 直接
给现成的:
from funauth.contrib.fastapi import make_admin_router
app.include_router(
make_admin_router(accounts=accounts, session_dep=SessionDep, guards=guard),
prefix="/api/v1",
)
| 端点 | 说明 |
|---|---|
GET /admin/users |
分页列表,query:role / is_active / keyword / offset / limit(默认 50,上限 200)。回 {"total": N, "items": [...]} |
PATCH /admin/users/{user_id}/role |
body {"role": "admin"},回更新后的账号 |
PATCH /admin/users/{user_id}/active |
body {"is_active": false},回更新后的账号 |
整组要 ADMIN:没登录 401、登录了但不是管理员 403、查无此人 404、
改到自己 409、撞上最后一个管理员 409。
收的是整个 guards(make_user_deps 的返回值)而不是一个裸依赖:收裸依赖的话
手滑传成 guards.current_user,造出来的就是一组任何登录用户都能改别人角色的
接口,而且不会有任何报错。guards 必须和 make_auth_router 用同一个 store。
默认出参是 AdminUserOut(比 UserOut 多一个 is_active,管理表格第一列就是
那个开关)。寻址用 user_id 而不是用户名 —— 用户名现在可以改。
默认不许改自己(降级、停用),手滑点到自己那一行的后果是当场失去后台,而这个
确认 HTTP 层问不出来;把自己设成管理员、启用自己这类空操作照常放行。要「交接完
自己退下」就传 allow_self_change=True,那时还有 LastAdmin 兜着。
没有删除端点,理由见上面账号管理那一节。自助改密不在这里,也
不在 make_auth_router 里(那个工厂的不变量是「每条路由都公开」)—— 自己拿
guard.current_user 挂一条:
@router.post("/me/password")
async def change_my_password(payload: PwPayload, session: SessionDep, me: guard.current_user):
try:
await accounts.change_password(session, me.username, payload.old, payload.new)
except PasswordNotSet as err:
raise HTTPException(400, detail=str(err)) from err
except BadCredentials as err:
raise HTTPException(401, detail=str(err)) from err
会话里只存 user_id
默认的 CookieSessionStore 走 Starlette 的签名 cookie(宿主自己装
SessionMiddleware,secret_key / 有效期 / https_only 都是部署决定)。它只存
user_id,不存角色 —— 角色每个请求重新查库,这样停用或降级一个账号之后他手上
那张还没过期的 cookie 立刻失效,而不是等到下次登录。
走 JWT 或 Redis 的宿主实现 SessionStore 协议即可,三个方法:
class SessionStore(Protocol):
def login(self, request: Request, user_id: str) -> None: ...
def current(self, request: Request) -> str | None: ...
def logout(self, request: Request) -> None: ...
会话里存的是字符串,而主键是 UUID,所以读回来要转一次。转不过去(上一套会话
方案留下的 cookie、换过 secret_key、你的 store 存了别的结构)按「登录已失效」处理
—— 清掉会话、门禁回 401、/me 回 null,不会变成 500。
主键不是 UUID 的宿主传 parse_user_id 换掉这一步,并且把 user_out 一起换掉
(UserOut.id 是 uuid.UUID,两处必须对得上):
guard = make_user_deps(accounts=accounts, session_dep=SessionDep, store=store, parse_user_id=int)
funauth 不起服务
包里没有 FastAPI() 实例、没有 uvicorn 入口,只导出一个装好的 APIRouter
跑在宿主进程里。有三件事必须共享进程才成立:
- 事务。凭码注册要让「扣名额」和「建账号」同生共死,现在它就是一个
session.commit()。跨进程就得靠补偿逻辑或分布式事务。 - cookie 同域。签名 cookie 必须同域才会带上。
- 外键。
user表长在宿主的Base上,宿主自己的表能正常 FK 引用user.id。
代价是宿主必须是 Python + FastAPI + SQLAlchemy async。要给 Go / Node 服务共用
同一批账号才需要真起一个服务 —— 届时加个 server.py 把同一个 router 挂到一个新
FastAPI() 上,现有宿主一行不用改。另外同一个库上有多个宿主时,schema 变更仍然
走宿主自己的 alembic,共库的宿主要一起升级。
邀请码为什么是一条 UPDATE
consume_invite 把「还能不能用」整个塞进 WHERE,由数据库裁决:
UPDATE invite_code SET used_count = used_count + 1
WHERE code = :code AND is_active
AND used_count < max_uses
AND (expires_at IS NULL OR expires_at > :now)
rowcount == 0 就是不可用。先 SELECT 判断再 UPDATE 的写法在并发下会把一张
max_uses=1 的码兑出两个账号:两个请求都读到 used_count == 0、都认为还有名额。
这种 bug 在单线程测试里看不出来,所以测试直接盯住发出去的语句(条数必须是 1、
WHERE 里每个守卫都必须在)。
consume_invite 自己不提交:register_with_invite 要让「扣名额」和「建账号」
同生共死 —— 建号那步因为用户名撞车失败时,名额跟着回滚,否则一张码会因为别人
手滑输了个重名用户名而白白少一次,而签发的人完全看不出为什么。
往后再加登录方式
再接一家第三方不用改本包。 抖音、飞书、GitHub OAuth 都是「第三方给一个稳定
id」这个形状:宿主握完手调 login_with_identity,这边只是 AuthProvider 多一个
成员,没有 DDL 变化、没有新代码。
需要新 mixin 的是形状不同的方式,比如 WebAuthn / passkey(要存公钥、要走
challenge-response)。那时候照 services/identity.py 的样子写一个挂到 Accounts
上即可,User 表同样不用动 —— 多种登录方式共用一个账号,靠身份表关联,而不是给
User 不断加列。
从 0.2.1 及更早升级
User.password_hash 现在是可空的(微信 / 验证码注册出来的账号没有密码),
已有库需要一条迁移:
ALTER TABLE "user" ALTER COLUMN password_hash DROP NOT NULL;
authenticate 对空哈希一律判失败,并且和「用户不存在」共用同一条消息和同一份
耗时,所以放开之后不会出现「空密码能登进去」。
要用新的登录方式才需要建 identity / verification_code 两张表;只用密码和邀请码
的话,除了上面那条 ALTER 不用动任何东西。
源码导览
每个模块的 docstring 里写的是为什么这么设计,比这份 README 更细。要改某块 之前先读那里 —— 很多写法是踩过坑才那样的,注释里记着是哪个坑。
| 文件 | 内容 | docstring 里回答了什么 |
|---|---|---|
models/mixins.py |
四张表的 mixin | 为什么导出 mixin 而不是现成模型类;唯一约束和外键为什么留给宿主;时间列为什么是可选的 |
models/types.py |
PkType、uuid7()、UTCDateTime |
为什么自己实现 uuid7;SQLite 和 PostgreSQL 的时区行为差异怎么抹平 |
enums.py |
UserRole、AuthProvider |
为什么枚举列不建 CHECK 约束;角色为什么不做数值比较 |
errors.py |
AuthError 及子类 |
哪些失败必须共用一条消息,哪些该说实话,各自的理由;最后一个管理员为什么拦得住人、拦不住并发 |
security.py |
bcrypt 两函数 | 为什么不用 passlib;为什么脏哈希返回 False 而不抛 |
services/base.py |
AccountsBase、账号查询 |
为什么是实例持有而不是模块级全局;事务边界那四档;列表过滤为什么用 ILIKE 且要转义通配符 |
services/password.py |
密码登录、建号改密、角色与启停 | 为什么失败路径也要跑一次 bcrypt(时序侧信道);最后一个管理员的护栏为什么只写一处、批量为什么要整批一起判 |
services/invite.py |
邀请码 | 为什么消耗必须是一条条件 UPDATE 且不提交 |
services/identity.py |
外部身份 | 为什么握手不在包内;为什么绝不按邮箱自动合并账号;并发怎么靠唯一约束兜住 |
services/verification.py |
邮箱 / 短信验证码 | 为什么这条链路反而整个在包内;试错计数为什么单独提交;标识为什么要规整 |
contrib/fastapi.py |
路由与门禁工厂 | 为什么不自带 FastAPI() 实例;注册开关为什么必须是个依赖而不是 bool |
测试也是文档 —— 每条测试的 docstring 写的是「它在防哪个具体的 bug」:
| 文件 | 钉住什么 |
|---|---|
test_accounts.py |
密码登录、角色、邀请码全生命周期、凭码注册、改角色 / 改名 / 改密、列表过滤分页 |
test_transactions.py |
事务那四档行为;本包逻辑不依赖宿主的时间列 |
test_timing.py |
两条失败路径的哈希次数相同(测次数而不是墙上时钟,后者必然 flaky) |
test_identity.py |
绑定/解绑、不自动合并账号、无密码账号不会把登录接口打成 500 |
test_verification.py |
限频、限次、一次性、标识规整、试错计数活过 rollback |
test_fastapi.py |
真起 app 打请求:两道门的状态码、cookie 隔离、自定义 store、脏会话 |
test_fastapi_code_login.py |
验证码路由端到端:明文不入响应、发送失败回滚、爆破被拦 |
test_fastapi_admin.py |
人员管理路由:门禁、分页、改自己被拦、最后一个管理员 409;以及提交后序列化不会炸成 500 |
test_packaging.py |
py.typed 在位(没它下游拿不到类型标注) |
开发
# 首次:把源码装成 editable。装成拷贝的话跑测试测的是 site-packages 里的旧代码,
# 而那种情况下测试照样全绿 —— 你改的东西压根没被执行到。
uv sync
uv run --group dev pytest -q
uv run --group dev ruff format src tests
uv run --group dev ruff check --fix src tests
funbuild build 发完版后会把 venv 里的 editable 换成刚构建的 wheel,所以发版之后
再开发前要重新 uv sync,否则又回到上面那个「测的是拷贝」的状态。
改动对外行为(公开 API、表结构、HTTP 行为、安全性质)时,同时更新 CHANGELOG.md 的「未发布」一节。纯内部重构不用记 —— 那些看 git log。
关于 farfarfun
farfarfun 是一个专注于实用工具库的开源组织, 涵盖云存储、数据处理、AI、多媒体与开发工具链等方向。
- 🏠 组织主页:https://github.com/farfarfun
- 📦 PyPI:https://pypi.org/user/niuliangtao/
- 📧 联系:farfarfun@qq.com
本项目基于 MIT 协议开源。
Metadata
Release files for funauth 0.2.3
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| funauth-0.2.3-py3-none-any.whl | Python 3 | none | any | Details |
Release files / funauth-0.2.3-py3-none-any.whl
| Download URL | funauth-0.2.3-py3-none-any.whl |
|---|---|
| Size | 70.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
339c52fb2d57819835bdc88c176739d581b6bfe2b8f4b4904edf17f2e5420a92
|
|
BLAKE2b-256 checksum How to use checksums |
ed96deac5c0493546fd7f1bfd985dccef518bdb9d3c22c20ae6b8683ca4eb33d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.17 {"installer":{"name":"uv","version":"0.11.17","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|