From c935720b2b0457eb855f2573b2a274773a8810d2 Mon Sep 17 00:00:00 2001 From: chakcy <947105045@qq.com> Date: Thu, 10 Sep 2026 16:30:53 +0800 Subject: [PATCH] first commit --- README.md | 53 + pyproject.toml | 24 + src/chongming_auth/__init__.py | 29 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 879 bytes .../__pycache__/models.cpython-312.pyc | Bin 0 -> 3402 bytes .../__pycache__/pydantic.cpython-312.pyc | Bin 0 -> 1642 bytes .../__pycache__/store.cpython-312.pyc | Bin 0 -> 10131 bytes src/chongming_auth/models.py | 110 ++ src/chongming_auth/pydantic.py | 24 + src/chongming_auth/store.py | 163 +++ ...st_auth_store.cpython-312-pytest-9.1.1.pyc | Bin 0 -> 27930 bytes tests/test_auth_store.py | 166 +++ uv.lock | 976 ++++++++++++++++++ 13 files changed, 1545 insertions(+) create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 src/chongming_auth/__init__.py create mode 100644 src/chongming_auth/__pycache__/__init__.cpython-312.pyc create mode 100644 src/chongming_auth/__pycache__/models.cpython-312.pyc create mode 100644 src/chongming_auth/__pycache__/pydantic.cpython-312.pyc create mode 100644 src/chongming_auth/__pycache__/store.cpython-312.pyc create mode 100644 src/chongming_auth/models.py create mode 100644 src/chongming_auth/pydantic.py create mode 100644 src/chongming_auth/store.py create mode 100644 tests/__pycache__/test_auth_store.cpython-312-pytest-9.1.1.pyc create mode 100644 tests/test_auth_store.py create mode 100644 uv.lock diff --git a/README.md b/README.md new file mode 100644 index 0000000..48d5bf2 --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# chongming-auth + +auth 域共享契约包:网关内置登录与 auth 插件(用户管理)的公共依赖。 +两侧不允许互相 import(插件/worker SDK 保持与网关解耦),共享物只放这里。 + +## 职责拆分(auth 域) + +| 部分 | 归属 | 说明 | +| --- | --- | --- | +| Users 模型(唯一权威定义) | 本包 | 禁止在别处重复定义同表模型 | +| 建库 / admin 种子 / 登录校验 / JWT 签发 | 本包 | `store.py` | +| `POST /api/v1/auth/login`(内置登录端点) | 网关核心 `chongming/app/routes/auth.py` | 管理平面可用性底线:auth 插件缩容/故障时登录与 admin API 仍可用 | +| 注册 / 资料 / 改密(用户管理) | auth 插件 `plugins.auth` | 可替换的业务部分,支持 inline/external | + +## 稳定契约(改动即破坏性变更) + +1. **JWT claims**:`sub / username / email / full_name / avatar_url / phone / + is_verified / is_superuser / created_at / updated_at / last_login_at`, + token 有效期默认 10800s(`DEFAULT_TOKEN_EXPIRES_SECONDS`)。 + 网关 admin API 依赖 `is_superuser` 判定超管(`worker_admin.py`); + 网关 JWT 校验依赖(`core/auth.py`)按 `[security]` 的 secret/algorithm 验签。 +2. **登录响应 shape**:`{result, token, message, timestamp}`,失败文案 + (`用户名错误` / `密码错误` / `用户已经注销或被禁用`)与前端兼容。 +3. **users 表 schema**:对既有 `auth.db`(SQLite)与 PostgreSQL users 表零迁移。 + +## 数据库定位 + +与 `chongming_worker.Plugin.init_database` 语义一致: + +- 配置含 `database.url`(插件 config.toml 的 `[workers.config.database]`)→ PostgreSQL; +- 否则 SQLite:`data_dir/auth.db`(`[workers.config]` 的 `data_dir`,默认 `./data`)。 + +网关 lifespan(`setup_auth_store`)与 auth 插件读取同一份配置,指向同一张表。 + +## 用法 + +```python +from chongming_auth import open_database, seed_admin, verify_login, issue_token + +db = await open_database({"data_dir": "./data"}) # 或 {"database": {"url": "postgresql://..."}} +await seed_admin(db) # 幂等,多进程并发安全 +user, message = await verify_login(db, "admin", "admin") +if user is not None: + token = issue_token(user, secret, algorithm) +``` + +## 开发 + +```bash +cd utils/auth +uv sync +uv run pytest +``` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e7ef86e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,24 @@ +[build-system] +build-backend = "hatchling.build" +requires = ["hatchling"] + +[project] +name = "chongming-auth" +version = "0.1.0" +description = "auth 域共享契约:Users 模型 / 建库与 admin 种子 / 登录校验与 JWT 签发(网关内置登录与 auth 插件共用)" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "chongming-database>=0.2.0", + "chongming-jwt", + "chongming-password", + "pydantic>=2.0.0", + "sqlmodel>=0.0.16", + "orjson>=3.9.0", +] + +[dependency-groups] +dev = ["pytest>=8.0.0", "anyio>=4.0.0", "aiosqlite>=0.20.0", "twine>=7.0.0"] + +[tool.hatch.build.targets.wheel] +packages = ["src/chongming_auth"] diff --git a/src/chongming_auth/__init__.py b/src/chongming_auth/__init__.py new file mode 100644 index 0000000..4cff02c --- /dev/null +++ b/src/chongming_auth/__init__.py @@ -0,0 +1,29 @@ +"""chongming-auth —— auth 域共享契约包。 + +网关内置登录与 auth 插件(用户管理)的公共依赖: +Users 模型(唯一权威定义)、建库与 admin 种子、登录校验、JWT 签发。 + +两侧不得互相 import(worker SDK / 插件保持与网关解耦),共享物只放这里。 +""" +from .models import Users +from .store import ( + DEFAULT_TOKEN_EXPIRES_SECONDS, + issue_token, + open_database, + seed_admin, + touch_last_login, + verify_login, +) +from .pydantic import LoginPostRequest, LoginPostResponse + +__all__ = [ + "Users", + "DEFAULT_TOKEN_EXPIRES_SECONDS", + "issue_token", + "open_database", + "seed_admin", + "touch_last_login", + "verify_login", + "LoginPostRequest", + "LoginPostResponse", +] diff --git a/src/chongming_auth/__pycache__/__init__.cpython-312.pyc b/src/chongming_auth/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..91ec88d64475c3200800cc6ac891e71143a231e0 GIT binary patch literal 879 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 literal 0 HcmV?d00001 diff --git a/src/chongming_auth/__pycache__/pydantic.cpython-312.pyc b/src/chongming_auth/__pycache__/pydantic.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d54f1c6e56c19b1b262b7d9fb8e0ac9cfe40bd2f GIT binary patch literal 1642 zcmb_cOHUhD6ux7RpP2Ae9;$>ap{lXk;!52lDWOsyq>qFqKvl_`(a1A^8{6Z|LzPh$ z24Tk#(~{s8NN6ZglbS+66he5aKcE{VykVA&?~GZ|3bM(n=gt`FR$cZk&YU^t+E?@|b{irnMHQ&Tv(SXiIMkM8K}kG0%1 zesFt#Gksa;4n_Aj2cIYU-R|P8pS`iL0v5?ncKIU$$A=|96f6>;{rB7)Xv$P;kV>n3sB)Ofxq41Kd)Pu_e#QS?9>oxma*cxq8s*XK+Hm9YZ;gR7PxD3Zj zK`|5~w^6N9Q%;T6yE4(PM~L!au5v6A$aAaZR!r|M)F62&m8n<2ec!bJBl z%ngMNmpl%J;e;T=h=kr^1qii@VaoakE|O&i9e_kHTm3ykg!cz~*z3VKe1vFsBoys_ zvzLzwJ$^+F0S65}N@DPDH)EK}xG6f|Fhf*=BAo@1M7wmuZvC0@#_iXe^Ys@KAEkpk z^hLvxYsuGNAcj1BL99F4`rj}Pf&2f2u>`WK3}mMPviq0<6`W_T@|Ykl?gS8|%Ai1n zuUf4sqkt3xO7j2afH`D@1HB$e2?lvd@^`VJu;?ULB~OxS7kNnu%gSr;^T`*TURk)# z!()&!nS_5_EG|DPtgh=b>o7a|+~dOPFRIN5GD)&Asy&$0R?=GEg6fDs0=AoPQ)=Nf zZE_e7P2-82Hny@iwydR>U}PK|-^D^c(FGB`p02RK%HEsaHX+KZE;$4T$ZRC$bBhE% z@dOcsy<#;H29-_`t%iuxM4Tbw6(UGZ6GJiy;>jaHV3j@vVG^PC)VMX*kgq+LXidMr zL!aBNIXNP2pS_l^X-|BZetU;*->o}6UYBdi*PTyXN_Xtg=XV<#b02KCwC5W-5+A2; z=IIVI4@m*@U=&A2p-Yh!5k^5I&oYcm>B$tyOJEjnu+ymQpVhu}Hk)(QA~rxUyx2&3 t1H>OzilY9s*eLqN2}CvO=+#5Jm3n6+eu&_6=&@0q)VS*fB44AXzW`wkKTrSw literal 0 HcmV?d00001 diff --git a/src/chongming_auth/__pycache__/store.cpython-312.pyc b/src/chongming_auth/__pycache__/store.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..845efa193ab7fd96d700a56ad757402cdc601651 GIT binary patch literal 10131 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 Jp-6~R`QPpl{)GSl literal 0 HcmV?d00001 diff --git a/src/chongming_auth/models.py b/src/chongming_auth/models.py new file mode 100644 index 0000000..ba7cc82 --- /dev/null +++ b/src/chongming_auth/models.py @@ -0,0 +1,110 @@ +"""Users 模型 —— auth 域的唯一权威定义。 + +自 plugins/auth/models/auth.py 原样迁移(表名 / 主键 factory / 字段 / 索引逐字段 +一致),保证对既有 auth.db(SQLite)与 PostgreSQL users 表零迁移兼容。 + +网关内置登录与 auth 插件(用户管理)共用本模型,指向同一张 users 表; +禁止在别处重复定义同表模型(会造成 SQLModel 元数据分叉)。 +""" +import os +import time +from typing import Optional +from datetime import datetime +from uuid import uuid4 + +from sqlmodel import Field, SQLModel, Column +from sqlalchemy import func, DateTime + + +def primary_key_create(): + return f"{uuid4()}_{os.getpid()}_{int(time.time())}" + + +class Users(SQLModel, table=True): + __tablename__ = "users" # type: ignore + + # ---------- 主键 ---------- + id: str = Field( + default_factory=primary_key_create, + primary_key=True, + index=True, + description="用户唯一ID(UUID格式)" + ) + + # ---------- 登录凭证(唯一索引) ---------- + username: str = Field( + max_length=50, + unique=True, + index=True, + nullable=False, + description="登录用户名" + ) + email: str = Field( + max_length=255, + unique=True, + index=True, + nullable=False, + description="电子邮箱" + ) + hashed_password: str = Field( + max_length=255, + nullable=False, + description="加密后的密码(不存明文)" + ) + + # ---------- 个人信息(可选) ---------- + full_name: Optional[str] = Field( + default=None, + max_length=100, + description="用户全名" + ) + avatar_url: Optional[str] = Field( + default=None, + max_length=500, + description="头像图片链接" + ) + phone: Optional[str] = Field( + default=None, + max_length=20, + description="手机号" + ) + + # ---------- 安全与状态标识 ---------- + is_active: bool = Field( + default=True, + description="账户是否启用(用于软禁用)" + ) + is_verified: bool = Field( + default=False, + description="邮箱/手机是否已验证" + ) + is_superuser: bool = Field( + default=False, + description="是否为超级管理员" + ) + + # ---------- 审计时间戳(数据库自动维护) ---------- + created_at: datetime = Field( + sa_column=Column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + comment="创建时间" + ) + ) + updated_at: datetime = Field( + sa_column=Column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + comment="最后更新时间" + ) + ) + + # ---------- 最后登录记录(可选) ---------- + last_login_at: Optional[datetime] = Field( + default=None, + sa_column=Column(DateTime(timezone=True), nullable=True), + description="最后一次登录时间" + ) diff --git a/src/chongming_auth/pydantic.py b/src/chongming_auth/pydantic.py new file mode 100644 index 0000000..c7d65eb --- /dev/null +++ b/src/chongming_auth/pydantic.py @@ -0,0 +1,24 @@ +"""auth 域共享契约模型(Login)。 + +自 plugins/auth/models/pydantic.py 迁移:登录是网关内置端点(管理平面), +其请求/响应契约归共享包所有;auth 插件的注册/资料/改密模型仍由插件内 +gen_models 按 TOML 生成。字段与拆分前完全一致(前端零改动)。 +""" + +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class LoginPostRequest(BaseModel): + """登录 — 请求体""" + username: str = Field(..., description="登录用户名") + password: str = Field(..., description="登录密码") + + +class LoginPostResponse(BaseModel): + """登录 — 响应体""" + result: str = Field(..., description="success/fail") + token: str = Field(..., description="JWT token,登录失败为空字符串") + message: str = Field(..., description="结果消息") + timestamp: float | None = Field(default=None, description="服务器时间戳") diff --git a/src/chongming_auth/store.py b/src/chongming_auth/store.py new file mode 100644 index 0000000..61d22a9 --- /dev/null +++ b/src/chongming_auth/store.py @@ -0,0 +1,163 @@ +"""auth 库存储与登录助手 —— 网关内置登录与 auth 插件共用的实现。 + +职责边界(auth 域拆分后): + - 本包:Users 模型、建库、admin 种子、登录校验、JWT 签发(基础设施部分) + - 网关核心:内置 POST /auth/login 端点(管理平面可用性底线,不依赖插件) + - auth 插件:注册 / 资料 / 改密等用户管理功能(可替换的业务部分) + +数据库定位与 chongming_worker.Plugin.init_database 语义一致: +config 含 ``database.url`` 时优先 PostgreSQL,否则 SQLite(data_dir/filename)。 +""" +import logging +from datetime import datetime +from pathlib import Path + +import orjson +from chongming_database import Database +from chongming_jwt import JWT +from chongming_password import hash_password, verify_password +from sqlalchemy import select, update as sqlalchemy_update +from sqlalchemy.exc import IntegrityError + +from .models import Users + +logger = logging.getLogger("chongming_auth") + +# 登录 token 有效期(秒),与拆分前 plugins.auth.login 保持一致(3 小时) +DEFAULT_TOKEN_EXPIRES_SECONDS = 10800 + + +async def open_database(config: dict | None = None, + filename: str = "auth.db") -> Database: + """按插件 ``[workers.config]`` 形态的配置初始化 auth 库。 + + Args: + config: 含 data_dir / database(type/url/generate_schemas)的字典, + 即插件 config.toml 的 ``[workers.config]`` 节;缺省走默认。 + filename: SQLite 文件名(data_dir 下)。 + + Returns: + 已 initialize 的 Database(users 表已建,checkfirst 幂等)。 + """ + config = config if isinstance(config, dict) else {} + db_conf = config.get("database") + if isinstance(db_conf, dict) and db_conf.get("url"): + db = Database.from_config(db_conf) + await db.initialize(generate_schemas=db_conf.get("generate_schemas", True)) + logger.info("auth 数据库已初始化 (PostgreSQL): %s", db_conf["url"]) + return db + + db_path = Path(config.get("data_dir", "./data")) / filename + db = Database(db_path) + await db.initialize() + logger.info("auth 数据库已初始化: %s", db_path) + return db + + +async def seed_admin(db: Database, username: str = "admin", + password: str = "admin", + email: str = "admin@example.com") -> bool: + """幂等初始化管理员账户,返回是否发生了新建。 + + 逻辑与拆分前 plugins.auth.AuthPlugin.on_init 完全一致: + 先按 username 查、未命中再按 email 兜底(兼容历史同邮箱数据)、 + 仍不存在才创建;并发插入撞 UNIQUE 约束时回滚忽略—— + gunicorn 多进程同时启动时保证 users 表只有一条 admin。 + """ + async with db.get_session() as sess: + select_stmt = select(Users).where(Users.username == username) # type: ignore + user: Users | None = (await sess.execute(select_stmt)).scalar_one_or_none() + + if user is None: + select_stmt = select(Users).where(Users.email == email) # type: ignore + user = (await sess.execute(select_stmt)).scalar_one_or_none() + + if user is not None: + return False + + user = Users( + username=username, + hashed_password=hash_password(password), + email=email, + created_at=datetime.now(), + updated_at=datetime.now(), + is_superuser=True, + ) + sess.add(user) + try: + await sess.commit() + return True + except IntegrityError: + # 其他并发进程已抢先创建 admin,当前进程忽略即可 + await sess.rollback() + return False + + +async def verify_login(db: Database, username: str, + password: str) -> tuple[Users | None, str]: + """校验用户名/密码,返回 (user, message)。 + + user 非 None 即登录成功;失败时 user=None 且 message 为与拆分前 + plugins.auth.login 逐字一致的中文原因(前端文案兼容)。 + """ + async with db.get_session() as session: + statement = select(Users).filter_by(username=username) + user: Users | None = (await session.execute(statement)).scalar_one_or_none() + + if user is None: + return None, "用户名错误" + if not verify_password(password, user.hashed_password): + return None, "密码错误" + if not user.is_active: + return None, "用户已经注销或被禁用" + return user, "" + + +async def touch_last_login(db: Database, user: Users) -> None: + """更新最后登录时间(尽力而为)。 + + 审计字段,失败仅告警、不影响登录结果;多进程共享同一 SQLite 时 + 可能偶发写锁冲突,不能让它拖垮登录主链路。 + 用独立 UPDATE 语句而非回挂实例:updated_at 列带 onupdate=func.now() + (服务端生成值),flush 会把该属性标记过期,session 关闭后实例上再读 + 即抛 DetachedInstanceError。 + (拆分前 plugins.auth.login 的同类更新从未 commit,实际从未落库—— + 这里顺带修复。) + """ + try: + async with db.get_session() as session: + await session.execute( + sqlalchemy_update(Users) + .where(Users.id == user.id) # type: ignore + .values(last_login_at=datetime.now()) + ) + await session.commit() + except Exception as e: # noqa: BLE001 + logger.warning("更新 last_login_at 失败(不影响登录): %s", e) + + +def issue_token(user: Users, secret: str, algorithm: str, + expires_in_seconds: int = DEFAULT_TOKEN_EXPIRES_SECONDS) -> str: + """按平台 JWT claims 契约签发 token。 + + claims 与拆分前 plugins.auth.login 完全一致: + sub/username/email/full_name/avatar_url/phone/is_verified/is_superuser/ + created_at/updated_at/last_login_at。 + 网关 admin API 依赖 is_superuser 判定超管(worker_admin.py), + 该 claims 契约是稳定接口,改动即破坏性变更。 + """ + payload = { + "sub": user.id, + "username": user.username, + "email": user.email, + "full_name": user.full_name, + "avatar_url": user.avatar_url, + "phone": user.phone, + "is_verified": user.is_verified, + "is_superuser": user.is_superuser, + "created_at": user.created_at.isoformat() if user.created_at else None, + "updated_at": user.updated_at.isoformat() if user.updated_at else None, + "last_login_at": user.last_login_at.isoformat() if user.last_login_at else None, + } + jwt = JWT(secret, algorithm) + return jwt.encode(orjson.dumps(payload).decode(), expires_in_seconds=expires_in_seconds) diff --git a/tests/__pycache__/test_auth_store.cpython-312-pytest-9.1.1.pyc b/tests/__pycache__/test_auth_store.cpython-312-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6d3540ff727618dc59fd65cf6b1a97d35d782cd4 GIT binary patch literal 27930 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