funauth 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
funauth/__init__.py ADDED
@@ -0,0 +1,86 @@
1
+ """funauth —— 可复用的登录与注册底座。
2
+
3
+ ## 现在有什么
4
+
5
+ - 密码登录 + 两级角色(`UserRole.ADMIN` / `GUEST`)
6
+ - 邀请码注册:限次、限期、可单独吊销,消耗是原子的
7
+ - bcrypt 密码哈希
8
+ - 表结构以 **mixin** 形式提供,具体表由宿主声明在自己的 `Base` 上
9
+ (理由见 `funauth.models.mixins` 的模块 docstring)
10
+
11
+ ## 不负责什么
12
+
13
+ **不依赖任何 web 框架**,也不管 session / cookie / JWT。本包只回答「这个凭据
14
+ 对不对、这个人是什么角色」,怎么维持登录态是宿主的事(Starlette 的签名 cookie、
15
+ Redis session、JWT 都行)。抛出来的是 `AuthError` 子类,宿主自己翻译成状态码。
16
+
17
+ 同样不管事务边界:每个方法收一个 `AsyncSession`,由调用方决定什么时候提交。
18
+
19
+ ## 三步接起来
20
+
21
+ ```python
22
+ # 1. 在自己的 Base 上声明表
23
+ import sqlalchemy as sa
24
+ from funauth.models import InviteCodeMixin, UserMixin
25
+
26
+ class User(UserMixin, TimestampMixin, Base):
27
+ __tablename__ = "user"
28
+ __table_args__ = (sa.UniqueConstraint("username", name="uq_user_username"),)
29
+
30
+ class InviteCode(InviteCodeMixin, TimestampMixin, Base):
31
+ __tablename__ = "invite_code"
32
+ __table_args__ = (sa.UniqueConstraint("code", name="uq_invite_code_code"),)
33
+
34
+ # 2. 建一个单例(放在自己的 services/account.py 之类的绑定模块里)
35
+ from funauth import Accounts
36
+ accounts = Accounts(user_model=User, invite_model=InviteCode)
37
+
38
+ # 3. 用
39
+ from funauth import BadCredentials
40
+ try:
41
+ user = await accounts.authenticate(session, username, password)
42
+ except BadCredentials as err:
43
+ raise HTTPException(401, detail=str(err)) from err
44
+ ```
45
+
46
+ FastAPI 侧完整的两道门(整站要登录 + 后台要管理员)见 README。
47
+
48
+ ## 往后加登录方式
49
+
50
+ 邮箱验证码、短信验证码、QQ / 微信扫码都按同一形状接:各写一个
51
+ `services/*.py` 里的 mixin 挂到 `Accounts` 上,需要存外部身份就再导出一个表
52
+ mixin。`User` 表不用动 —— 多种登录方式共用一个账号,靠一张
53
+ `(provider, external_id) -> user_id` 的身份表关联,而不是给 `User` 不断加列。
54
+ """
55
+
56
+ from funauth.enums import UserRole, enum_col
57
+ from funauth.errors import (
58
+ AuthError,
59
+ BadCredentials,
60
+ InviteUnusable,
61
+ PermissionDenied,
62
+ UsernameTaken,
63
+ )
64
+ from funauth.models import InviteCodeMixin, TimestampMixin, UserMixin
65
+ from funauth.security import hash_password, verify_password
66
+ from funauth.services import Accounts, describe_invite_status, generate_code
67
+
68
+ __version__ = "0.1.0"
69
+
70
+ __all__ = [
71
+ "Accounts",
72
+ "AuthError",
73
+ "BadCredentials",
74
+ "InviteCodeMixin",
75
+ "InviteUnusable",
76
+ "PermissionDenied",
77
+ "TimestampMixin",
78
+ "UserMixin",
79
+ "UserRole",
80
+ "UsernameTaken",
81
+ "describe_invite_status",
82
+ "enum_col",
83
+ "generate_code",
84
+ "hash_password",
85
+ "verify_password",
86
+ ]
funauth/enums.py ADDED
@@ -0,0 +1,36 @@
1
+ """枚举与枚举列。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import StrEnum
6
+
7
+ import sqlalchemy as sa
8
+
9
+
10
+ def enum_col(py_enum: type[StrEnum], length: int = 32) -> sa.Enum:
11
+ """把 Python StrEnum 映射成 VARCHAR,不生成数据库侧的 CHECK 约束。
12
+
13
+ 不生成 CHECK 是刻意的:枚举加一个成员就要写一条迁移去改约束,而这类
14
+ 枚举(登录方式、角色)本来就是会持续扩张的。
15
+ """
16
+ return sa.Enum(
17
+ py_enum,
18
+ native_enum=False,
19
+ create_constraint=False,
20
+ length=length,
21
+ values_callable=lambda e: [m.value for m in e],
22
+ )
23
+
24
+
25
+ class UserRole(StrEnum):
26
+ """账号角色,只有两级。
27
+
28
+ `GUEST` 是自助注册能拿到的唯一角色;`ADMIN` 只能由服务端显式创建
29
+ (`Accounts.create_user`)。
30
+
31
+ 两级之间**不做数值比较**,判定一律写 `role != ADMIN` 这种精确相等 ——
32
+ 以后真要加第三级(比如只读审计员),枚举的定义顺序不该悄悄决定它的权限。
33
+ """
34
+
35
+ ADMIN = "admin"
36
+ GUEST = "guest"
funauth/errors.py ADDED
@@ -0,0 +1,35 @@
1
+ """账号域的异常。
2
+
3
+ 全是 `RuntimeError` 子类,**不是 HTTP 异常** —— 这个包不依赖任何 web 框架,
4
+ 状态码由调用方翻译(见 README 里 FastAPI 的例子)。异常消息是面向终端用户的
5
+ 中文,可以直接当 `detail` 回给前端。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+
11
+ class AuthError(RuntimeError):
12
+ """本包所有异常的基类。调用方可以只捕这一个。"""
13
+
14
+
15
+ class BadCredentials(AuthError):
16
+ """用户名不存在、密码不对、或账号已停用。
17
+
18
+ 三种情况**共用一条消息**,见 `Accounts.authenticate` 的说明。
19
+ """
20
+
21
+
22
+ class UsernameTaken(AuthError):
23
+ """用户名已被占用。"""
24
+
25
+
26
+ class InviteUnusable(AuthError):
27
+ """邀请码不存在 / 已吊销 / 已过期 / 已用完。
28
+
29
+ 四种情况共用一条消息:分开报等于把注册接口变成「这个码存不存在」的
30
+ 探测器,攻击者能靠枚举问出哪些码是真的、只是暂时用完了。
31
+ """
32
+
33
+
34
+ class PermissionDenied(AuthError):
35
+ """已登录,但角色不够。"""
@@ -0,0 +1,14 @@
1
+ """表结构(mixin)与可移植列类型。"""
2
+
3
+ from funauth.models.mixins import InviteCodeMixin, TimestampMixin, UserMixin
4
+ from funauth.models.types import PkType, UTCDateTime, utcnow, uuid7
5
+
6
+ __all__ = [
7
+ "InviteCodeMixin",
8
+ "PkType",
9
+ "TimestampMixin",
10
+ "UTCDateTime",
11
+ "UserMixin",
12
+ "utcnow",
13
+ "uuid7",
14
+ ]
@@ -0,0 +1,96 @@
1
+ """表结构以 **mixin** 形式提供,具体表由宿主项目声明。
2
+
3
+ ## 为什么不直接导出现成的模型类
4
+
5
+ 一个 declarative 模型必须绑在某个 `Base`(也就是某个 `MetaData`)上。如果本包
6
+ 自带 `Base`,宿主项目的库里就有两套 `MetaData`:
7
+
8
+ - `Base.metadata.create_all()` 建不出本包的表,宿主的测试夹具得额外记得建一遍;
9
+ - Alembic 的 `target_metadata` 得写成列表,否则 autogenerate 会兴冲冲地生成
10
+ 「DROP TABLE user」—— 它看不见那张表的定义,只看见库里多了一张表;
11
+ - 宿主自己的表想外键引用 `user.id` 时跨 `MetaData`,得退化成字符串引用。
12
+
13
+ 导出 mixin 就没这些问题:表长在宿主的 `Base` 上,一套 `MetaData`、一条迁移链、
14
+ 外键照常写。代价是宿主要自己写一行类声明和 `__table_args__`,很便宜。
15
+
16
+ ```python
17
+ from funauth.models import InviteCodeMixin, UserMixin
18
+
19
+ class User(UserMixin, TimestampMixin, Base):
20
+ __tablename__ = "user"
21
+ __table_args__ = (sa.UniqueConstraint("username", name="uq_user_username"),)
22
+ ```
23
+
24
+ 唯一约束刻意**不**放进 mixin:约束名进迁移、进 `ON CONFLICT`,宿主得能看见也能
25
+ 改名(已经建好的库里那个名字是什么样就得是什么样,不能由本包的版本决定)。
26
+
27
+ ## 时间列
28
+
29
+ `TimestampMixin` 这里也给了一份,但宿主已经有自己的就用自己的 —— 两边 DDL
30
+ 一致(都是 `DateTime(timezone=True)`),混用不需要迁移。
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import uuid
36
+ from datetime import datetime
37
+
38
+ import sqlalchemy as sa
39
+ from sqlalchemy.orm import Mapped, mapped_column
40
+
41
+ from funauth.enums import UserRole, enum_col
42
+ from funauth.models.types import PkType, UTCDateTime, utcnow, uuid7
43
+
44
+
45
+ class TimestampMixin:
46
+ """创建 / 更新时间。默认值在 Python 侧生成,不依赖数据库函数。"""
47
+
48
+ created_at: Mapped[datetime] = mapped_column(
49
+ UTCDateTime, default=utcnow, nullable=False, index=True
50
+ )
51
+ updated_at: Mapped[datetime] = mapped_column(
52
+ UTCDateTime, default=utcnow, onupdate=utcnow, nullable=False
53
+ )
54
+
55
+
56
+ class UserMixin:
57
+ """登录账号:用户名 + bcrypt 密码哈希 + 角色 + 启用标记。
58
+
59
+ 不存明文密码,也不做可逆加密。停用走 `is_active` 而不是删除行 —— 否则
60
+ session / token 里存的 user_id 会变成悬空引用,而那个 id 以后可能被别的
61
+ 新账号复用,等于把旧会话悄悄接到新账号上。
62
+ """
63
+
64
+ id: Mapped[uuid.UUID] = mapped_column(PkType, primary_key=True, default=uuid7)
65
+ username: Mapped[str] = mapped_column(sa.String(64), nullable=False)
66
+ #: bcrypt 哈希(含盐)
67
+ password_hash: Mapped[str] = mapped_column(sa.String(128), nullable=False)
68
+ #: 默认取 `GUEST` 而不是 `ADMIN`:漏传时往最小权限掉,而不是凭空多出一个
69
+ #: 管理员。要建管理员必须显式写出来(`Accounts.create_user`)。
70
+ role: Mapped[UserRole] = mapped_column(
71
+ enum_col(UserRole, length=16), nullable=False, default=UserRole.GUEST
72
+ )
73
+ is_active: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, default=True)
74
+
75
+
76
+ class InviteCodeMixin:
77
+ """一张注册邀请码:码值 + 可用次数 + 过期时间 + 启用标记。
78
+
79
+ 「还能不能用」是三个条件的合取,判定只许写在 `Accounts.consume_invite` 的
80
+ 那条条件 UPDATE 里 —— 在别处重新拼一遍等于埋一个会和它悄悄分叉的副本。
81
+ """
82
+
83
+ id: Mapped[uuid.UUID] = mapped_column(PkType, primary_key=True, default=uuid7)
84
+ #: 码值本身。只取大写字母和数字、去掉易混的 0O1Il,方便口头/截图转述
85
+ code: Mapped[str] = mapped_column(sa.String(32), nullable=False)
86
+ #: 总共能换出几个账号
87
+ max_uses: Mapped[int] = mapped_column(sa.Integer, nullable=False, default=1)
88
+ #: 已经换出去几个。只由 `consume_invite` 的条件 UPDATE 原子递增
89
+ used_count: Mapped[int] = mapped_column(sa.Integer, nullable=False, default=0)
90
+ #: 空 = 永不过期
91
+ expires_at: Mapped[datetime | None] = mapped_column(UTCDateTime)
92
+ #: 吊销走标记而不是删行 —— 和 `UserMixin.is_active` 同一个理由:保留「这张码
93
+ #: 换出去过几个账号」的痕迹,删掉就查不出来了
94
+ is_active: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, default=True)
95
+ #: 备注,给签发的人自己记「这张给谁的」
96
+ note: Mapped[str | None] = mapped_column(sa.Text)
@@ -0,0 +1,74 @@
1
+ """可移植的列类型与主键生成。
2
+
3
+ 宿主项目往往已经有自己的一套(funflix 就有),那就用宿主的 —— 这里提供的是
4
+ 给**还没有**的项目用的默认实现。两边 DDL 刻意保持一致(`PkType` 都是
5
+ `sa.Uuid`、时间列都是 `DateTime(timezone=True)`),所以换过来不需要写迁移。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import time
12
+ import uuid
13
+ from datetime import UTC, datetime
14
+ from typing import Any
15
+
16
+ import sqlalchemy as sa
17
+
18
+ #: 主键。PostgreSQL 上是原生 uuid 列,SQLite 上退化成 CHAR(32) 存十六进制。
19
+ #: 客户端生成(见 `uuid7`),不依赖数据库分配。
20
+ PkType = sa.Uuid(as_uuid=True)
21
+
22
+
23
+ def utcnow() -> datetime:
24
+ """当前 UTC 时间(tz-aware)。"""
25
+ return datetime.now(UTC)
26
+
27
+
28
+ def uuid7() -> uuid.UUID:
29
+ """时间排序主键(RFC 9562 UUIDv7):48 位毫秒时间戳 + 74 位随机数。
30
+
31
+ 字典序等于生成顺序,所以 `ORDER BY id DESC` 就是「最新优先」,不用再加一列
32
+ 时间索引;同时全局唯一,多机并发生成不会撞号。
33
+
34
+ 标准库要到 3.14 才有 `uuid.uuid7()`,本包下限是 3.12,这里自己实现。
35
+ """
36
+ ts_ms = time.time_ns() // 1_000_000
37
+ rand = int.from_bytes(os.urandom(10), "big")
38
+ rand_a = (rand >> 62) & 0xFFF
39
+ rand_b = rand & 0x3FFFFFFFFFFFFFFF
40
+ value = ((ts_ms & 0xFFFFFFFFFFFF) << 80) | (0x7 << 76) | (rand_a << 64) | (0b10 << 62) | rand_b
41
+ return uuid.UUID(int=value)
42
+
43
+
44
+ class UTCDateTime(sa.types.TypeDecorator):
45
+ """始终以 UTC-aware datetime 进出的 DateTime。
46
+
47
+ SQLite 没有原生时间类型,`DateTime(timezone=True)` 读回来是 naive 的,而
48
+ PostgreSQL 读回来是 aware 的 —— 不处理的话同一份代码在两个库上行为不一致。
49
+ 这里在绑定期强制转 UTC、在返回期补齐 tzinfo,抹平差异。
50
+ """
51
+
52
+ impl = sa.DateTime(timezone=True)
53
+ cache_ok = True
54
+
55
+ def process_bind_param(self, value: datetime | None, dialect: Any) -> datetime | None:
56
+ """写库前把值统一转成 UTC。
57
+
58
+ Raises:
59
+ ValueError: 传入 naive datetime。宁可当场报错,也不猜它是哪个时区 ——
60
+ 猜错会让时间悄悄偏移几小时且永远查不出来。
61
+ """
62
+ if value is None:
63
+ return None
64
+ if value.tzinfo is None:
65
+ raise ValueError(f"拒绝写入 naive datetime: {value!r},请传带 tzinfo 的值")
66
+ return value.astimezone(UTC)
67
+
68
+ def process_result_value(self, value: datetime | None, dialect: Any) -> datetime | None:
69
+ """读库后补齐 tzinfo,保证无论哪个数据库取出来都是 UTC-aware 的。"""
70
+ if value is None:
71
+ return None
72
+ if value.tzinfo is None:
73
+ return value.replace(tzinfo=UTC)
74
+ return value.astimezone(UTC)
funauth/security.py ADDED
@@ -0,0 +1,40 @@
1
+ """密码哈希。
2
+
3
+ 直接用 `bcrypt`,不引入 passlib —— passlib 已多年未发版,对 bcrypt 4.x 的
4
+ `__about__` 变更没跟上,社区里一堆关于它报 warning/崩溃的 issue。bcrypt 库
5
+ 本身的 API 就两个函数,没有再包一层的必要。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import bcrypt
11
+
12
+
13
+ def hash_password(password: str) -> str:
14
+ """对明文密码做 bcrypt 哈希。
15
+
16
+ Args:
17
+ password: 用户输入的明文密码。
18
+
19
+ Returns:
20
+ 可直接落库的 bcrypt 哈希字符串(含算法标识与随机 salt)。
21
+ """
22
+ return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
23
+
24
+
25
+ def verify_password(password: str, password_hash: str) -> bool:
26
+ """校验明文密码是否与已存储的哈希匹配。
27
+
28
+ Args:
29
+ password: 登录时用户输入的明文密码。
30
+ password_hash: `hash_password` 生成并落库的哈希值。
31
+
32
+ Returns:
33
+ 匹配返回 `True`;不匹配,或 `password_hash` 本身不是合法的 bcrypt
34
+ 哈希格式(例如历史迁移遗留的明文)时返回 `False`。
35
+ """
36
+ try:
37
+ return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8"))
38
+ except ValueError:
39
+ # 哈希格式不对(比如库迁移时手滑存了明文):当作校验失败而不是 500
40
+ return False
@@ -0,0 +1,34 @@
1
+ """`Accounts` 门面。
2
+
3
+ 各登录方式一个 mixin,按需组合。目前只有密码登录(`PasswordMixin`)和邀请码
4
+ 注册(`InviteMixin`);邮箱验证码、短信验证码、QQ / 微信扫码往后按同一形状加
5
+ ——各自一个 mixin + 各自的 mixin 表,挂上来即可,`User` 表不用动。
6
+ """
7
+
8
+ from funauth.services.base import AccountsBase
9
+ from funauth.services.invite import (
10
+ InviteMixin,
11
+ describe_invite_status,
12
+ generate_code,
13
+ )
14
+ from funauth.services.password import PasswordMixin
15
+
16
+
17
+ class Accounts(InviteMixin, PasswordMixin, AccountsBase):
18
+ """账号域的全部操作。宿主建一个单例,别处 import 它。
19
+
20
+ ```python
21
+ accounts = Accounts(user_model=User, invite_model=InviteCode)
22
+ user = await accounts.authenticate(session, "someone", "pw")
23
+ ```
24
+ """
25
+
26
+
27
+ __all__ = [
28
+ "Accounts",
29
+ "AccountsBase",
30
+ "InviteMixin",
31
+ "PasswordMixin",
32
+ "describe_invite_status",
33
+ "generate_code",
34
+ ]
@@ -0,0 +1,60 @@
1
+ """`Accounts` 门面的基座:持有宿主的模型类。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from sqlalchemy import select
8
+ from sqlalchemy.ext.asyncio import AsyncSession
9
+
10
+
11
+ class AccountsBase:
12
+ """把宿主的模型类绑进来,各登录方式的 mixin 共用。
13
+
14
+ ## 为什么是一个实例而不是一组模块级函数
15
+
16
+ 本包不自带表(见 `funauth.models.mixins`),所以每个函数都得知道宿主的
17
+ `User` / `InviteCode` 类是哪个。三种办法:
18
+
19
+ 1. 每个函数多一个 `user_model` 参数 —— 调用方每次都要传,噪音大且传错了
20
+ 编译期发现不了;
21
+ 2. 模块级全局变量 + `configure()` —— import 顺序一变就是个 `None`,而且
22
+ 同一个进程里没法接两套表(测试、多租户都会撞);
23
+ 3. **实例持有**(本方案)—— 宿主在自己的绑定模块里建一个单例,别处 import
24
+ 它。没有全局可变状态,测试里想换表就再建一个实例。
25
+
26
+ ## 为什么不自己管 session
27
+
28
+ 每个方法都收一个 `AsyncSession`。宿主的事务边界各不相同(FastAPI 一个请求
29
+ 一个 session、CLI 一条命令一个、后台任务一批一个),本包无权决定。
30
+
31
+ 涉及多步写入的方法(`register_with_invite`)在内部**不提交**中间状态,让
32
+ 整串操作共享调用方的事务 —— 理由见那个方法的 docstring。
33
+ """
34
+
35
+ def __init__(self, *, user_model: Any, invite_model: Any | None = None) -> None:
36
+ """
37
+ Args:
38
+ user_model: 宿主声明的 User 类,必须带 `UserMixin` 的那几列。
39
+ invite_model: 宿主声明的 InviteCode 类。不打算用邀请码注册可以不传,
40
+ 传 `None` 时调用邀请码相关方法会 `AttributeError` —— 刻意不做
41
+ 优雅降级,那只会把「忘了配」变成运行时的静默失败。
42
+ """
43
+ self.user_model = user_model
44
+ self.invite_model = invite_model
45
+
46
+ async def get_by_username(self, session: AsyncSession, username: str) -> Any | None:
47
+ """按用户名取账号,没有返回 `None`。"""
48
+ return await session.scalar(
49
+ select(self.user_model).where(self.user_model.username == username)
50
+ )
51
+
52
+ async def get_by_id(self, session: AsyncSession, user_id: Any) -> Any | None:
53
+ """按主键取账号,没有返回 `None`。会话校验用。"""
54
+ return await session.get(self.user_model, user_id)
55
+
56
+ async def list_users(self, session: AsyncSession) -> list[Any]:
57
+ """列出全部账号,按用户名排序。"""
58
+ return list(
59
+ await session.scalars(select(self.user_model).order_by(self.user_model.username))
60
+ )
@@ -0,0 +1,167 @@
1
+ """邀请码:签发、消耗、吊销,以及凭码注册。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import secrets
6
+ from datetime import datetime, timedelta
7
+ from typing import Any
8
+
9
+ from sqlalchemy import select, update
10
+ from sqlalchemy.ext.asyncio import AsyncSession
11
+
12
+ from funauth.enums import UserRole
13
+ from funauth.errors import InviteUnusable
14
+ from funauth.models.types import utcnow
15
+ from funauth.services.password import PasswordMixin
16
+
17
+ #: 码值字母表:大写字母 + 数字,去掉 `0O1I` 和小写 `l`。这些码要靠人念、靠
18
+ #: 截图转述,`0/O` 和 `1/I/l` 认错的概率太高,与其让人反复试不如直接不用。
19
+ ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
20
+ CODE_LENGTH = 8
21
+
22
+ #: 不存在 / 已吊销 / 已过期 / 已用完共用这一句,理由见 `InviteUnusable`。
23
+ UNUSABLE = "邀请码无效或已用完"
24
+
25
+
26
+ def generate_code() -> str:
27
+ """生成一个码值。用 `secrets` 而不是 `random` —— 这是凭据,不是随机数。"""
28
+ return "".join(secrets.choice(ALPHABET) for _ in range(CODE_LENGTH))
29
+
30
+
31
+ def describe_invite_status(code: Any, *, now: datetime | None = None) -> str:
32
+ """这张码当前处于什么状态,给管理界面 / CLI 显示用。
33
+
34
+ 判定条件和 `InviteMixin.consume_invite` 的 WHERE 一一对应。写歪了不会报错
35
+ (它只负责展示),但会出现「列表里写着可用、注册却说无效」这种最难查的
36
+ 不一致,所以两处要一起改。
37
+ """
38
+ moment = now or utcnow()
39
+ if not code.is_active:
40
+ return "已吊销"
41
+ if code.expires_at is not None and code.expires_at <= moment:
42
+ return "已过期"
43
+ if code.used_count >= code.max_uses:
44
+ return "已用完"
45
+ return "可用"
46
+
47
+
48
+ class InviteMixin(PasswordMixin):
49
+ """邀请码的生命周期 + 凭码自助注册。
50
+
51
+ 继承 `PasswordMixin` 是因为注册最终要落一个密码账号(复用 `_insert`)。
52
+ """
53
+
54
+ async def issue_invite(
55
+ self,
56
+ session: AsyncSession,
57
+ *,
58
+ max_uses: int = 1,
59
+ expires_in_days: int | None = None,
60
+ note: str | None = None,
61
+ ) -> Any:
62
+ """签发一张邀请码并落库。
63
+
64
+ Args:
65
+ max_uses: 这张码总共能换出几个账号。
66
+ expires_in_days: 多少天后过期;`None` 表示永不过期。
67
+ note: 备注,给签发的人自己记「这张给谁的」。
68
+
69
+ Returns:
70
+ 已落库的邀请码对象,`code` 字段是生成出来的码值。
71
+ """
72
+ expires_at = utcnow() + timedelta(days=expires_in_days) if expires_in_days else None
73
+ code = self.invite_model(
74
+ code=generate_code(),
75
+ max_uses=max_uses,
76
+ expires_at=expires_at,
77
+ note=note,
78
+ )
79
+ session.add(code)
80
+ await session.commit()
81
+ await session.refresh(code)
82
+ return code
83
+
84
+ async def consume_invite(
85
+ self, session: AsyncSession, code: str, *, now: datetime | None = None
86
+ ) -> None:
87
+ """占用这张码的一个名额。
88
+
89
+ **一条条件 UPDATE 搞定,不许先 SELECT 判断再 UPDATE。** 先查后改在并发
90
+ 下会把一张 `max_uses=1` 的码兑出两个账号:两个请求都读到
91
+ `used_count == 0`、都认为还有名额,然后各自 `+1`。这里把「还能不能用」
92
+ 整个塞进 WHERE,由数据库保证只有一个人能把它从 0 改到 1,`rowcount`
93
+ 就是裁决结果。
94
+
95
+ **不自己提交** —— 调用方(`register_with_invite`)要让「扣名额」和
96
+ 「建账号」同生共死:建号那步因为用户名撞车失败时,名额必须跟着回滚,
97
+ 否则一张码会因为别人手滑输了个重名用户名而白白少一个名额。
98
+
99
+ Raises:
100
+ InviteUnusable: 码不存在、已吊销、已过期,或名额已用完。
101
+ """
102
+ moment = now or utcnow()
103
+ model = self.invite_model
104
+ result = await session.execute(
105
+ update(model)
106
+ .where(
107
+ model.code == code,
108
+ model.is_active.is_(True),
109
+ model.used_count < model.max_uses,
110
+ (model.expires_at.is_(None)) | (model.expires_at > moment),
111
+ )
112
+ .values(used_count=model.used_count + 1, updated_at=moment)
113
+ .execution_options(synchronize_session=False)
114
+ )
115
+ if not result.rowcount:
116
+ raise InviteUnusable(UNUSABLE)
117
+
118
+ async def revoke_invite(self, session: AsyncSession, code: str) -> bool:
119
+ """吊销一张码。重复吊销是幂等的。
120
+
121
+ Returns:
122
+ 码存在返回 `True`,不存在返回 `False`。
123
+ """
124
+ result = await session.execute(
125
+ update(self.invite_model)
126
+ .where(self.invite_model.code == code)
127
+ .values(is_active=False, updated_at=utcnow())
128
+ .execution_options(synchronize_session=False)
129
+ )
130
+ await session.commit()
131
+ return bool(result.rowcount)
132
+
133
+ async def list_invites(self, session: AsyncSession) -> list[Any]:
134
+ """列出全部邀请码,新的在前。"""
135
+ return list(
136
+ await session.scalars(
137
+ select(self.invite_model).order_by(self.invite_model.created_at.desc())
138
+ )
139
+ )
140
+
141
+ @staticmethod
142
+ def describe_invite_status(code: Any, *, now: datetime | None = None) -> str:
143
+ """见模块级的同名函数。"""
144
+ return describe_invite_status(code, now=now)
145
+
146
+ async def register_with_invite(
147
+ self, session: AsyncSession, username: str, password: str, invite_code: str
148
+ ) -> Any:
149
+ """凭邀请码自助注册一个账号。
150
+
151
+ **角色硬编码成 `GUEST`,不接受调用方指定。** 这是这个方法存在的意义:
152
+ 注册这条路径通常对公网开放,一旦让它能产出 `ADMIN`,邀请码外泄就等于
153
+ 交出后台;而现在最坏结果只是多几个普通用户。要建管理员走 `create_user`。
154
+
155
+ 扣名额和建账号在**同一个事务**里:用户名撞车时 `consume_invite` 已经
156
+ 递增的 `used_count` 跟着回滚 —— 不然别人手滑输了个重名用户名,这张码
157
+ 就白少一次,而签发的人完全看不出为什么。
158
+
159
+ Raises:
160
+ InviteUnusable: 码不存在 / 已吊销 / 已过期 / 已用完。
161
+ UsernameTaken: 用户名已被占用。
162
+ """
163
+ await self.consume_invite(session, invite_code)
164
+ user = await self._insert(session, username, password, UserRole.GUEST)
165
+ await session.commit()
166
+ await session.refresh(user)
167
+ return user
@@ -0,0 +1,109 @@
1
+ """密码登录与建号。
2
+
3
+ 这是本包目前唯一实现了的登录方式。往后加邮箱验证码、短信验证码、QQ / 微信
4
+ 扫码,各写一个同形状的 mixin 挂到 `Accounts` 上,共用这里的 `User` 表和
5
+ `AccountsBase.get_by_username` 等基础查询。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from sqlalchemy import select
13
+ from sqlalchemy.ext.asyncio import AsyncSession
14
+
15
+ from funauth.enums import UserRole
16
+ from funauth.errors import BadCredentials, PermissionDenied, UsernameTaken
17
+ from funauth.security import hash_password, verify_password
18
+ from funauth.services.base import AccountsBase
19
+
20
+ #: 用户名不存在 / 密码不对 / 账号停用,三种都回这一句。见 `authenticate`。
21
+ BAD_CREDENTIALS = "用户名或密码不正确"
22
+
23
+
24
+ class PasswordMixin(AccountsBase):
25
+ """密码登录、角色判定、建号改密。"""
26
+
27
+ async def authenticate(self, session: AsyncSession, username: str, password: str) -> Any:
28
+ """校验用户名密码,返回对应账号。
29
+
30
+ **不区分「用户不存在」和「密码不对」**,连「账号已停用」也归到同一条
31
+ 消息里。分开报的话这个接口就是个用户名枚举器:拿字典刷一遍,回「密码
32
+ 不对」的那些就是真实存在的账号,接下来只用对这几个爆破密码。
33
+
34
+ 停用账号也混进去是同一个道理 —— 「这个用户被停用了」同样确认了它存在。
35
+ 真正需要知道区别的是运维自己,而运维看得到数据库。
36
+
37
+ Raises:
38
+ BadCredentials: 上述任一种情况。
39
+ """
40
+ user = await self.get_by_username(session, username)
41
+ if user is None or not user.is_active:
42
+ raise BadCredentials(BAD_CREDENTIALS)
43
+ if not verify_password(password, user.password_hash):
44
+ raise BadCredentials(BAD_CREDENTIALS)
45
+ return user
46
+
47
+ @staticmethod
48
+ def require_role(user: Any, role: UserRole) -> None:
49
+ """要求 `user` 正好是 `role` 这个角色,不然抛异常。
50
+
51
+ 用精确相等而不是「大于等于」:`UserRole` 只有两级,而枚举的定义顺序
52
+ 不该悄悄决定权限高低(理由见 `UserRole` 的 docstring)。以后真有三级,
53
+ 这里要显式写出每级能干什么,而不是靠排序蒙对。
54
+
55
+ Raises:
56
+ PermissionDenied: 角色不匹配。
57
+ """
58
+ if user.role != role:
59
+ raise PermissionDenied("需要管理员权限")
60
+
61
+ async def create_user(
62
+ self, session: AsyncSession, username: str, password: str, role: UserRole
63
+ ) -> Any:
64
+ """服务端直接建号,角色由调用方指定。自助注册请走 `register_with_invite`。
65
+
66
+ Raises:
67
+ UsernameTaken: 用户名已被占用。
68
+ """
69
+ user = await self._insert(session, username, password, role)
70
+ await session.commit()
71
+ await session.refresh(user)
72
+ return user
73
+
74
+ async def set_password(self, session: AsyncSession, username: str, password: str) -> bool:
75
+ """重置密码。用户不存在返回 `False`。"""
76
+ user = await self.get_by_username(session, username)
77
+ if user is None:
78
+ return False
79
+ user.password_hash = hash_password(password)
80
+ await session.commit()
81
+ return True
82
+
83
+ async def set_active(self, session: AsyncSession, username: str, active: bool) -> bool:
84
+ """启用 / 停用账号。用户不存在返回 `False`。"""
85
+ user = await self.get_by_username(session, username)
86
+ if user is None:
87
+ return False
88
+ user.is_active = active
89
+ await session.commit()
90
+ return True
91
+
92
+ async def _insert(
93
+ self, session: AsyncSession, username: str, password: str, role: UserRole
94
+ ) -> Any:
95
+ """查重后插入,**不提交**。
96
+
97
+ 这里的查重**挡不住并发**(两个请求可以都查到「没占用」然后都插),真正
98
+ 的保证是宿主表上的唯一约束。先查一遍只是为了在绝大多数情况下回一句人能
99
+ 看懂的「用户名已存在」,而不是把 IntegrityError 原样抛给调用方。
100
+ """
101
+ taken = await session.scalar(
102
+ select(self.user_model.id).where(self.user_model.username == username)
103
+ )
104
+ if taken is not None:
105
+ raise UsernameTaken(f"用户名已存在:{username}")
106
+ user = self.user_model(username=username, password_hash=hash_password(password), role=role)
107
+ session.add(user)
108
+ await session.flush()
109
+ return user
@@ -0,0 +1,25 @@
1
+ Metadata-Version: 2.5
2
+ Name: funauth
3
+ Version: 0.1.1
4
+ Summary: 可复用的登录与注册底座:密码登录、角色、邀请码注册(SQLAlchemy 异步)
5
+ Project-URL: Organization, https://github.com/farfarfun
6
+ Project-URL: Repository, https://github.com/farfarfun/funauth
7
+ Project-URL: Releases, https://github.com/farfarfun/funauth/releases
8
+ Author-email: 牛哥 <niuliangtao@qq.com>, farfarfun <farfarfun@qq.com>
9
+ Maintainer-email: 牛哥 <niuliangtao@qq.com>, farfarfun <farfarfun@qq.com>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: asyncio,auth,invite,login,sqlalchemy
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Software Development :: Libraries
20
+ Requires-Python: >=3.12
21
+ Requires-Dist: bcrypt>=4.0
22
+ Requires-Dist: sqlalchemy[asyncio]>=2.0.30
23
+ Description-Content-Type: text/markdown
24
+
25
+ # funauth
@@ -0,0 +1,15 @@
1
+ funauth/__init__.py,sha256=snw26Tx_XVlqiv4tbdG-KCG3P9HZTm1Wrfv2dSnXo54,2893
2
+ funauth/enums.py,sha256=LzPA0PEY7ZXpzsXidwlGOZz8_J4AirVukl5dW8iAJD0,1092
3
+ funauth/errors.py,sha256=Ring0cHjKde-ldy_SONVJFDdRGMqXyUXSOccnfzEcOg,1068
4
+ funauth/security.py,sha256=h_HkzriOZu4uPc4Av_uM-NZdSdNwqlUm8lK9ntLmlUM,1373
5
+ funauth/models/__init__.py,sha256=gcG5yoaUGP4eUidGQUR-hnSqXYFjPQnd9JwVuY5pjYE,334
6
+ funauth/models/mixins.py,sha256=1u-_C2nDAM_xQeg-E4qfyPKJXpTIHhni-6_wsfcoooY,4542
7
+ funauth/models/types.py,sha256=wVA9Irmg0Shs98S87Abu9TRAbcAAkB-tE2smIdTyATQ,2877
8
+ funauth/services/__init__.py,sha256=nFgZorlOjBh-W5E5vX0kzgowUDdIL5GF2Ugt9DbrCX0,976
9
+ funauth/services/base.py,sha256=qSZUtYBwVeNJoHpFxI8-poyRIkn-yVQUUMnx789nxKM,2791
10
+ funauth/services/invite.py,sha256=jQnNgr7YkyjkmJ9qKbh4GtQSys7tcElCBLcurnPhyw8,6714
11
+ funauth/services/password.py,sha256=SOn1jJBV5sjysdhRLsoJy0Vk_nK18asyfMaJyVYv2Jg,4641
12
+ funauth-0.1.1.dist-info/METADATA,sha256=dSpT579ZsnPB1VMSdZS9eoS6QFJOnE7n3VncmlNNM8Q,1083
13
+ funauth-0.1.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
14
+ funauth-0.1.1.dist-info/licenses/LICENSE,sha256=BvvS-yeQjeaYgHQW2Vh7Msedpzoc-r7mnjNCpqUUbgM,1066
15
+ funauth-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 farfarfun
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.