Skip to main content

funauth

funauth 是一个可复用的登录与注册底座:密码登录、两级角色、邀请码注册、 微信 / QQ 扫码、邮箱 / 短信验证码。表结构以 mixin 形式提供,具体表长在宿主 项目自己的 Base 上 —— 一套 MetaData、一条迁移链、外键照常写。

版本变化见 CHANGELOG.md。

目录

特性

四条登录路,按需取用 —— 不用的那条不传对应模型即可,没有空表也没有空列:

方式 入口 需要的模型
用户名 + 密码 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
  • 邀请码:限次、限期、可单独吊销,名额消耗是一条条件 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、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,次数要是跟着回滚,这个上限就 完全不存在:爆破方每次失败都顺手帮自己把计数清零。安全计数器必须活过它所记录的 那次失败。

代价是这个方法在失败路径上会提交一次,所以请在请求的早期调用它,别在同一个会话里 攒了一堆待写的东西之后才调。

异常

全是 RuntimeError 的子类,不是 HTTP 异常:

异常 什么时候抛 建议状态码
BadCredentials 用户名不存在 / 密码不对 / 账号停用 / 没设过密码 401
UsernameTaken 用户名已被占用 409
InviteUnusable 邀请码不存在 / 已吊销 / 已过期 / 已用完 400
PermissionDenied 角色不满足要求 403
VerificationFailed 验证码不存在 / 不对 / 已过期 / 已用过 / 试错超限 400
VerificationThrottled 发送过于频繁 429
AccountDisabled 凭据对,但账号被停用 403
SignupDisabled 这个外部身份没绑过账号,且注册未开放 403
IdentityTaken 这个外部身份已绑在别的账号上 409
LastLoginMethod 不能解绑:这是最后一种登录方式 409

BadCredentials(四种)、InviteUnusable(四种)、VerificationFailed(五种) 各自共用一条消息,刻意不区分:分开报的话登录接口就是个用户名枚举器(拿字典 刷一遍,回「密码不对」的那些就是真实存在的账号),注册接口就是个「这个码存不存在」 的探测器。真正需要知道区别的是运维自己,而运维看得到数据库。

而 AccountDisabled / SignupDisabled / IdentityTaken 刻意说实话 —— 能走到它们的前提是对方已经证明了自己拥有那个外部身份,含糊其辞不多保护任何东西, 只会让他去反复重试一个永远不会成功的操作。

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 接口 —— 静态文件服务器和反向 代理层通常没有鉴权,前端拦一道等于没拦。

会话里只存 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 跑在宿主进程里。有三件事必须共享进程才成立:

  1. 事务。凭码注册要让「扣名额」和「建账号」同生共死,现在它就是一个 session.commit()。跨进程就得靠补偿逻辑或分布式事务。
  2. cookie 同域。签名 cookie 必须同域才会带上。
  3. 外键。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.x 升级

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 为什么是实例持有而不是模块级全局;事务边界那四档
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_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

改动对外行为(公开 API、表结构、HTTP 行为、安全性质)时,同时更新 CHANGELOG.md 的「未发布」一节。纯内部重构不用记 —— 那些看 git log。

许可证

MIT,见 LICENSE。

Metadata

Release files for funauth 0.2.2

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Built distribution (wheel)

Table of built distributions (wheels) for funauth 0.2.2
File Interpreter ABI Platform
funauth-0.2.2-py3-none-any.whl Python 3 none any Details

Release files / funauth-0.2.2-py3-none-any.whl

Download URL funauth-0.2.2-py3-none-any.whl
Size 57.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
840d4756b6a940c22ce5e500fc26e8c91d34047c4bf915dccd7252f575ff3715
BLAKE2b-256 checksum
How to use checksums
fa3e72817b1752fc0e1d357522dede1c327072dac57c28e631b0b3f9cd8f3ca4
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}

Release history Release notifications | RSS feed

0.2.3

1 release file

This release

0.2.2 This release

1 release file

0.2.1

1 release file

0.2.0

1 release file

0.1.2

1 release file

0.1.1

1 release file

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page