first commit

This commit is contained in:
2026-09-10 16:30:53 +08:00
commit c935720b2b
13 changed files with 1545 additions and 0 deletions
+29
View File
@@ -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",
]
Binary file not shown.
+110
View File
@@ -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="最后一次登录时间"
)
+24
View File
@@ -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="服务器时间戳")
+163
View File
@@ -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)