diff --git a/aibox_sophon_adapter/__init__.py b/aibox_sophon_adapter/__init__.py index 6bdf1bf..3d6c9ba 100644 --- a/aibox_sophon_adapter/__init__.py +++ b/aibox_sophon_adapter/__init__.py @@ -120,6 +120,7 @@ class AiboxSophonAdapterPlugin(Plugin): ip_sound_column_cfg = config.get("ip_sound_column", {}) ip_sound_column_enabled = ip_sound_column_cfg.get("enabled", True) ip_sound_column_volume = ip_sound_column_cfg.get("volume", 50) # 播放音量 0-100 + ip_sound_column_tts = ip_sound_column_cfg.get("tts", {}) # 播放内容(key: Summary,value: tts 文本) # 告警转发配置 alarm_fwd_cfg = config.get("alarm_forward", {}) @@ -159,6 +160,7 @@ class AiboxSophonAdapterPlugin(Plugin): set_global_var("doubao_analysis_enabled", doubao_enabled) set_global_var("ip_sound_column_enabled", ip_sound_column_enabled) set_global_var("ip_sound_column_volume", ip_sound_column_volume) + set_global_var("ip_sound_column_tts", ip_sound_column_tts) set_global_var("alarm_forward_enabled", alarm_fwd_enabled) set_global_var("alarm_forward_url", alarm_fwd_url) set_global_var("alarm_forward_video_url", alarm_fwd_video_url) diff --git a/aibox_sophon_adapter/global_var.py b/aibox_sophon_adapter/global_var.py index 6bda9fb..8d06706 100644 --- a/aibox_sophon_adapter/global_var.py +++ b/aibox_sophon_adapter/global_var.py @@ -51,6 +51,9 @@ ip_sound_column_enabled: bool = True # ip 音柱 TTS 播放音量(0-100) ip_sound_column_volume: int = 50 +# ip 音柱 TTS 播放内容(key: Summary,value: tts 文本) +ip_sound_column_tts: dict = {} + # 告警转发配置 alarm_forward_enabled: bool = False alarm_forward_url: str = "http://dg.datagoo.cn:60014/open-api/event-alarm/receive" diff --git a/aibox_sophon_adapter/routes.py b/aibox_sophon_adapter/routes.py index 967affe..fcbf76d 100644 --- a/aibox_sophon_adapter/routes.py +++ b/aibox_sophon_adapter/routes.py @@ -498,9 +498,14 @@ async def alarm_report( Result=AlarmReportResult(Code=0, Desc="Doubao 分析判定为 False,忽略告警") ) - from .global_var import ip_sound_column_volume - # 2. ip 音柱 TTS 播放(可通过配置开关关闭),失败时仅记录日志,不影响主流程 + from .global_var import ip_sound_column_volume, ip_sound_column_tts + + tts_text = ip_sound_column_tts.get( + full_data.get("Summary", ""), + f"告警: {full_data.get('Summary', '')}" + ) + tts_payload = { "command": "play_local_tts", "volume": ip_sound_column_volume, @@ -511,8 +516,9 @@ async def alarm_report( "tts_pitch": 50, "tts_speed": 50, "tts_voice_name": "xiaoyan", - "tts_text": f"告警: {full_data.get('Summary', '')}", + "tts_text": tts_text, } + tts_result = await ip_sound_column_tts_play(request.app.state.zenoh_session, tts_payload) if tts_result.get("result") != 0: logger.warning(f"[AlarmReport] ip_sound_column TTS 播放失败: {tts_result}") diff --git a/auth/README.md b/auth/README.md index 2000ffc..b387995 100644 --- a/auth/README.md +++ b/auth/README.md @@ -1,26 +1,36 @@ -# Auth Worker 插件 +# Auth Worker 插件 — 用户管理 -用户认证与授权插件,提供完整的用户管理系统。 +auth 域拆分后的**用户管理插件**(可替换的业务部分)。 +登录 / token 签发 / admin 种子已**内置在网关**(管理平面底线,不依赖本插件是否 +加载,本插件缩容到 0 或全部副本故障时登录与 admin API 仍可用)。 + +共享契约归 `chongming-auth` 包(`utils/auth/`):Users 模型(唯一权威定义)、 +建库、登录校验、JWT 签发。本插件与网关内置登录**共用同一张 users 表**。 --- ## 功能 - **用户注册**:用户名 + 邮箱 + 密码注册,自动校验唯一性 -- **用户登录**:JWT Token 签发,有效期 3 小时 - **获取用户信息**:根据 JWT Token 获取完整用户档案 - **修改密码**:旧密码验证 + 新密码更新 - **更新资料**:更新全名、邮箱、头像、手机号等信息 -- **自动初始化**:首次启动自动创建 `admin` 管理员账户 + +(登录 `POST /auth/login` 由网关内置提供,路径与响应 shape 不变) --- ## 数据库 -使用 SQLite(通过 `chongming-database` 异步封装),数据库文件位于 `data/storage/chongming/auth.db`。 +使用 SQLite(通过 `chongming-database` 异步封装),数据库文件位于 +`data/auth.db`(`[workers.config].data_dir` 下);配置 `database.url` 时走 +PostgreSQL。库位置与网关内置登录读同一份配置(本插件 `config.toml` 的 +`[workers.config]`),指向同一张 users 表。 ### Users 表结构 +权威定义见 `chongming_auth.models.Users`(本仓库 `models/auth.py` 仅为 re-export): + | 字段 | 类型 | 说明 | | ----------------- | ----------------- | --------------------------------- | | `id` | `str` (PK) | UUID 格式主键 | @@ -45,6 +55,27 @@ 所有端点路由前缀为 `/auth`。 +### `POST /auth/login` — 登录(网关内置) + +无需认证。由网关核心 `chongming/app/routes/auth.py` 提供(非本插件), +路径与响应 shape 与拆分前一致。JWT claims 契约见 `chongming-auth` 包 README。 + +```json +// 请求 +{ + "username": "admin", + "password": "admin" +} + +// 响应 +{ + "result": "success", + "token": "eyJhbGciOiJIUzI1NiIs...", + "message": "登录成功", + "timestamp": 1720000000.123 +} +``` + ### `POST /auth/register` — 用户注册 无需认证。 @@ -68,28 +99,6 @@ } ``` -### `POST /auth/login` — 登录 - -无需认证。 - -```json -// 请求 -{ - "username": "admin", - "password": "admin" -} - -// 响应 -{ - "result": "success", - "token": "eyJhbGciOiJIUzI1NiIs...", - "message": "登录成功", - "timestamp": 1720000000.123 -} -``` - -JWT Token 有效期 3 小时,载荷包含:`sub`(用户ID)、`username`、`email`、`full_name`、`is_superuser` 等。 - ### `GET /auth/me?user_id=` — 获取用户信息 需要 JWT 鉴权(`Authorization: Bearer `)。 @@ -159,9 +168,9 @@ JWT Token 有效期 3 小时,载荷包含:`sub`(用户ID)、`username` ## 默认管理员账户 -插件基于 `Plugin` 基类统一生命周期:`on_init` 中执行管理员种子逻辑 -(先按用户名、再按邮箱兜底查询;多进程并发启动时靠唯一约束 + `IntegrityError` 回滚保证幂等), -数据库 `auth.db` 由基类统一初始化并在 cleanup 时释放。 +admin 种子逻辑随登录一并内置到网关(网关 lifespan 的 `setup_auth_store` +→ `chongming_auth.seed_admin`,幂等,多进程并发启动安全),不再依赖本插件。 +数据库 `auth.db` 本插件仍由 `Plugin` 基类统一初始化并在 cleanup 时释放。 首次启动时自动创建: @@ -190,8 +199,7 @@ JWT Token 有效期 3 小时,载荷包含:`sub`(用户ID)、`username` | 工具包 | 用途 | | -------------------- | ------------------------------------ | +| `chongming-auth` | Users 模型 / 建库 / 登录校验 / 签发(共享契约) | | `chongming-database` | 异步 SQLite 数据库操作 | -| `chongming-jwt` | Rust JWT 编码/验证 | -| `chongming-password` | Rust Argon2id 密码哈希 | | `chongming-config` | 全局配置加载 | | `chongming-worker` | `@rpc_method` 装饰器 + Worker 运行时 | diff --git a/auth/__init__.py b/auth/__init__.py index c5a0b97..8d63fb7 100644 --- a/auth/__init__.py +++ b/auth/__init__.py @@ -1,19 +1,22 @@ """ -Auth Worker 插件 — 鉴权管理(登录 / 注册 / 资料 / 改密) +Auth Worker 插件 — 用户管理(注册 / 资料 / 改密) -基于 Plugin 基类统一生命周期:数据库由基类初始化(auth.db), -on_init 中执行 admin 幂等种子逻辑,on_cleanup 无需重复 dispose。 +auth 域拆分后本插件只保留用户管理功能(可替换的业务部分); +登录 / token 签发 / admin 种子已内置在网关(chongming_auth 包 + +chongming.app.routes.auth),不依赖本插件是否加载。 + +基于 Plugin 基类统一生命周期:数据库由基类初始化(auth.db,与网关内置 +登录共用同一张 users 表),on_cleanup 无需重复 dispose。 """ import logging -from datetime import datetime from chongming_worker.plugin import Plugin -from chongming_password import hash_password from .router import * # noqa: F401,F403 触发 @rpc_method 注册 # 模块加载时即注册 SQLModel 元数据 —— 基类 _bootstrap 建表(create_all)只创建 # 已导入的表,若拖到 on_init 才导入会导致全新库缺少 users 表(no such table)。 +# Users 权威定义在 chongming_auth 包,此处 re-export 注册元数据。 from .models.auth import Users # noqa: F401 logger = logging.getLogger(__name__) @@ -23,49 +26,13 @@ class AuthPlugin(Plugin): """Auth 插件 — 统一生命周期。""" type = "auth" - db_name = "auth.db" # 自动在 data_dir 下建 SQLite 库 + db_name = "auth.db" # 自动在 data_dir 下建 SQLite 库(与网关内置登录共用) auto_bind = True # 首次初始化时自动 auto_bind_models("auth") async def on_init(self): - """首次 create_workers 时调用一次:初始化 admin 账号(幂等)。""" - from .models.auth import Users - from sqlalchemy import select - from sqlalchemy.exc import IntegrityError - - db = self.db - - # 初始化管理员(多进程并发启动时必须幂等,避免 UNIQUE 约束冲突) - async with db.get_session() as sess: - select_stmt = select(Users) \ - .where(Users.username == "admin") # type: ignore - result = await sess.execute(select_stmt) - user: Users | None = result.scalar_one_or_none() - - # 按用户名未命中时,再按邮箱兜底,避免历史数据中已存在同邮箱用户 - if user is None: - select_stmt = select(Users) \ - .where(Users.email == "admin@example.com") # type: ignore - result = await sess.execute(select_stmt) - user = result.scalar_one_or_none() - - if user is None: - user = Users( - username="admin", - hashed_password=hash_password("admin"), - email="admin@example.com", - created_at=datetime.now(), - updated_at=datetime.now(), - is_superuser=True - ) - sess.add(user) - try: - await sess.commit() - except IntegrityError: - # 其他并发进程已抢先创建 admin,当前进程忽略即可 - await sess.rollback() - + """首次 create_workers 时调用一次。""" # 写入全局变量供 router 使用(数据库由基类在 cleanup 时统一 dispose) - self.set_var("db", db) + self.set_var("db", self.db) async def on_cleanup(self): """cleanup 时调用(数据库由基类处理)。""" diff --git a/auth/global_var.py b/auth/global_var.py index bbdef28..17f42cd 100644 --- a/auth/global_var.py +++ b/auth/global_var.py @@ -1,14 +1,13 @@ from typing import Any from chongming_config import get_config -from chongming_jwt import JWT from chongming_database import Database from .models import * def set_global_var(name: str, value: Any) -> None: """ 设置当前模块的全局变量。 - + Args: name: 变量名(字符串) value: 任意值 @@ -19,11 +18,9 @@ config = get_config() db: Database | None = None -secret = config["security"]["secret_key"] -algorithm = config["security"]["algorithm"] -jwt = JWT(secret, algorithm) +# JWT 签发已随登录内置到网关(chongming.app.routes.auth / chongming_auth 包), +# 插件不再持有 jwt 实例 __all__ = [ "db", - "jwt" -] \ No newline at end of file +] diff --git a/auth/models/__init__.py b/auth/models/__init__.py index 887b9a9..677de46 100644 --- a/auth/models/__init__.py +++ b/auth/models/__init__.py @@ -1,13 +1,12 @@ """ 模型导出 — auth 自动生成,请勿手动修改 +(LoginPostRequest/LoginPostResponse 已随内置登录迁移至 chongming_auth 包) """ from .pydantic import AuthResponse from .pydantic import ChangePasswordPostRequest from .pydantic import ChangePasswordPostResponse -from .pydantic import LoginPostRequest -from .pydantic import LoginPostResponse from .pydantic import MeGetRequest from .pydantic import MeGetResponse from .pydantic import RegisterPostRequest @@ -21,8 +20,6 @@ __all__ = [ "AuthResponse", "ChangePasswordPostRequest", "ChangePasswordPostResponse", - "LoginPostRequest", - "LoginPostResponse", "MeGetRequest", "MeGetResponse", "RegisterPostRequest", diff --git a/auth/models/auth.py b/auth/models/auth.py index d92b96a..f1c2d7a 100644 --- a/auth/models/auth.py +++ b/auth/models/auth.py @@ -1,102 +1,10 @@ -import os -import time -from typing import Optional -from datetime import datetime -from uuid import uuid4 +"""Users 模型 re-export —— 权威定义已迁移至共享包 chongming_auth。 -from sqlmodel import Field, SQLModel, Column -from sqlalchemy import func, DateTime +auth 域拆分后:Users 模型 / 建库 / 登录校验 / JWT 签发归 chongming_auth 包 +(网关内置登录与 auth 插件共用,指向同一张 users 表)。本模块保留 re-export +以兼容插件内既有相对导入(from ..models.auth import Users); +re-export 保证全进程单一模型定义,SQLModel 元数据不会重复注册。 +""" +from chongming_auth import Users # noqa: F401 - -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="最后一次登录时间" - ) +__all__ = ["Users"] diff --git a/auth/models/pydantic.py b/auth/models/pydantic.py index c8c220b..ffad0ff 100644 --- a/auth/models/pydantic.py +++ b/auth/models/pydantic.py @@ -20,18 +20,6 @@ class ChangePasswordPostResponse(BaseModel): message: str = Field(...) timestamp: Optional[float] = Field(default=None) -class LoginPostRequest(BaseModel): - """登录 — 请求体""" - username: str = Field(...) - password: str = Field(...) - -class LoginPostResponse(BaseModel): - """登录 — 响应体""" - result: str = Field(...) - token: str = Field(...) - message: str = Field(...) - timestamp: Optional[float] = Field(default=None) - class MeGetRequest(BaseModel): """获取当前用户信息 — 请求体""" user_id: str = Field(...) @@ -83,4 +71,5 @@ class UpdateProfilePostResponse(BaseModel): # 所有响应模型的联合类型 -AuthResponse = LoginPostResponse | ChangePasswordPostResponse | MeGetResponse | UpdateProfilePostResponse | RegisterPostResponse +# (登录响应 LoginPostResponse 已随内置登录迁移至 chongming_auth 包) +AuthResponse = ChangePasswordPostResponse | MeGetResponse | UpdateProfilePostResponse | RegisterPostResponse diff --git a/auth/router/__init__.py b/auth/router/__init__.py index f3c9629..840f2f0 100644 --- a/auth/router/__init__.py +++ b/auth/router/__init__.py @@ -1,8 +1,7 @@ -from .login import login from .register import register from .me import me from .update_profile import update_profile from .change_password import change_password -__all__ = ["login", "register", "me", "update_profile", "change_password"] +__all__ = ["register", "me", "update_profile", "change_password"] diff --git a/auth/router/login.py b/auth/router/login.py deleted file mode 100644 index 08770d4..0000000 --- a/auth/router/login.py +++ /dev/null @@ -1,85 +0,0 @@ -import time -from datetime import datetime -import orjson - -from sqlalchemy import select - -from chongming_worker import rpc_method -from chongming_password import verify_password - -from ..models.pydantic import ( - LoginPostRequest, - LoginPostResponse -) -from ..models.auth import Users - - -@rpc_method( - subject="auth.login", - method="POST", - summary="登录", - response_model=LoginPostResponse, -) -async def login(login_post_request: LoginPostRequest) -> LoginPostResponse: - from ..global_var import jwt, db - - if db is None: - raise RuntimeError("Database not initialized") - async with db.get_session() as session: - statement = select(Users).filter_by( - username=login_post_request.username, - ) - result = await session.execute(statement) - user: Users | None = result.scalar_one_or_none() - - if user is None: - return LoginPostResponse( - result="fail", - message="用户名错误", - token="", - timestamp=time.time() - ) - - if not verify_password(login_post_request.password, user.hashed_password): - return LoginPostResponse( - result="fail", - message="密码错误", - token="", - timestamp=time.time() - ) - - if not user.is_active: - return LoginPostResponse( - result="fail", - message="用户已经注销或被禁用", - token="", - timestamp=time.time() - ) - - user.last_login_at = datetime.now() - - 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(), - "updated_at": user.updated_at.isoformat(), - "last_login_at": user.last_login_at.isoformat() - } - - token = jwt.encode( - orjson.dumps(payload).decode(), - expires_in_seconds=10800 # 3小时有效期 - ) - - return LoginPostResponse( - result="success", - message="登录成功", - token=token, - timestamp=time.time() - ) diff --git a/auth_oauth/README.md b/auth_oauth/README.md new file mode 100644 index 0000000..d7bf4b0 --- /dev/null +++ b/auth_oauth/README.md @@ -0,0 +1,61 @@ +# Auth OAuth 插件 — 第三方授权登录示例(身份源插件) + +基于 `chongming-auth` 共享契约实现**身份源插件**的参考实现:准入判断归插件, +平台凭证签发归 `chongming_auth.issue_token`(claims 契约与网关内置登录完全一致)。 +第三方身份登录后拿到的 token 与内置登录**完全等价**——网关 JWT 验签、 +`user_info` 随信封透传、admin API 判超管全部无缝生效。 + +## 提供的身份源 + +| 身份源 | 模式 | 端点 | +| --- | --- | --- | +| GitHub | OAuth2 授权码(SPA JSON 流程) | `GET /auth/oauth/github_authorize`、`POST /auth/oauth/github_callback` | +| 手机号 | 一次性验证码(演示模式) | `POST /auth/oauth/phone_send_code`、`POST /auth/oauth/phone_login` | + +## 身份源插件的标准形态(写新身份源时照此扩展) + +1. **准入**:校验第三方凭据(OAuth code / OTP / LDAP 密码 / ...); +2. **映射**:在插件自有绑定表 `identity_bindings(provider, provider_uid, user_id)` + 中 find-or-create——老用户换身份源登录仍是同一账号体系; +3. **建号**:无绑定时新建 `users` 账号(密码为随机不可用值)+ 绑定记录, + 与网关内置登录**共用同一张 users 表**(`chongming_auth.open_database`); +4. **签发**:`chongming_auth.issue_token(user, secret, algorithm)`。 + +扩展新身份源(LDAP / 企业微信 / 飞书 / OIDC ...)只需:加一个 provider 对接模块 ++ 一组 TOML 路由 + 一个 `@rpc_method` 端点,调 `store.find_or_create_user` 即可。 + +## 配置 + +`config/workers/auth_oauth/config.toml` 的 `[workers.config]`: + +```toml +return_otp_in_response = true # 演示模式:验证码随响应返回。生产必须 false 并接短信通道 + +[workers.config.github] +client_id / client_secret / redirect_uri / scope +``` + +## 安全说明(务必阅读) + +- **本插件须以 inline 模式运行**(默认):`issue_token` 需要 `[security]` 签名密钥, + external 进程持有该密钥即能铸造超管 token。external 化前需平台提供内部签发 API + 或按 issuer 分离密钥。 +- `return_otp_in_response` 仅限本地演示,生产必须关闭并接真实短信通道。 +- OAuth state 与 OTP 验证码均存共享缓存(`chongming_cache`),一次性消费、300s 过期。 + +## 踩坑记录(写插件必读) + +**插件必须提供 `models/pydantic.py`**(gen_models 产物位置,手写等价物亦可)。 +网关契约校验按 `plugins..models.pydantic` → `src.plugins....` → `workers....` +顺序探测;缺失时 `src.plugins.` 兜底路径会以第二个模块名重新执行整个插件包 +(父包导入链),插件内自定义 SQLModel 表会在全局元数据中重复定义, +网关启动即崩(`Table 'xxx' is already defined for this MetaData instance`)。 + +## 依赖 + +| 工具包 | 用途 | +| --- | --- | +| `chongming-auth` | Users 模型 / 建库 / 登录校验 / issue_token(共享契约) | +| `chongming-cache` | OAuth state 与 OTP 验证码存储 | +| `chongming-worker` | `@rpc_method` 装饰器 + Worker 运行时 | +| `httpx` | GitHub OAuth API 调用 | diff --git a/auth_oauth/__init__.py b/auth_oauth/__init__.py new file mode 100644 index 0000000..197d544 --- /dev/null +++ b/auth_oauth/__init__.py @@ -0,0 +1,65 @@ +"""auth_oauth 身份源插件 — 第三方授权登录示例(基于 chongming-auth 共享契约)。 + +演示 auth 域拆分后的身份源插件标准形态: + - 准入判断归插件(GitHub OAuth 授权码模式 / 手机验证码模式) + - 平台凭证签发归 chongming_auth.issue_token(claims 契约与内置登录完全一致) + - 账号落地到共享 users 表,第三方身份与账号的映射存插件自有绑定表 + (identity_bindings,建在同一 auth 库,不改动 chongming-auth 拥有的 users schema) + +演示模式说明: + - 手机验证码默认直接随响应返回 dev_code(无短信通道的本地演示用), + 生产必须接真实短信通道并关闭 return_otp_in_response + - GitHub OAuth 需在 [workers.config.github] 配置 client_id/secret/redirect_uri +""" +import logging + +from chongming_worker.plugin import Plugin + +from .router import * # noqa: F401,F403 触发 @rpc_method 注册 + +# 模块加载时即注册 SQLModel 元数据(identity_bindings 表与 users 同库, +# 由 chongming_auth.open_database 的 create_all 一并创建) +from .models import IdentityBinding # noqa: F401 + +logger = logging.getLogger(__name__) + + +class AuthOAuthPlugin(Plugin): + """AuthOAuth 插件 — 身份源示例,统一生命周期。""" + + type = "auth_oauth" + # 不用基类建库:与网关内置登录共用同一 auth 库(chongming_auth.open_database + # 按 [workers.config] 的 data_dir / database 解析,指向 users + identity_bindings) + db_name = None + auto_bind = False # 请求/响应模型由端点函数的类型注解直接提供,无需 gen_models + + async def on_init(self): + """首次 create_workers 时调用一次:打开共享 auth 库 + 缓存。""" + from chongming_auth import open_database + from chongming_cache import Cache + from chongming_config import get_config + + db = await open_database(self.config) + cache = Cache.from_config(get_config()["cache"]) + + # 写入全局变量供 router 使用(cleanup 时统一释放) + self.set_var("db", db) + self.set_var("cache", cache) + self.set_var("plugin_config", dict(self.config or {})) + logger.info("[auth_oauth] 初始化完成(共享 auth 库 + 缓存就绪)") + + async def on_cleanup(self): + """cleanup 时调用:释放自有资源(基类不再托管本插件的数据库)。""" + db = self.get_var("db") + cache = self.get_var("cache") + if db is not None: + await db.dispose() + if cache is not None: + cache.close() + logger.info("[auth_oauth] 清理完成") + + +# 暴露给 app lifespan 的模块级入口(bound method,签名与旧写法完全一致) +plugin = AuthOAuthPlugin() +create_workers = plugin.create_workers +cleanup = plugin.cleanup diff --git a/auth_oauth/global_var.py b/auth_oauth/global_var.py new file mode 100644 index 0000000..c27ea2d --- /dev/null +++ b/auth_oauth/global_var.py @@ -0,0 +1,27 @@ +from typing import Any + +from chongming_config import get_config +from chongming_database import Database +from chongming_cache import Cache + +def set_global_var(name: str, value: Any) -> None: + """ + 设置当前模块的全局变量。 + + Args: + name: 变量名(字符串) + value: 任意值 + """ + globals()[name] = value + +config = get_config() + +db: Database | None = None +cache: Cache | None = None # chongming_cache.Cache(OAuth state / OTP 验证码) +plugin_config: dict = {} # [workers.config] 运行时配置(github 凭据等) + +__all__ = [ + "db", + "cache", + "plugin_config", +] diff --git a/auth_oauth/models/__init__.py b/auth_oauth/models/__init__.py new file mode 100644 index 0000000..d08ef60 --- /dev/null +++ b/auth_oauth/models/__init__.py @@ -0,0 +1,21 @@ +"""模型导出 — auth_oauth(含插件自有绑定表 + 请求/响应模型)""" + +from .pydantic import ( + GithubAuthorizeGetResponse, + GithubCallbackPostRequest, + PhoneLoginPostRequest, + PhoneSendCodePostRequest, + PhoneSendCodePostResponse, +) +from chongming_auth import LoginPostResponse +from .identity import IdentityBinding + +__all__ = [ + "GithubAuthorizeGetResponse", + "GithubCallbackPostRequest", + "LoginPostResponse", + "PhoneLoginPostRequest", + "PhoneSendCodePostRequest", + "PhoneSendCodePostResponse", + "IdentityBinding", +] diff --git a/auth_oauth/models/identity.py b/auth_oauth/models/identity.py new file mode 100644 index 0000000..283ae7c --- /dev/null +++ b/auth_oauth/models/identity.py @@ -0,0 +1,34 @@ +"""身份源绑定表 — 第三方身份(github open_id / 手机号 / ...)与 users 账号的映射。 + +插件自有表,建在共享 auth 库(与 users 同库不同表): +chongming-auth 拥有的 users schema 保持最小不改动,身份源扩展物归插件。 +""" +from datetime import datetime +from uuid import uuid4 + +from sqlalchemy import UniqueConstraint, func, Column, DateTime +from sqlmodel import Field, SQLModel + + +def _pk() -> str: + return f"{uuid4()}_{int(datetime.now().timestamp())}" + + +class IdentityBinding(SQLModel, table=True): + __tablename__ = "identity_bindings" # type: ignore + __table_args__ = ( + UniqueConstraint("provider", "provider_uid", name="uq_provider_uid"), + ) + + id: str = Field(default_factory=_pk, primary_key=True, description="绑定记录ID") + user_id: str = Field(index=True, max_length=255, description="关联的 users.id") + provider: str = Field(index=True, max_length=32, description="身份源: github / phone / ...") + provider_uid: str = Field(max_length=255, description="身份源侧唯一标识(github user id / 手机号)") + created_at: datetime = Field( + sa_column=Column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + comment="创建时间", + ) + ) diff --git a/auth_oauth/models/pydantic.py b/auth_oauth/models/pydantic.py new file mode 100644 index 0000000..6fefd97 --- /dev/null +++ b/auth_oauth/models/pydantic.py @@ -0,0 +1,43 @@ +"""auth_oauth 请求/响应模型。 + +框架契约:所有插件须有 models/pydantic.py(gen_models 的产物位置)—— +网关契约校验按 plugins..models.pydantic → src.plugins.... → workers.... +顺序探测,缺失时会以 src.plugins. 前缀二次执行插件包(父包导入链), +插件内自定义 SQLModel 表会在全局元数据中重复定义导致启动失败。 +本插件模型由手写(等价 gen_models 产物),端点函数以类型注解直接绑定。 +登录响应 LoginPostResponse 直接从 chongming_auth 导入(共享契约)。 +""" +from pydantic import BaseModel, Field + + +class GithubAuthorizeGetResponse(BaseModel): + """授权跳转 URL 响应""" + result: str = Field(..., description="success/fail") + authorize_url: str = Field(default="", description="GitHub 授权跳转 URL") + state: str = Field(default="", description="防 CSRF state(一次性,300s)") + expires_in: int = Field(default=300, description="state 有效期(秒)") + + +class GithubCallbackPostRequest(BaseModel): + """GitHub 回调请求""" + code: str = Field(..., description="GitHub 回跳授权码") + state: str = Field(..., description="authorize 返回的 state") + + +class PhoneSendCodePostRequest(BaseModel): + """发送验证码请求""" + phone: str = Field(..., description="手机号") + + +class PhoneSendCodePostResponse(BaseModel): + """发送验证码响应""" + result: str = Field(..., description="success/fail") + message: str = Field(..., description="结果消息") + expires_in: int = Field(default=300, description="验证码有效期(秒)") + dev_code: str | None = Field(default=None, description="验证码(仅演示模式返回)") + + +class PhoneLoginPostRequest(BaseModel): + """验证码登录请求""" + phone: str = Field(..., description="手机号") + code: str = Field(..., description="短信验证码") diff --git a/auth_oauth/providers/__init__.py b/auth_oauth/providers/__init__.py new file mode 100644 index 0000000..d1737d5 --- /dev/null +++ b/auth_oauth/providers/__init__.py @@ -0,0 +1 @@ +from . import github # noqa: F401 diff --git a/auth_oauth/providers/github.py b/auth_oauth/providers/github.py new file mode 100644 index 0000000..2d5e06c --- /dev/null +++ b/auth_oauth/providers/github.py @@ -0,0 +1,74 @@ +"""GitHub OAuth 提供方对接(授权码模式)。 + +HTTP 调用独立成模块函数,便于测试替身(monkeypatch)注入。 +""" +import logging + +import httpx + +logger = logging.getLogger("plugins.auth_oauth.github") + +_TOKEN_URL = "https://github.com/login/oauth/access_token" +_USER_API = "https://api.github.com/user" +_USER_EMAILS_API = "https://api.github.com/user/emails" + + +async def exchange_code(client_id: str, client_secret: str, code: str, + redirect_uri: str) -> str: + """授权码换 access_token,失败抛 RuntimeError(业务语义,调用方转 fail 响应)。""" + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.post( + _TOKEN_URL, + json={ + "client_id": client_id, + "client_secret": client_secret, + "code": code, + "redirect_uri": redirect_uri, + }, + headers={"Accept": "application/json"}, + ) + data = resp.json() + if "access_token" not in data: + detail = data.get("error_description") or data.get("error") or data + raise RuntimeError(f"GitHub 授权码换取失败: {detail}") + return data["access_token"] + + +async def fetch_user(access_token: str) -> dict: + """拉取 GitHub 用户并归一化为建号 profile。 + + Returns: + {uid, username, email, full_name, avatar_url} + Raises: + RuntimeError: API 失败或拿不到邮箱(users.email 非空唯一,必须可得)。 + """ + async with httpx.AsyncClient(timeout=10.0) as client: + headers = { + "Authorization": f"Bearer {access_token}", + "Accept": "application/vnd.github+json", + } + resp = await client.get(_USER_API, headers=headers) + if resp.status_code != 200: + raise RuntimeError(f"GitHub 用户信息获取失败: HTTP {resp.status_code}") + u = resp.json() + + email = u.get("email") + if not email: + # 私密邮箱:走 /user/emails 取主邮箱 + er = await client.get(_USER_EMAILS_API, headers=headers) + if er.status_code == 200 and isinstance(er.json(), list): + emails = er.json() + primary = next( + (e for e in emails if e.get("primary")), emails[0] if emails else None + ) + email = primary.get("email") if primary else None + if not email: + raise RuntimeError("GitHub 账号未提供可用邮箱,无法创建账号") + + return { + "uid": str(u["id"]), + "username": u.get("login") or f"github_{u['id']}", + "email": email, + "full_name": u.get("name"), + "avatar_url": u.get("avatar_url"), + } diff --git a/auth_oauth/router/__init__.py b/auth_oauth/router/__init__.py new file mode 100644 index 0000000..6f1fa00 --- /dev/null +++ b/auth_oauth/router/__init__.py @@ -0,0 +1,2 @@ +from .github import github_authorize, github_callback # noqa: F401 +from .phone import phone_send_code, phone_login # noqa: F401 diff --git a/auth_oauth/router/github.py b/auth_oauth/router/github.py new file mode 100644 index 0000000..0e33fab --- /dev/null +++ b/auth_oauth/router/github.py @@ -0,0 +1,86 @@ +"""GitHub OAuth 授权码模式端点(SPA 友好的 JSON 流程)。 + +流程:前端调 authorize 拿 authorize_url → 浏览器跳转 GitHub → +GitHub 回跳前端页(带 code+state)→ 前端调 callback 换平台 token。 +""" +import logging +import secrets +from urllib.parse import urlencode + +from chongming_worker import rpc_method + +from ..models import GithubAuthorizeGetResponse, GithubCallbackPostRequest, LoginPostResponse +from ..providers import github as github_provider +from .. import store + +logger = logging.getLogger("plugins.auth_oauth.github") + +_STATE_TTL = 300 # state 有效期(秒),一次性消费防 CSRF + + +@rpc_method( + subject="auth_oauth.github_authorize", + method="GET", + summary="GitHub 授权登录:生成授权跳转 URL(state 存缓存防 CSRF)", +) +async def github_authorize() -> GithubAuthorizeGetResponse: + from ..global_var import cache, plugin_config + + gh = plugin_config.get("github") or {} + client_id = gh.get("client_id", "") + if not client_id or client_id.startswith("your-"): + return GithubAuthorizeGetResponse( + result="fail", authorize_url="", + state="GitHub OAuth 未配置(config/workers/auth_oauth/config.toml 的 [workers.config.github])", + ) + + state = secrets.token_urlsafe(24) + cache.set(f"auth_oauth:state:{state}", "1", expire=_STATE_TTL) + + params = urlencode({ + "client_id": client_id, + "redirect_uri": gh.get("redirect_uri", ""), + "scope": gh.get("scope", "read:user user:email"), + "state": state, + }) + return GithubAuthorizeGetResponse( + result="success", + authorize_url=f"https://github.com/login/oauth/authorize?{params}", + state=state, + ) + + +@rpc_method( + subject="auth_oauth.github_callback", + method="POST", + summary="GitHub 授权回调:校验 state、换取用户信息,find-or-create 后签发平台 token", +) +async def github_callback(req: GithubCallbackPostRequest) -> LoginPostResponse: + from ..global_var import cache, db, plugin_config + + if db is None or cache is None: + return store.fail_response("插件未初始化") + + # state 一次性消费(防 CSRF / 重放) + state_key = f"auth_oauth:state:{req.state}" + if not cache.get(state_key): + return store.fail_response("state 无效或已过期,请重新发起授权") + cache.delete(state_key) + + gh = plugin_config.get("github") or {} + try: + access_token = await github_provider.exchange_code( + gh.get("client_id", ""), gh.get("client_secret", ""), + req.code, gh.get("redirect_uri", ""), + ) + profile = await github_provider.fetch_user(access_token) + except RuntimeError as e: + logger.warning("GitHub 回调失败: %s", e) + return store.fail_response(str(e)) + + user, message = await store.find_or_create_user( + db, "github", profile["uid"], profile + ) + if user is None: + return store.fail_response(message) + return store.issue_login_response(user) diff --git a/auth_oauth/router/phone.py b/auth_oauth/router/phone.py new file mode 100644 index 0000000..e5fd904 --- /dev/null +++ b/auth_oauth/router/phone.py @@ -0,0 +1,82 @@ +"""手机验证码登录端点(演示模式:验证码存共享缓存、默认随响应返回)。 + +生产接入真实短信通道后,将 send_code 中的 dev_code 分支关闭 +(config: return_otp_in_response = false),验证码只经短信下发。 +""" +import logging +import re +import secrets + +from chongming_worker import rpc_method + +from ..models import ( + LoginPostResponse, + PhoneLoginPostRequest, + PhoneSendCodePostRequest, + PhoneSendCodePostResponse, +) +from .. import store + +logger = logging.getLogger("plugins.auth_oauth.phone") + +_OTP_TTL = 300 # 验证码有效期(秒) +_PHONE_RE = re.compile(r"^1\d{10}$") # 演示按中国大陆手机号校验 + + +def _otp_key(phone: str) -> str: + return f"auth_oauth:otp:{phone}" + + +@rpc_method( + subject="auth_oauth.phone_send_code", + method="POST", + summary="手机验证码登录:下发验证码(演示模式:存缓存并随响应返回)", +) +async def phone_send_code(req: PhoneSendCodePostRequest) -> PhoneSendCodePostResponse: + from ..global_var import cache, plugin_config + + if cache is None: + return PhoneSendCodePostResponse(result="fail", message="插件未初始化") + if not _PHONE_RE.match(req.phone): + return PhoneSendCodePostResponse(result="fail", message="手机号格式不正确") + + code = f"{secrets.randbelow(1_000_000):06d}" + cache.set(_otp_key(req.phone), code, expire=_OTP_TTL) + + # 生产环境在此接真实短信通道;演示模式打印并将验证码随响应返回 + logger.info("[auth_oauth] 验证码已生成 phone=%s code=%s(演示模式,请勿用于生产)", + req.phone, code) + if plugin_config.get("return_otp_in_response", True): + return PhoneSendCodePostResponse( + result="success", message="验证码已发送(演示模式直接返回)", dev_code=code, + ) + return PhoneSendCodePostResponse(result="success", message="验证码已发送") + + +@rpc_method( + subject="auth_oauth.phone_login", + method="POST", + summary="手机验证码登录:校验验证码,find-or-create 后签发平台 token", +) +async def phone_login(req: PhoneLoginPostRequest) -> LoginPostResponse: + from ..global_var import cache, db + + if db is None or cache is None: + return store.fail_response("插件未初始化") + + key = _otp_key(req.phone) + stored = cache.get(key) + if not stored or stored != req.code: + return store.fail_response("验证码错误或已过期") + cache.delete(key) # 一次性消费 + + user, message = await store.find_or_create_user( + db, "phone", req.phone, + profile={ + "username": req.phone, + "email": f"{req.phone}@phone.local", + }, + ) + if user is None: + return store.fail_response(message) + return store.issue_login_response(user) diff --git a/auth_oauth/store.py b/auth_oauth/store.py new file mode 100644 index 0000000..589606e --- /dev/null +++ b/auth_oauth/store.py @@ -0,0 +1,114 @@ +"""身份源插件共享逻辑:绑定查找、账号 find-or-create、平台 token 签发。 + +账号落点: + - 已有绑定 → 直接映射到既有 users 账号(老用户换身份源登录也是同一账号体系) + - 无绑定 → 新建 users 账号(密码为随机值,本插件身份源不走密码登录)+ 绑定记录 +签发: + - chongming_auth.issue_token —— claims 契约与网关内置登录完全一致, + 网关验签 / user_info 透传 / admin 判超管全部无缝生效 +""" +import logging +import secrets +from datetime import datetime + +from chongming_auth import LoginPostResponse, Users, issue_token +from chongming_config import get_config +from chongming_database import Database +from chongming_password import hash_password +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +import time + +from .models import IdentityBinding + +logger = logging.getLogger("plugins.auth_oauth") + +# 演示身份源建号使用的占位密码(随机、不可用于密码登录) +_UNUSABLE_PASSWORD_BYTES = 32 + + +async def _lookup_user(db: Database, provider: str, + provider_uid: str) -> Users | None: + """按绑定关系定位既有账号;无绑定或账号已消失返回 None。""" + async with db.get_session() as session: + binding = ( + await session.execute( + select(IdentityBinding).where( + IdentityBinding.provider == provider, # type: ignore + IdentityBinding.provider_uid == provider_uid, # type: ignore + ) + ) + ).scalar_one_or_none() + if binding is None: + return None + user = ( + await session.execute(select(Users).where(Users.id == binding.user_id)) # type: ignore + ).scalar_one_or_none() + return user + + +async def _create_user_with_binding( + db: Database, provider: str, provider_uid: str, profile: dict +) -> None: + """新建 users 账号 + 绑定记录(同一事务)。 + + profile: {username, email, full_name?, avatar_url?} + """ + async with db.get_session() as session: + user = Users( + username=str(profile.get("username", ""))[:50], + email=str(profile["email"]), + hashed_password=hash_password(secrets.token_urlsafe(_UNUSABLE_PASSWORD_BYTES)), + full_name=profile.get("full_name"), + avatar_url=profile.get("avatar_url"), + created_at=datetime.now(), + updated_at=datetime.now(), + is_active=True, + ) + session.add(user) + await session.flush() # 取 user.id 供绑定外键 + session.add(IdentityBinding( + user_id=user.id, provider=provider, provider_uid=provider_uid, + )) # type: ignore + await session.commit() + + +async def find_or_create_user( + db: Database, provider: str, provider_uid: str, profile: dict +) -> tuple[Users | None, str]: + """按身份源 find-or-create 账号,返回 (user, message)。 + + user 非 None 即成功;唯一约束冲突(并发首登 / username·email 被占)时 + 回退到重查绑定,仍无则返回失败原因。 + """ + user = await _lookup_user(db, provider, provider_uid) + if user is not None: + return user, "" + + try: + await _create_user_with_binding(db, provider, provider_uid, profile) + except IntegrityError as e: + logger.warning("[%s] 建号冲突(%s),尝试复用既有绑定: %s", provider, provider_uid, e) + + user = await _lookup_user(db, provider, provider_uid) + if user is None: + return None, "创建用户失败(用户名或邮箱已被占用),请改用其他方式登录" + return user, "" + + +def issue_login_response(user: Users) -> LoginPostResponse: + """按平台契约签发 token 并组装与内置登录同 shape 的响应。""" + security = get_config()["security"] + token = issue_token( + user, security["secret_key"], security["algorithm"], + ) + return LoginPostResponse( + result="success", token=token, message="登录成功", timestamp=time.time() + ) + + +def fail_response(message: str) -> LoginPostResponse: + """业务失败(不抛异常):result=fail + 原文案,保持与内置登录同 shape。""" + return LoginPostResponse( + result="fail", token="", message=message, timestamp=time.time() + )