modify: auth插件拆分,将token签发部分交给网关
This commit is contained in:
@@ -120,6 +120,7 @@ class AiboxSophonAdapterPlugin(Plugin):
|
|||||||
ip_sound_column_cfg = config.get("ip_sound_column", {})
|
ip_sound_column_cfg = config.get("ip_sound_column", {})
|
||||||
ip_sound_column_enabled = ip_sound_column_cfg.get("enabled", True)
|
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_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", {})
|
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("doubao_analysis_enabled", doubao_enabled)
|
||||||
set_global_var("ip_sound_column_enabled", ip_sound_column_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_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_enabled", alarm_fwd_enabled)
|
||||||
set_global_var("alarm_forward_url", alarm_fwd_url)
|
set_global_var("alarm_forward_url", alarm_fwd_url)
|
||||||
set_global_var("alarm_forward_video_url", alarm_fwd_video_url)
|
set_global_var("alarm_forward_video_url", alarm_fwd_video_url)
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ ip_sound_column_enabled: bool = True
|
|||||||
# ip 音柱 TTS 播放音量(0-100)
|
# ip 音柱 TTS 播放音量(0-100)
|
||||||
ip_sound_column_volume: int = 50
|
ip_sound_column_volume: int = 50
|
||||||
|
|
||||||
|
# ip 音柱 TTS 播放内容(key: Summary,value: tts 文本)
|
||||||
|
ip_sound_column_tts: dict = {}
|
||||||
|
|
||||||
# 告警转发配置
|
# 告警转发配置
|
||||||
alarm_forward_enabled: bool = False
|
alarm_forward_enabled: bool = False
|
||||||
alarm_forward_url: str = "http://dg.datagoo.cn:60014/open-api/event-alarm/receive"
|
alarm_forward_url: str = "http://dg.datagoo.cn:60014/open-api/event-alarm/receive"
|
||||||
|
|||||||
@@ -498,9 +498,14 @@ async def alarm_report(
|
|||||||
Result=AlarmReportResult(Code=0, Desc="Doubao 分析判定为 False,忽略告警")
|
Result=AlarmReportResult(Code=0, Desc="Doubao 分析判定为 False,忽略告警")
|
||||||
)
|
)
|
||||||
|
|
||||||
from .global_var import ip_sound_column_volume
|
|
||||||
|
|
||||||
# 2. ip 音柱 TTS 播放(可通过配置开关关闭),失败时仅记录日志,不影响主流程
|
# 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 = {
|
tts_payload = {
|
||||||
"command": "play_local_tts",
|
"command": "play_local_tts",
|
||||||
"volume": ip_sound_column_volume,
|
"volume": ip_sound_column_volume,
|
||||||
@@ -511,8 +516,9 @@ async def alarm_report(
|
|||||||
"tts_pitch": 50,
|
"tts_pitch": 50,
|
||||||
"tts_speed": 50,
|
"tts_speed": 50,
|
||||||
"tts_voice_name": "xiaoyan",
|
"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)
|
tts_result = await ip_sound_column_tts_play(request.app.state.zenoh_session, tts_payload)
|
||||||
if tts_result.get("result") != 0:
|
if tts_result.get("result") != 0:
|
||||||
logger.warning(f"[AlarmReport] ip_sound_column TTS 播放失败: {tts_result}")
|
logger.warning(f"[AlarmReport] ip_sound_column TTS 播放失败: {tts_result}")
|
||||||
|
|||||||
+40
-32
@@ -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 获取完整用户档案
|
- **获取用户信息**:根据 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 表结构
|
### Users 表结构
|
||||||
|
|
||||||
|
权威定义见 `chongming_auth.models.Users`(本仓库 `models/auth.py` 仅为 re-export):
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
| 字段 | 类型 | 说明 |
|
||||||
| ----------------- | ----------------- | --------------------------------- |
|
| ----------------- | ----------------- | --------------------------------- |
|
||||||
| `id` | `str` (PK) | UUID 格式主键 |
|
| `id` | `str` (PK) | UUID 格式主键 |
|
||||||
@@ -45,6 +55,27 @@
|
|||||||
|
|
||||||
所有端点路由前缀为 `/auth`。
|
所有端点路由前缀为 `/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` — 用户注册
|
### `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>` — 获取用户信息
|
### `GET /auth/me?user_id=<id>` — 获取用户信息
|
||||||
|
|
||||||
需要 JWT 鉴权(`Authorization: Bearer <token>`)。
|
需要 JWT 鉴权(`Authorization: Bearer <token>`)。
|
||||||
@@ -159,9 +168,9 @@ JWT Token 有效期 3 小时,载荷包含:`sub`(用户ID)、`username`
|
|||||||
|
|
||||||
## 默认管理员账户
|
## 默认管理员账户
|
||||||
|
|
||||||
插件基于 `Plugin` 基类统一生命周期:`on_init` 中执行管理员种子逻辑
|
admin 种子逻辑随登录一并内置到网关(网关 lifespan 的 `setup_auth_store`
|
||||||
(先按用户名、再按邮箱兜底查询;多进程并发启动时靠唯一约束 + `IntegrityError` 回滚保证幂等),
|
→ `chongming_auth.seed_admin`,幂等,多进程并发启动安全),不再依赖本插件。
|
||||||
数据库 `auth.db` 由基类统一初始化并在 cleanup 时释放。
|
数据库 `auth.db` 本插件仍由 `Plugin` 基类统一初始化并在 cleanup 时释放。
|
||||||
|
|
||||||
首次启动时自动创建:
|
首次启动时自动创建:
|
||||||
|
|
||||||
@@ -190,8 +199,7 @@ JWT Token 有效期 3 小时,载荷包含:`sub`(用户ID)、`username`
|
|||||||
|
|
||||||
| 工具包 | 用途 |
|
| 工具包 | 用途 |
|
||||||
| -------------------- | ------------------------------------ |
|
| -------------------- | ------------------------------------ |
|
||||||
|
| `chongming-auth` | Users 模型 / 建库 / 登录校验 / 签发(共享契约) |
|
||||||
| `chongming-database` | 异步 SQLite 数据库操作 |
|
| `chongming-database` | 异步 SQLite 数据库操作 |
|
||||||
| `chongming-jwt` | Rust JWT 编码/验证 |
|
|
||||||
| `chongming-password` | Rust Argon2id 密码哈希 |
|
|
||||||
| `chongming-config` | 全局配置加载 |
|
| `chongming-config` | 全局配置加载 |
|
||||||
| `chongming-worker` | `@rpc_method` 装饰器 + Worker 运行时 |
|
| `chongming-worker` | `@rpc_method` 装饰器 + Worker 运行时 |
|
||||||
|
|||||||
+11
-44
@@ -1,19 +1,22 @@
|
|||||||
"""
|
"""
|
||||||
Auth Worker 插件 — 鉴权管理(登录 / 注册 / 资料 / 改密)
|
Auth Worker 插件 — 用户管理(注册 / 资料 / 改密)
|
||||||
|
|
||||||
基于 Plugin 基类统一生命周期:数据库由基类初始化(auth.db),
|
auth 域拆分后本插件只保留用户管理功能(可替换的业务部分);
|
||||||
on_init 中执行 admin 幂等种子逻辑,on_cleanup 无需重复 dispose。
|
登录 / token 签发 / admin 种子已内置在网关(chongming_auth 包 +
|
||||||
|
chongming.app.routes.auth),不依赖本插件是否加载。
|
||||||
|
|
||||||
|
基于 Plugin 基类统一生命周期:数据库由基类初始化(auth.db,与网关内置
|
||||||
|
登录共用同一张 users 表),on_cleanup 无需重复 dispose。
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from chongming_worker.plugin import Plugin
|
from chongming_worker.plugin import Plugin
|
||||||
from chongming_password import hash_password
|
|
||||||
|
|
||||||
from .router import * # noqa: F401,F403 触发 @rpc_method 注册
|
from .router import * # noqa: F401,F403 触发 @rpc_method 注册
|
||||||
|
|
||||||
# 模块加载时即注册 SQLModel 元数据 —— 基类 _bootstrap 建表(create_all)只创建
|
# 模块加载时即注册 SQLModel 元数据 —— 基类 _bootstrap 建表(create_all)只创建
|
||||||
# 已导入的表,若拖到 on_init 才导入会导致全新库缺少 users 表(no such table)。
|
# 已导入的表,若拖到 on_init 才导入会导致全新库缺少 users 表(no such table)。
|
||||||
|
# Users 权威定义在 chongming_auth 包,此处 re-export 注册元数据。
|
||||||
from .models.auth import Users # noqa: F401
|
from .models.auth import Users # noqa: F401
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -23,49 +26,13 @@ class AuthPlugin(Plugin):
|
|||||||
"""Auth 插件 — 统一生命周期。"""
|
"""Auth 插件 — 统一生命周期。"""
|
||||||
|
|
||||||
type = "auth"
|
type = "auth"
|
||||||
db_name = "auth.db" # 自动在 data_dir 下建 SQLite 库
|
db_name = "auth.db" # 自动在 data_dir 下建 SQLite 库(与网关内置登录共用)
|
||||||
auto_bind = True # 首次初始化时自动 auto_bind_models("auth")
|
auto_bind = True # 首次初始化时自动 auto_bind_models("auth")
|
||||||
|
|
||||||
async def on_init(self):
|
async def on_init(self):
|
||||||
"""首次 create_workers 时调用一次:初始化 admin 账号(幂等)。"""
|
"""首次 create_workers 时调用一次。"""
|
||||||
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()
|
|
||||||
|
|
||||||
# 写入全局变量供 router 使用(数据库由基类在 cleanup 时统一 dispose)
|
# 写入全局变量供 router 使用(数据库由基类在 cleanup 时统一 dispose)
|
||||||
self.set_var("db", db)
|
self.set_var("db", self.db)
|
||||||
|
|
||||||
async def on_cleanup(self):
|
async def on_cleanup(self):
|
||||||
"""cleanup 时调用(数据库由基类处理)。"""
|
"""cleanup 时调用(数据库由基类处理)。"""
|
||||||
|
|||||||
+4
-7
@@ -1,14 +1,13 @@
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from chongming_config import get_config
|
from chongming_config import get_config
|
||||||
from chongming_jwt import JWT
|
|
||||||
from chongming_database import Database
|
from chongming_database import Database
|
||||||
from .models import *
|
from .models import *
|
||||||
|
|
||||||
def set_global_var(name: str, value: Any) -> None:
|
def set_global_var(name: str, value: Any) -> None:
|
||||||
"""
|
"""
|
||||||
设置当前模块的全局变量。
|
设置当前模块的全局变量。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name: 变量名(字符串)
|
name: 变量名(字符串)
|
||||||
value: 任意值
|
value: 任意值
|
||||||
@@ -19,11 +18,9 @@ config = get_config()
|
|||||||
|
|
||||||
db: Database | None = None
|
db: Database | None = None
|
||||||
|
|
||||||
secret = config["security"]["secret_key"]
|
# JWT 签发已随登录内置到网关(chongming.app.routes.auth / chongming_auth 包),
|
||||||
algorithm = config["security"]["algorithm"]
|
# 插件不再持有 jwt 实例
|
||||||
jwt = JWT(secret, algorithm)
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"db",
|
"db",
|
||||||
"jwt"
|
]
|
||||||
]
|
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
"""
|
"""
|
||||||
模型导出 — auth
|
模型导出 — auth
|
||||||
自动生成,请勿手动修改
|
自动生成,请勿手动修改
|
||||||
|
(LoginPostRequest/LoginPostResponse 已随内置登录迁移至 chongming_auth 包)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .pydantic import AuthResponse
|
from .pydantic import AuthResponse
|
||||||
from .pydantic import ChangePasswordPostRequest
|
from .pydantic import ChangePasswordPostRequest
|
||||||
from .pydantic import ChangePasswordPostResponse
|
from .pydantic import ChangePasswordPostResponse
|
||||||
from .pydantic import LoginPostRequest
|
|
||||||
from .pydantic import LoginPostResponse
|
|
||||||
from .pydantic import MeGetRequest
|
from .pydantic import MeGetRequest
|
||||||
from .pydantic import MeGetResponse
|
from .pydantic import MeGetResponse
|
||||||
from .pydantic import RegisterPostRequest
|
from .pydantic import RegisterPostRequest
|
||||||
@@ -21,8 +20,6 @@ __all__ = [
|
|||||||
"AuthResponse",
|
"AuthResponse",
|
||||||
"ChangePasswordPostRequest",
|
"ChangePasswordPostRequest",
|
||||||
"ChangePasswordPostResponse",
|
"ChangePasswordPostResponse",
|
||||||
"LoginPostRequest",
|
|
||||||
"LoginPostResponse",
|
|
||||||
"MeGetRequest",
|
"MeGetRequest",
|
||||||
"MeGetResponse",
|
"MeGetResponse",
|
||||||
"RegisterPostRequest",
|
"RegisterPostRequest",
|
||||||
|
|||||||
+8
-100
@@ -1,102 +1,10 @@
|
|||||||
import os
|
"""Users 模型 re-export —— 权威定义已迁移至共享包 chongming_auth。
|
||||||
import time
|
|
||||||
from typing import Optional
|
|
||||||
from datetime import datetime
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from sqlmodel import Field, SQLModel, Column
|
auth 域拆分后:Users 模型 / 建库 / 登录校验 / JWT 签发归 chongming_auth 包
|
||||||
from sqlalchemy import func, DateTime
|
(网关内置登录与 auth 插件共用,指向同一张 users 表)。本模块保留 re-export
|
||||||
|
以兼容插件内既有相对导入(from ..models.auth import Users);
|
||||||
|
re-export 保证全进程单一模型定义,SQLModel 元数据不会重复注册。
|
||||||
|
"""
|
||||||
|
from chongming_auth import Users # noqa: F401
|
||||||
|
|
||||||
|
__all__ = ["Users"]
|
||||||
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="最后一次登录时间"
|
|
||||||
)
|
|
||||||
|
|||||||
+2
-13
@@ -20,18 +20,6 @@ class ChangePasswordPostResponse(BaseModel):
|
|||||||
message: str = Field(...)
|
message: str = Field(...)
|
||||||
timestamp: Optional[float] = Field(default=None)
|
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):
|
class MeGetRequest(BaseModel):
|
||||||
"""获取当前用户信息 — 请求体"""
|
"""获取当前用户信息 — 请求体"""
|
||||||
user_id: str = Field(...)
|
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,8 +1,7 @@
|
|||||||
from .login import login
|
|
||||||
from .register import register
|
from .register import register
|
||||||
from .me import me
|
from .me import me
|
||||||
from .update_profile import update_profile
|
from .update_profile import update_profile
|
||||||
from .change_password import change_password
|
from .change_password import change_password
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["login", "register", "me", "update_profile", "change_password"]
|
__all__ = ["register", "me", "update_profile", "change_password"]
|
||||||
|
|||||||
@@ -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()
|
|
||||||
)
|
|
||||||
@@ -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 调用 |
|
||||||
@@ -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
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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="创建时间",
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -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="短信验证码")
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
from . import github # noqa: F401
|
||||||
@@ -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"),
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
from .github import github_authorize, github_callback # noqa: F401
|
||||||
|
from .phone import phone_send_code, phone_login # noqa: F401
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user