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
+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()
)