first commit
This commit is contained in:
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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="最后一次登录时间"
|
||||
)
|
||||
@@ -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="服务器时间戳")
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user