modify: auth插件拆分,将token签发部分交给网关

This commit is contained in:
2026-09-10 16:43:43 +08:00
parent 5ec90a5575
commit c139b0c1d3
23 changed files with 691 additions and 290 deletions
+2
View File
@@ -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)
+3
View File
@@ -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"
+9 -3
View File
@@ -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}")
+40 -32
View File
@@ -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=<id>` — 获取用户信息
需要 JWT 鉴权(`Authorization: Bearer <token>`)。
@@ -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 运行时 |
+11 -44
View File
@@ -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 时调用(数据库由基类处理)。"""
+2 -5
View File
@@ -1,7 +1,6 @@
from typing import Any
from chongming_config import get_config
from chongming_jwt import JWT
from chongming_database import Database
from .models import *
@@ -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"
]
+1 -4
View File
@@ -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",
+8 -100
View File
@@ -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"]
+2 -13
View File
@@ -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
+1 -2
View File
@@ -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"]
-85
View File
@@ -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()
)
+61
View File
@@ -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.<type>.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 调用 |
+65
View File
@@ -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
+27
View File
@@ -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",
]
+21
View File
@@ -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",
]
+34
View File
@@ -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="创建时间",
)
)
+43
View File
@@ -0,0 +1,43 @@
"""auth_oauth 请求/响应模型。
框架契约:所有插件须有 models/pydantic.py(gen_models 的产物位置)——
网关契约校验按 plugins.<type>.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="短信验证码")
+1
View File
@@ -0,0 +1 @@
from . import github # noqa: F401
+74
View File
@@ -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"),
}
+2
View File
@@ -0,0 +1,2 @@
from .github import github_authorize, github_callback # noqa: F401
from .phone import phone_send_code, phone_login # noqa: F401
+86
View File
@@ -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)
+82
View File
@@ -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)
+114
View File
@@ -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()
)