Merge pull request #4 from openqj/oauth-real-login
OAuth real browser login: GET callback, userinfo, API key issuance
This commit is contained in:
5 files changed
+637
-69
No files matched your search
+59
-19
@@ -83,19 +83,56 @@ X-API-Key,因为外部服务无法持本站 API key,改靠各自的签名认
|
||||
本站 API key——它的认证靠随机 state(10 分钟过期、一次性)加
|
||||
client_secret(只走环境变量),不是无认证后门。
|
||||
|
||||
### OAuth 接线步骤(脚手架,已实现,未接真实应用)
|
||||
### OAuth 真实浏览器登录(已实现,部署前置仍需用户动手)
|
||||
|
||||
1. 注册真实 OAuth 应用(GitHub 或 Google),拿到 client_id,
|
||||
回调地址填本站的 `/oauth/{provider}/callback` 公网地址。
|
||||
2. `GET /oauth/{provider}/login?client_id=...&redirect_uri=...` 拿
|
||||
authorization_url,把用户送到 provider 授权页。
|
||||
3. provider 回调本站并带回 code + state,调
|
||||
`POST /oauth/{provider}/callback`,验 state → 换 token → 映射
|
||||
本站用户并记审计。client_secret 只从环境变量读
|
||||
(`GITHUB_OAUTH_CLIENT_SECRET` / `GOOGLE_OAUTH_CLIENT_SECRET`),
|
||||
绝不写文件、绝不进 URL、换 token 前未配好就明确报错。
|
||||
4. 仍缺:真实应用注册、回调公网地址、已有本站账号的绑定流程
|
||||
(目前是确定性用户名新建/复用,不做账号绑定)。
|
||||
流程(标准 OAuth2 授权码流程):
|
||||
1. `GET /oauth/{provider}/login?client_id=...&redirect_uri=...`
|
||||
返回 `{provider, state, authorization_url}`;state 已入库
|
||||
(10 分钟过期、一次性,provider / redirect_uri / client_id
|
||||
都随 state 一起存下),把用户送到 provider 授权页。
|
||||
2. 用户授权后 provider 重定向到本站
|
||||
`GET /oauth/{provider}/callback?code=...&state=...`(真实浏览器
|
||||
流程只带 code + state;client_id / redirect_uri 由 server 从
|
||||
state 记录取回,不需要 provider 额外带回)。
|
||||
3. server 验 state → 用 code 换 token → 调真实 userinfo 取身份
|
||||
(GitHub:`https://api.github.com/user`,Bearer + User-Agent,
|
||||
取 login/id;Google:`https://openidconnect.googleapis.com/v1/userinfo`,
|
||||
取 sub/email/name)→ 映射到本站用户(确定性用户名
|
||||
`oauth:<provider>:<login>`,新建或复用)→ 签发本站 API key
|
||||
并记审计 `oauth_login`(轮换时另记 `api_key_rotated`)。
|
||||
4. 成功页(GET 回调)只显示 login、user_id、是否新用户和新 API
|
||||
key 一次,并写清“请立即保存,刷新后不再显示,旧 key 已失效
|
||||
(如系轮换)”;成功页绝不显示 access_token、client_secret、
|
||||
旧 API key。`POST /oauth/{provider}/callback`(JSON 版,body 带
|
||||
code/state,client_id/redirect_uri 可省、从 state 取回)同样在
|
||||
成功响应里返回一次新 API key;之后任何接口都不再返回它。
|
||||
失败一律 400:state 无效/过期/不匹配、换 token 失败、userinfo
|
||||
失败、client_secret 缺失都有明确 reason,且不建用户、不发 key。
|
||||
state 仍是一次性、10 分钟过期、provider / redirect_uri 必须匹配。
|
||||
|
||||
真实部署前置(以下任何一项没做,都不算真登录,绝不是“改个
|
||||
环境变量就自动有公网登录”):
|
||||
- 在 GitHub 上注册 OAuth App(或 Google 上注册 OAuth 客户端):
|
||||
应用名自定;Homepage URL 填本站公网地址;Authorization
|
||||
callback URL 必须与本站回调地址逐字一致,即
|
||||
`https://<你的域名>/oauth/github/callback`(Google 同理);
|
||||
非 localhost 时 GitHub 要求回调地址是 https,http 会被拒绝。
|
||||
- client_id 由登录请求方传入(login 接口的 query 参数不变);
|
||||
client_secret 只从环境变量读(GitHub
|
||||
`GITHUB_OAUTH_CLIENT_SECRET`、Google
|
||||
`GOOGLE_OAUTH_CLIENT_SECRET`),绝不写文件、绝不硬编码、
|
||||
绝不出现在授权 URL 或成功页里;secret 缺失时回调明确报
|
||||
`client_secret_missing`,不会以空 secret 去撞真实端点。
|
||||
- 本站控制面必须有公网可达的 https 回调地址:目前 server 默认
|
||||
只绑 `127.0.0.1:8765`,没有公网入口、没有 TLS,这一步是部署
|
||||
前置——需要在部署侧配反向代理 + TLS 证书并把公网域名指到
|
||||
server,再把该域名下的回调地址填进 OAuth 应用的注册信息里。
|
||||
- 登录后 API key 的保存与轮换规则:新用户拿到创建时生成的 key;
|
||||
已存在用户每次 OAuth 登录都会轮换 key(旧 key 立即失效),
|
||||
因此不要把旧 key 存到多处长期复用;key 明文只在回调成功页/
|
||||
响应里出现一次,丢了只能重新走一次 OAuth 登录拿新 key。
|
||||
- 仍缺(非部署前置,但同样未完成):已有本站账号的绑定流程
|
||||
(目前是确定性用户名新建/复用,不做账号绑定)。
|
||||
|
||||
### 推送凭证清单
|
||||
|
||||
@@ -245,7 +282,8 @@ psycopg,写明“需安装 psycopg”),绝不静默回落到 SQLite,
|
||||
| `GET /projects/{id}/cost` | 该项目 `read` | 项目成本总览 + 按天:`?day=YYYY-MM-DD` 查单天,`?days=N` 查最近 N 天;无参数只给总览 |
|
||||
| `GET /models` | 认证即可 | 优先级列表(环境变量 `MODELS_PRIORITY` 逗号分隔,未设用默认)+ 只含调用者成员项目的模型统计与 fallback 事件(`fallback_count > 0` 的任务);不返回 prompt 内容 |
|
||||
| `GET /oauth/{provider}/login?client_id=&redirect_uri=&scope=` | 无需 X-API-Key(pre-auth) | 返回 `{provider, state, authorization_url}`,只返回 URL 不跳转;provider 仅 github/google,未知 `400`;state 已入库,10 分钟过期 |
|
||||
| `POST /oauth/{provider}/callback` | 无需 X-API-Key(pre-auth) | body `{code, state, client_id, redirect_uri}`;验 state → 换 token → 映射到本站用户(复用 users 表,用户名 `oauth:<provider>:<login>`)并记审计 `oauth_login`;state 无效/过期、换 token 失败、无 secret 一律 `400` 且不建会话;成功不返回 API key(发放方式由调用方另行决定) |
|
||||
| `GET /oauth/{provider}/callback?code=&state=` | 无需 X-API-Key(pre-auth) | 真实浏览器回调:验 state(从 state 记录取回 client_id / redirect_uri)→ 换 token → 真实 userinfo 取身份 → 映射本站用户并签发 API key;成功返回 HTML 成功页(login、user_id、是否新用户、新 API key 一次,含保存与轮换提示,不含 access_token / client_secret / 旧 key);state 无效/过期/不匹配、换 token 失败、userinfo 失败、无 secret 一律 `400` 错误页且不建用户、不发 key |
|
||||
| `POST /oauth/{provider}/callback` | 无需 X-API-Key(pre-auth) | JSON 版回调:body `{code, state, client_id?, redirect_uri?}`(后两者可省、从 state 取回);验 state → 换 token → 真实 userinfo 取身份 → 映射到本站用户(复用 users 表,用户名 `oauth:<provider>:<login>`)并签发 API key、记审计 `oauth_login`(轮换时另记 `api_key_rotated`);state 无效/过期、换 token 失败、userinfo 失败、无 secret 一律 `400` 且不建用户、不发 key;成功响应里返回一次新 API key,之后任何接口都不再返回它 |
|
||||
| `POST /projects/{id}/push/subscriptions` | 该项目 `create_task` | 记订阅 body `{channel, target, preference?}`,channel 为 webhook/slack/webpush/apns/fcm,preference 为 instant/digest/silent,返回 `201` |
|
||||
| `GET /projects/{id}/push/subscriptions` | 该项目 `read` | 只列调用者自己的订阅(不泄露他人 target) |
|
||||
| `DELETE /projects/{id}/push/subscriptions/{sid}` | 该项目 `create_task` | 只能删自己的订阅,项目 owner 可删任意;不存在 `404`,他人订阅 `403` |
|
||||
@@ -338,12 +376,14 @@ interrupted 且终态任务 cancel 返回 400、心跳后 sweep 不扫刚心跳
|
||||
Secure Vault 注入、或其它经用户明确同意的渠道),而不是把 key
|
||||
写进配置文件、数据库或代码里凑合。定下之前,定时自动化与 worker
|
||||
的模型回合都只能停在“失败路径可用、模型跑不起来”的状态。
|
||||
- **其它待接线(非外部条件,但同样未完成)**:OAuth 已接成脚手架
|
||||
端点(state + 授权 URL + 回调),但真实应用注册、回调公网地址、
|
||||
已有账号绑定仍缺;推送已接成订阅 + 分发端点,但 Web Push /
|
||||
APNs / FCM 的真实凭证与发送端仍缺;单机 SQLite 在高并发写下
|
||||
仍有上限,生产量上来后需换 Postgres(Postgres 后端已接通,
|
||||
但真实服务器部署与验证仍缺,见上文“数据库后端选择”);
|
||||
- **其它待接线(非外部条件,但同样未完成)**:OAuth 真实浏览器
|
||||
登录已接通(GET 回调 + 真实 userinfo + 回调签发 API key),但
|
||||
真实应用注册、回调公网 https 入口、已有账号绑定仍缺(见上文
|
||||
“OAuth 真实浏览器登录”一节的部署前置);推送已接成订阅 +
|
||||
分发端点,但 Web Push / APNs / FCM 的真实凭证与发送端仍缺;
|
||||
单机 SQLite 在高并发写下仍有上限,生产量上来后需换 Postgres
|
||||
(Postgres 后端已接通,真实服务器部署与验证见上文“数据库后端
|
||||
选择”);
|
||||
worker 执行已接通(见下文“worker 常驻与真取消”),云端执行、
|
||||
真实通知与真实模型回合仍受 key 供给与云账号卡点限制。
|
||||
|
||||
|
||||
@@ -84,6 +84,26 @@ class AuthStore:
|
||||
self.conn.commit()
|
||||
return user_id, api_key_plain
|
||||
|
||||
def rotate_api_key(self, user_id):
|
||||
"""轮换用户的 API key,返回新明文 key(只返回这一次)。
|
||||
|
||||
旧 key 的哈希即被替换,轮换后旧 key 立即失效。
|
||||
用户不存在时抛 ValueError(调用方不得用它凭空建用户)。
|
||||
"""
|
||||
row = self.conn.execute(
|
||||
"SELECT id FROM users WHERE id = ?", (user_id,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise ValueError("unknown user: %r" % (user_id,))
|
||||
api_key_plain = secrets.token_urlsafe(32)
|
||||
api_key_hash = hashlib.sha256(api_key_plain.encode()).hexdigest()
|
||||
self.conn.execute(
|
||||
"UPDATE users SET api_key_hash = ? WHERE id = ?",
|
||||
(api_key_hash, user_id),
|
||||
)
|
||||
self.conn.commit()
|
||||
return api_key_plain
|
||||
|
||||
def authenticate(self, api_key):
|
||||
"""用 API key 认证,成功返回 user_id,失败返回 None。"""
|
||||
if not api_key:
|
||||
|
||||
+168
-33
@@ -1,23 +1,25 @@
|
||||
"""OAuth 接线脚手架(GitHub / Google,首版)。
|
||||
"""OAuth 接线(GitHub / Google)。
|
||||
|
||||
诚实边界:这只是脚手架,不是已接通的真实登录。
|
||||
- 本模块不向真实 GitHub / Google 发任何请求:默认的 HTTP 路径
|
||||
(``_urllib_post``)只在调用方真的传入真实 code 且配好
|
||||
client_secret 时才会被走到;单测一律用注入的 fake http 客户端。
|
||||
真实浏览器登录已接通:server 层提供 ``GET /oauth/{provider}/callback``
|
||||
(provider 重定向带回 code + state),回调验 state、换 token、
|
||||
调真实 userinfo 取身份、映射到本站用户并签发本站 API key。
|
||||
单测一律用注入的 fake http 客户端,不向真实 provider 发请求。
|
||||
|
||||
诚实边界(仍未做的部分,见 cloud/README.md 的 OAuth 一节):
|
||||
- 本模块不硬编码任何真实 client_id / client_secret:client_id 由
|
||||
调用方以参数传入,client_secret 只从环境变量读
|
||||
登录请求方传入并随 state 一起入库(回调时从 state 记录取回,
|
||||
这样浏览器重定向只带 code + state 也能完成换 token),
|
||||
client_secret 只从环境变量读
|
||||
(GitHub:``GITHUB_OAUTH_CLIENT_SECRET``,
|
||||
Google:``GOOGLE_OAUTH_CLIENT_SECRET``),绝不写文件、绝不
|
||||
硬编码、绝不出现在授权 URL 里。
|
||||
硬编码、绝不出现在授权 URL 或成功页里。
|
||||
- 本模块不存储 access_token:回调成功后只返回外部身份信息,
|
||||
access_token 在提取身份后即丢弃。如后续需要用 token 调 provider
|
||||
API,需另设计加密存储,本模块不提供。
|
||||
- 本模块不创建本站会话:回调成功返回的只是外部身份字典,
|
||||
映射到本站用户、建会话、记审计由 server 层用 ``auth.AuthStore``
|
||||
完成(见 ``handle_oauth_callback`` 文档里的 server 层示例)。
|
||||
附带的 ``map_external_identity_to_local_user`` 只复用 auth.py 已有
|
||||
的 users 表(按确定性用户名查找/创建),不发明新的身份表,
|
||||
也不发明新的审计格式。
|
||||
- 本模块不做已有本站账号的绑定:映射仍是确定性用户名
|
||||
``oauth:<provider>:<login>`` 新建/复用,绑定流程需另加。
|
||||
- 本模块不提供公网 https 入口:server 默认只绑 127.0.0.1,
|
||||
真实浏览器登录还需要在部署侧配公网可达的 https 回调地址。
|
||||
|
||||
流程(标准 OAuth2 授权码流程的接线点):
|
||||
state 生成(OAuthStateStore.create_state,存库,默认 10 分钟过期)
|
||||
@@ -49,12 +51,14 @@ PROVIDERS = {
|
||||
"github": {
|
||||
"authorize_url": "https://github.com/login/oauth/authorize",
|
||||
"token_url": "https://github.com/login/oauth/access_token",
|
||||
"userinfo_url": "https://api.github.com/user",
|
||||
"default_scope": "read:user",
|
||||
"client_secret_env": "GITHUB_OAUTH_CLIENT_SECRET",
|
||||
},
|
||||
"google": {
|
||||
"authorize_url": "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
"token_url": "https://oauth2.googleapis.com/token",
|
||||
"userinfo_url": "https://openidconnect.googleapis.com/v1/userinfo",
|
||||
"default_scope": "openid email profile",
|
||||
"client_secret_env": "GOOGLE_OAUTH_CLIENT_SECRET",
|
||||
},
|
||||
@@ -67,7 +71,7 @@ class OAuthError(Exception):
|
||||
``reason`` 供调用方区分(例如记日志或返回不同的错误提示),
|
||||
取值:``unknown_provider`` / ``state_not_found`` / ``state_expired`` /
|
||||
``state_mismatch`` / ``client_secret_missing`` / ``token_exchange_failed``
|
||||
/ ``identity_missing``。
|
||||
/ ``identity_missing`` / ``userinfo_failed`` / ``client_id_missing``。
|
||||
"""
|
||||
|
||||
def __init__(self, message, reason=None):
|
||||
@@ -103,26 +107,40 @@ class OAuthStateStore:
|
||||
"state TEXT PRIMARY KEY, "
|
||||
"provider TEXT, "
|
||||
"redirect_uri TEXT, "
|
||||
"client_id TEXT, "
|
||||
"created_at REAL)"
|
||||
)
|
||||
# 老库迁移:脚手架版的表没有 client_id 列,缺了就补上。
|
||||
# client_id 随 state 入库,是真实浏览器流程的关键:provider
|
||||
# 重定向只带回 code + state,回调靠 state 记录取回 client_id
|
||||
# 才能换 token;补列是 no-op(已有的库再跑一次不变)。
|
||||
cols = {row[1] for row in self.conn.execute(
|
||||
"PRAGMA table_info(oauth_states)").fetchall()}
|
||||
if "client_id" not in cols:
|
||||
self.conn.execute(
|
||||
"ALTER TABLE oauth_states ADD COLUMN client_id TEXT")
|
||||
self.conn.commit()
|
||||
|
||||
def create_state(self, provider, redirect_uri, ttl_seconds=STATE_TTL_SECONDS):
|
||||
def create_state(self, provider, redirect_uri, ttl_seconds=STATE_TTL_SECONDS,
|
||||
client_id=None):
|
||||
"""生成并入库一个随机 state,返回 state 字符串。
|
||||
|
||||
``ttl_seconds`` 只影响后续 ``validate_state`` 的过期判定,
|
||||
本函数不把过期时间单独存一列:判定统一用
|
||||
``created_at + ttl_seconds``(validate 时传入的 ttl 须与
|
||||
创建时一致,默认两边都是 600 秒)。
|
||||
``client_id`` 随 state 一起入库:真实浏览器回调只带
|
||||
code + state,回调方靠它取回登录时传入的 client_id。
|
||||
"""
|
||||
_provider_config(provider)
|
||||
if not redirect_uri or not isinstance(redirect_uri, str):
|
||||
raise ValueError("redirect_uri required")
|
||||
state = secrets.token_urlsafe(32)
|
||||
self.conn.execute(
|
||||
"INSERT INTO oauth_states(state, provider, redirect_uri, created_at)"
|
||||
" VALUES(?, ?, ?, ?)",
|
||||
(state, provider, redirect_uri, time.time()),
|
||||
"INSERT INTO oauth_states(state, provider, redirect_uri,"
|
||||
" client_id, created_at)"
|
||||
" VALUES(?, ?, ?, ?, ?)",
|
||||
(state, provider, redirect_uri, client_id, time.time()),
|
||||
)
|
||||
self.conn.commit()
|
||||
return state
|
||||
@@ -148,13 +166,13 @@ class OAuthStateStore:
|
||||
if now is None:
|
||||
now = time.time()
|
||||
row = self.conn.execute(
|
||||
"SELECT state, provider, redirect_uri, created_at"
|
||||
"SELECT state, provider, redirect_uri, client_id, created_at"
|
||||
" FROM oauth_states WHERE state = ?",
|
||||
(state,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
stored_state, stored_provider, stored_redirect_uri, created_at = row
|
||||
stored_state, stored_provider, stored_redirect_uri, stored_client_id, created_at = row
|
||||
if stored_provider != provider:
|
||||
return None
|
||||
if redirect_uri is not None and stored_redirect_uri != redirect_uri:
|
||||
@@ -171,6 +189,7 @@ class OAuthStateStore:
|
||||
"state": stored_state,
|
||||
"provider": stored_provider,
|
||||
"redirect_uri": stored_redirect_uri,
|
||||
"client_id": stored_client_id,
|
||||
"created_at": created_at,
|
||||
}
|
||||
|
||||
@@ -217,8 +236,10 @@ def start_oauth_login(state_store, provider, client_id, redirect_uri,
|
||||
|
||||
返回 ``(state, authorization_url)``:调用方把用户重定向到该 URL,
|
||||
provider 回调时带回的 state 交给 ``handle_oauth_callback`` 校验。
|
||||
client_id 随 state 一起入库,回调时从 state 记录取回。
|
||||
"""
|
||||
state = state_store.create_state(provider, redirect_uri)
|
||||
state = state_store.create_state(provider, redirect_uri,
|
||||
client_id=client_id)
|
||||
url = build_authorization_url(
|
||||
provider, client_id, redirect_uri, scope=scope, state=state)
|
||||
return state, url
|
||||
@@ -310,15 +331,72 @@ def exchange_code_for_token(provider, code, client_id, redirect_uri,
|
||||
return result
|
||||
|
||||
|
||||
def _urllib_get_json(url, headers):
|
||||
"""默认的真实 userinfo 实现(urllib GET,返回解析后的 dict)。
|
||||
|
||||
非 2xx、非法 JSON、非 dict 一律抛 OAuthError(reason=userinfo_failed),
|
||||
调用方不得把失败静默当成匿名登录。
|
||||
"""
|
||||
request = urllib.request.Request(url, method="GET", headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
raw = response.read().decode("utf-8", "replace")
|
||||
status = response.status
|
||||
except Exception as exc:
|
||||
raise OAuthError(
|
||||
"userinfo request failed: %s" % (exc,),
|
||||
reason="userinfo_failed",
|
||||
) from exc
|
||||
if not (200 <= status < 300):
|
||||
raise OAuthError(
|
||||
"userinfo request returned HTTP %s" % status,
|
||||
reason="userinfo_failed",
|
||||
)
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except ValueError as exc:
|
||||
raise OAuthError(
|
||||
"userinfo response is not JSON", reason="userinfo_failed",
|
||||
) from exc
|
||||
if not isinstance(parsed, dict):
|
||||
raise OAuthError(
|
||||
"userinfo response is not an object", reason="userinfo_failed")
|
||||
return parsed
|
||||
|
||||
|
||||
def default_userinfo_client(provider):
|
||||
"""返回该 provider 的真实 userinfo 调用(access_token -> dict)。
|
||||
|
||||
GitHub 调 https://api.github.com/user(Bearer + User-Agent)取
|
||||
login/id;Google 调 https://openidconnect.googleapis.com/v1/userinfo
|
||||
取 sub/email/name。失败一律抛 OAuthError(reason=userinfo_failed)。
|
||||
"""
|
||||
|
||||
config = _provider_config(provider)
|
||||
url = config["userinfo_url"]
|
||||
|
||||
def fetch(access_token):
|
||||
headers = {"Authorization": "Bearer %s" % access_token,
|
||||
"Accept": "application/json"}
|
||||
if provider == "github":
|
||||
# GitHub 要求 User-Agent,否则可能直接 403。
|
||||
headers["User-Agent"] = "codex-app-server"
|
||||
return _urllib_get_json(url, headers)
|
||||
|
||||
return fetch
|
||||
|
||||
|
||||
def _identity_from_token_or_userinfo(provider, token_result, userinfo_client):
|
||||
"""从 token 响应或 userinfo 回调里抽外部身份。
|
||||
|
||||
顺序:先看 token 响应自带的身份字段(fake / 某些 provider 会带
|
||||
id、login、email),没有则调 userinfo_client(access_token);
|
||||
两边都没有可用身份时返回 None。真实 GitHub 的 token 响应不带
|
||||
身份,生产调用必须传 userinfo_client(例如调
|
||||
https://api.github.com/user 并取 login/id);真实 Google 的
|
||||
id_token 解析同样走 userinfo_client 这一注入点,本模块不解析 JWT。
|
||||
id、login、email),没有则调 userinfo_client(access_token)。
|
||||
userinfo_client 已传入但调用失败、返回非 dict、或无可用身份字段时,
|
||||
抛 OAuthError(reason=userinfo_failed),绝不静默当成匿名登录;
|
||||
只有 userinfo_client 本就没传时才返回 None(调用方再判为
|
||||
identity_missing)。真实 GitHub 的 token 响应不带身份,生产
|
||||
调用必须传 userinfo_client;真实 Google 的 id_token 解析同样
|
||||
走 userinfo_client 这一注入点,本模块不解析 JWT。
|
||||
"""
|
||||
for source in (token_result,):
|
||||
external_id = source.get("id") or source.get("sub")
|
||||
@@ -335,15 +413,24 @@ def _identity_from_token_or_userinfo(provider, token_result, userinfo_client):
|
||||
return None
|
||||
try:
|
||||
info = userinfo_client(token_result["access_token"])
|
||||
except Exception:
|
||||
return None
|
||||
except OAuthError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise OAuthError(
|
||||
"userinfo request failed: %s" % (exc,),
|
||||
reason="userinfo_failed",
|
||||
) from exc
|
||||
if not isinstance(info, dict):
|
||||
return None
|
||||
raise OAuthError(
|
||||
"userinfo response is not an object", reason="userinfo_failed")
|
||||
external_id = info.get("id") or info.get("sub")
|
||||
login = info.get("login") or info.get("name")
|
||||
email = info.get("email")
|
||||
if external_id is None and login is None and email is None:
|
||||
return None
|
||||
raise OAuthError(
|
||||
"userinfo returned no usable identity fields",
|
||||
reason="userinfo_failed",
|
||||
)
|
||||
return {
|
||||
"provider": provider,
|
||||
"external_id": str(external_id) if external_id is not None else None,
|
||||
@@ -368,7 +455,12 @@ def handle_oauth_callback(state_store, provider, code, state, client_id,
|
||||
- state 与 provider / redirect_uri 不匹配 ->
|
||||
reason=state_mismatch;
|
||||
- 换 token 失败 -> reason=token_exchange_failed;
|
||||
- 拿不到外部身份 -> reason=identity_missing。
|
||||
- userinfo 已传但失败/无可用身份 -> reason=userinfo_failed;
|
||||
- 未传 userinfo_client 且 token 本身无身份 -> reason=identity_missing;
|
||||
- state 里没有 client_id 且调用方也没传 -> reason=client_id_missing。
|
||||
|
||||
client_id / redirect_uri 未传时从 state 记录取回(见上文),
|
||||
因此真实浏览器回调只带 code + state 也能完成。
|
||||
|
||||
本函数 deliberately 不做以下三件事,全部留给 server 层(签名
|
||||
与 auth.py 完全兼容,不发明新格式):
|
||||
@@ -392,17 +484,27 @@ def handle_oauth_callback(state_store, provider, code, state, client_id,
|
||||
if now is None:
|
||||
now = time.time()
|
||||
peek = state_store.conn.execute(
|
||||
"SELECT provider, redirect_uri, created_at"
|
||||
"SELECT provider, redirect_uri, client_id, created_at"
|
||||
" FROM oauth_states WHERE state = ?",
|
||||
(state,),
|
||||
).fetchone() if state else None
|
||||
if peek is None:
|
||||
raise OAuthError("unknown state", reason="state_not_found")
|
||||
peek_provider, peek_redirect_uri, peek_created_at = peek
|
||||
peek_provider, peek_redirect_uri, peek_client_id, peek_created_at = peek
|
||||
# 真实浏览器回调只带 code + state:client_id / redirect_uri 未传
|
||||
# 时从 state 记录取回(登录时已随 state 入库);显式传入的优先。
|
||||
if client_id is None:
|
||||
client_id = peek_client_id
|
||||
if redirect_uri is None:
|
||||
redirect_uri = peek_redirect_uri
|
||||
if peek_provider != provider or (
|
||||
redirect_uri is not None and peek_redirect_uri != redirect_uri):
|
||||
raise OAuthError("state does not match provider/redirect_uri",
|
||||
reason="state_mismatch")
|
||||
if not client_id:
|
||||
raise OAuthError(
|
||||
"client_id not recorded for this state; please restart login",
|
||||
reason="client_id_missing")
|
||||
if now > peek_created_at + ttl_seconds:
|
||||
state_store.conn.execute(
|
||||
"DELETE FROM oauth_states WHERE state = ?", (state,))
|
||||
@@ -460,3 +562,36 @@ def map_external_identity_to_local_user(auth_store, provider, identity):
|
||||
return row[0], False
|
||||
user_id, _api_key_plain = auth_store.create_user(username)
|
||||
return user_id, True
|
||||
|
||||
|
||||
def map_external_identity_to_local_user_with_key(auth_store, provider,
|
||||
identity):
|
||||
"""映射到本站用户并签发本站 API key(明文只返回这一次)。
|
||||
|
||||
返回 ``(user_id, created, api_key_plain, rotated)``:
|
||||
- 新用户:经 ``AuthStore.create_user`` 创建,返回创建时生成的
|
||||
key,``rotated`` 为 False;
|
||||
- 已存在用户:经 ``AuthStore.rotate_api_key`` 轮换,返回新 key,
|
||||
旧 key 即失效,``rotated`` 为 True;
|
||||
调用方须把明文 key 只在回调成功页/响应里展示一次,之后任何
|
||||
接口都不再返回它;审计由 server 层记(oauth_login 恒记,
|
||||
轮换时另记 api_key_rotated)。
|
||||
"""
|
||||
_provider_config(provider)
|
||||
login = identity.get("login") or identity.get("external_id") \
|
||||
or identity.get("email")
|
||||
if login is None:
|
||||
raise OAuthError(
|
||||
"identity has no login/external_id/email to map from",
|
||||
reason="identity_missing",
|
||||
)
|
||||
username = "oauth:%s:%s" % (provider, login)
|
||||
row = auth_store.conn.execute(
|
||||
"SELECT id FROM users WHERE username = ?", (username,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
# 新用户:直接返回创建时生成的 key,不再多轮换一次。
|
||||
user_id, api_key_plain = auth_store.create_user(username)
|
||||
return user_id, True, api_key_plain, False
|
||||
api_key_plain = auth_store.rotate_api_key(row[0])
|
||||
return row[0], False, api_key_plain, True
|
||||
+146
-17
@@ -37,6 +37,7 @@ audit_log 三张表;本 server 用 projects/tasks 两张表,表名不冲突
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import html
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
@@ -58,8 +59,9 @@ from cloud.memory import MemoryStore
|
||||
from cloud.oauth import (
|
||||
OAuthError,
|
||||
OAuthStateStore,
|
||||
default_userinfo_client,
|
||||
handle_oauth_callback,
|
||||
map_external_identity_to_local_user,
|
||||
map_external_identity_to_local_user_with_key,
|
||||
start_oauth_login,
|
||||
)
|
||||
from cloud.push import (
|
||||
@@ -280,6 +282,14 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _send_html(self, status, html_body):
|
||||
body = html_body.encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _read_body(self):
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
@@ -341,6 +351,10 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
# 认证,不走 X-API-Key;不要把它当成无认证后门。
|
||||
if method == "GET" and path.endswith("/login"):
|
||||
self._oauth_login(path)
|
||||
elif method == "GET" and path.endswith("/callback"):
|
||||
# 真实浏览器流程:provider 重定向到这里,query 带
|
||||
# code + state(不带 client_id,靠 state 记录取回)。
|
||||
self._oauth_callback_get(path)
|
||||
elif method == "POST" and path.endswith("/callback"):
|
||||
self._oauth_callback(path)
|
||||
else:
|
||||
@@ -1209,6 +1223,51 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
self._send_json(200, {"provider": provider, "state": state,
|
||||
"authorization_url": url})
|
||||
|
||||
def _oauth_clients(self, provider):
|
||||
"""回调用的 http/userinfo 客户端:测试注入优先,生产走真实路径。
|
||||
|
||||
server 属性 oauth_http_client / oauth_userinfo_client 由
|
||||
create_server 置为 None(生产)或由测试注入 fake;http_client
|
||||
已注入(fake)而 userinfo 未注入时不走真实 userinfo(fake
|
||||
token 自带身份字段时不需要它,走真实端点反而会用假 token
|
||||
撞真实 GitHub)。生产(两者都未注入)时 userinfo 走真实端点。
|
||||
"""
|
||||
http_client = getattr(self.server, "oauth_http_client", None)
|
||||
userinfo_client = getattr(self.server, "oauth_userinfo_client", None)
|
||||
if userinfo_client is None and http_client is None:
|
||||
userinfo_client = default_userinfo_client(provider)
|
||||
return http_client, userinfo_client
|
||||
|
||||
def _oauth_finish(self, provider, code, state, client_id, redirect_uri):
|
||||
"""回调共用后半段:验 state -> 换 token -> 取身份 -> 发本站 key。
|
||||
|
||||
成功返回 (local_id, created, api_key_plain, rotated, identity);
|
||||
失败抛 OAuthError(调用方按 JSON/HTML 分别呈现,绝不把
|
||||
access_token、client_secret、旧 API key 写进任何响应)。
|
||||
client_id / redirect_uri 为 None 时由 handle_oauth_callback
|
||||
从 state 记录取回(真实浏览器回调只带 code + state)。
|
||||
"""
|
||||
store = OAuthStateStore(self.server.db_path)
|
||||
try:
|
||||
http_client, userinfo_client = self._oauth_clients(provider)
|
||||
identity = handle_oauth_callback(
|
||||
store, provider, code, state, client_id, redirect_uri,
|
||||
http_client=http_client, userinfo_client=userinfo_client)
|
||||
finally:
|
||||
try:
|
||||
store.conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
with _auth_store(self.server.db_path) as auth:
|
||||
local_id, created, api_key_plain, rotated = \
|
||||
map_external_identity_to_local_user_with_key(
|
||||
auth, provider, identity)
|
||||
auth.audit(local_id, "", "oauth_login",
|
||||
"%s:%s" % (provider, identity.get("external_id")))
|
||||
if rotated:
|
||||
auth.audit(local_id, "", "api_key_rotated", provider)
|
||||
return local_id, created, api_key_plain, rotated, identity
|
||||
|
||||
def _oauth_callback(self, path):
|
||||
provider = self._oauth_provider(path)
|
||||
if provider is None:
|
||||
@@ -1222,30 +1281,96 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
state = body.get("state")
|
||||
client_id = body.get("client_id")
|
||||
redirect_uri = body.get("redirect_uri")
|
||||
if not code or not state or not client_id or not redirect_uri:
|
||||
self._send_json(400, {"error": "code, state, client_id and redirect_uri required"})
|
||||
if not code or not state:
|
||||
self._send_json(400, {"error": "code and state required"})
|
||||
return
|
||||
store = OAuthStateStore(self.server.db_path)
|
||||
try:
|
||||
identity = handle_oauth_callback(
|
||||
store, provider, code, state, client_id, redirect_uri)
|
||||
local_id, created, api_key_plain, rotated, identity = \
|
||||
self._oauth_finish(provider, code, state, client_id,
|
||||
redirect_uri)
|
||||
except OAuthError as e:
|
||||
self._send_json(400, {"error": str(e), "reason": e.reason})
|
||||
return
|
||||
finally:
|
||||
try:
|
||||
store.conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
with _auth_store(self.server.db_path) as auth:
|
||||
local_id, created = map_external_identity_to_local_user(
|
||||
auth, provider, identity)
|
||||
auth.audit(local_id, "", "oauth_login",
|
||||
"%s:%s" % (provider, identity.get("external_id")))
|
||||
# api_key 明文只在这里出现一次:调用方须立即保存,
|
||||
# 之后任何接口都不再返回它;轮换时旧 key 已失效。
|
||||
self._send_json(200, {"user_id": local_id, "created": created,
|
||||
"provider": provider,
|
||||
"login": identity.get("login"),
|
||||
"email": identity.get("email")})
|
||||
"email": identity.get("email"),
|
||||
"api_key": api_key_plain,
|
||||
"api_key_rotated": rotated})
|
||||
|
||||
def _oauth_error_page(self, reason, message):
|
||||
"""GET 回调的 400 错误页:只含 reason 与提示,不含任何密钥、
|
||||
token 或旧 API key(message 来自 OAuthError,本身不含密钥,
|
||||
仍做 HTML 转义)。"""
|
||||
return (
|
||||
"<!doctype html><html><head><meta charset='utf-8'>"
|
||||
"<title>OAuth login failed</title></head><body>"
|
||||
"<h1>OAuth login failed</h1>"
|
||||
"<p>reason: %s</p><p>%s</p>"
|
||||
"<p>Please restart login from the login page.</p>"
|
||||
"</body></html>"
|
||||
% (html.escape(str(reason or "error")),
|
||||
html.escape(str(message))))
|
||||
|
||||
def _oauth_success_page(self, provider, local_id, created, login,
|
||||
api_key_plain, rotated):
|
||||
"""GET 回调的成功页:只显示 login、user_id、created 与新 API key
|
||||
一次;绝不显示 access_token、client_secret、旧 API key。"""
|
||||
rotation_note = (
|
||||
"<p>Your previous API key has been rotated and no longer works.</p>"
|
||||
if rotated else "")
|
||||
return (
|
||||
"<!doctype html><html><head><meta charset='utf-8'>"
|
||||
"<title>OAuth login succeeded</title></head><body>"
|
||||
"<h1>OAuth login succeeded</h1>"
|
||||
"<p>provider: %s</p>"
|
||||
"<p>login: %s</p>"
|
||||
"<p>user_id: %s</p>"
|
||||
"<p>new user: %s</p>"
|
||||
"%s"
|
||||
"<p>Your API key (shown only once, save it now):</p>"
|
||||
"<p><code>%s</code></p>"
|
||||
"<p>Please save it immediately. It will not be shown again "
|
||||
"after you leave or refresh this page."
|
||||
"%s</p>"
|
||||
"<p>Use it as the X-API-Key header for API calls.</p>"
|
||||
"</body></html>"
|
||||
% (html.escape(str(provider)),
|
||||
html.escape(str(login)),
|
||||
html.escape(str(local_id)),
|
||||
"yes" if created else "no",
|
||||
rotation_note,
|
||||
html.escape(str(api_key_plain)),
|
||||
" Your old key has expired (rotated)." if rotated else ""))
|
||||
|
||||
def _oauth_callback_get(self, path):
|
||||
provider = self._oauth_provider(path)
|
||||
if provider is None:
|
||||
self._send_html(404, self._oauth_error_page(
|
||||
"not_found", "unknown oauth path"))
|
||||
return
|
||||
query = parse_qs(urlparse(self.path).query)
|
||||
code = (query.get("code") or [None])[0]
|
||||
state = (query.get("state") or [None])[0]
|
||||
client_id = (query.get("client_id") or [None])[0]
|
||||
redirect_uri = (query.get("redirect_uri") or [None])[0]
|
||||
if not code or not state:
|
||||
self._send_html(400, self._oauth_error_page(
|
||||
"missing_params", "code and state required"))
|
||||
return
|
||||
try:
|
||||
local_id, created, api_key_plain, rotated, identity = \
|
||||
self._oauth_finish(provider, code, state, client_id,
|
||||
redirect_uri)
|
||||
except OAuthError as e:
|
||||
self._send_html(400, self._oauth_error_page(
|
||||
e.reason or "oauth_failed", str(e)))
|
||||
return
|
||||
self._send_html(200, self._oauth_success_page(
|
||||
provider, local_id, created, identity.get("login"),
|
||||
api_key_plain, rotated))
|
||||
|
||||
# -- 推送订阅与分发 ---------------------------------------------------
|
||||
def _push_create_endpoint(self, user_id, project_id):
|
||||
@@ -1363,6 +1488,10 @@ def create_server(db_path, host="127.0.0.1", port=8765, backend=None):
|
||||
server = ThreadingHTTPServer((host, port), CloudControlPlaneHandler)
|
||||
server.db_path = db_path
|
||||
server.daemon_threads = True
|
||||
# OAuth 回调注入点:生产为 None(走真实换 token + 真实 userinfo),
|
||||
# 测试可注入 fake http_client / userinfo_client;绝不注入真实 secret。
|
||||
server.oauth_http_client = None
|
||||
server.oauth_userinfo_client = None
|
||||
return server
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
"""OAuth 真实浏览器登录的 HTTP 端点测试。
|
||||
|
||||
不联网、不向真实 GitHub/Google 发请求:server 的注入点
|
||||
(server.oauth_http_client / server.oauth_userinfo_client)挂 fake;
|
||||
真实 userinfo 端点本身只由 cloud.oauth.default_userinfo_client
|
||||
拼 URL,不在这里调真实网络。测试里出现的 token、key 一律是
|
||||
fake 占位,绝无真实 client_secret、真实 token、真实 GitHub 账号。
|
||||
"""
|
||||
|
||||
import http.client
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from cloud.auth import AuthStore
|
||||
from cloud.oauth import OAuthStateStore
|
||||
from cloud.server import create_server
|
||||
except ImportError:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
from cloud.auth import AuthStore
|
||||
from cloud.oauth import OAuthStateStore
|
||||
from cloud.server import create_server
|
||||
|
||||
|
||||
def _fake_token_client(result=None, error=None):
|
||||
def fake(token_url, payload):
|
||||
if error is not None:
|
||||
return {"error": error}
|
||||
return dict(result if result is not None else
|
||||
{"access_token": "fake-token-not-real"})
|
||||
return fake
|
||||
|
||||
|
||||
def _fake_userinfo(result=None, exc=None):
|
||||
def fake(access_token):
|
||||
if exc is not None:
|
||||
raise exc
|
||||
return dict(result if result is not None else
|
||||
{"id": 4242, "login": "gh-user",
|
||||
"email": "gh-user@example.invalid"})
|
||||
return fake
|
||||
|
||||
|
||||
class OAuthRealLoginTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.db_path = os.path.join(self.tmp.name, "control.db")
|
||||
self.auth = AuthStore(self.db_path)
|
||||
self.server = create_server(self.db_path, "127.0.0.1", 0)
|
||||
self.port = self.server.server_address[1]
|
||||
self.thread = threading.Thread(
|
||||
target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
# 默认挂 fake:token 响应故意不带身份字段,逼走 userinfo 路径。
|
||||
self.server.oauth_http_client = _fake_token_client()
|
||||
self.server.oauth_userinfo_client = _fake_userinfo()
|
||||
|
||||
def tearDown(self):
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
self.thread.join(timeout=5)
|
||||
try:
|
||||
self.auth.conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.tmp.cleanup()
|
||||
|
||||
def _req(self, method, path, body=None):
|
||||
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
|
||||
try:
|
||||
headers = {}
|
||||
payload = None
|
||||
if body is not None:
|
||||
import json
|
||||
payload = json.dumps(body).encode("utf-8")
|
||||
headers["Content-Type"] = "application/json"
|
||||
conn.request(method, path, body=payload, headers=headers)
|
||||
resp = conn.getresponse()
|
||||
data = resp.read().decode("utf-8", "replace")
|
||||
finally:
|
||||
conn.close()
|
||||
return resp.status, data
|
||||
|
||||
def _login_state(self):
|
||||
status, _ = self._req(
|
||||
"GET",
|
||||
"/oauth/github/login?client_id=test-client"
|
||||
"&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcb")
|
||||
self.assertEqual(status, 200)
|
||||
store = OAuthStateStore(self.db_path)
|
||||
try:
|
||||
row = store.conn.execute(
|
||||
"SELECT state FROM oauth_states ORDER BY created_at DESC"
|
||||
" LIMIT 1").fetchone()
|
||||
finally:
|
||||
store.conn.close()
|
||||
return row[0]
|
||||
|
||||
def _api_key_in_page(self, page):
|
||||
match = re.search(r"<code>([^<]+)</code>", page)
|
||||
self.assertIsNotNone(match, page)
|
||||
return match.group(1)
|
||||
|
||||
# -- GET 回调成功:发 key、key 可用、轮换旧 key 失效 -------------
|
||||
def test_get_callback_success_issues_key_and_rotation(self):
|
||||
state = self._login_state()
|
||||
status, page = self._req(
|
||||
"GET", "/oauth/github/callback?code=fake-code&state=" + state)
|
||||
self.assertEqual(status, 200, page)
|
||||
self.assertIn("gh-user", page)
|
||||
self.assertIn("new user: yes", page)
|
||||
self.assertNotIn("fake-token-not-real", page)
|
||||
self.assertNotIn("client_secret", page)
|
||||
key1 = self._api_key_in_page(page)
|
||||
user_id = self.auth.authenticate(key1)
|
||||
self.assertIsNotNone(user_id)
|
||||
|
||||
# 同一身份第二次登录:新 key、旧 key 失效、审计有轮换记录。
|
||||
state2 = self._login_state()
|
||||
status, page2 = self._req(
|
||||
"GET", "/oauth/github/callback?code=fake-code&state=" + state2)
|
||||
self.assertEqual(status, 200, page2)
|
||||
self.assertIn("new user: no", page2)
|
||||
self.assertIn("rotated", page2)
|
||||
key2 = self._api_key_in_page(page2)
|
||||
self.assertNotEqual(key1, key2)
|
||||
self.assertIsNone(self.auth.authenticate(key1))
|
||||
self.assertEqual(self.auth.authenticate(key2), user_id)
|
||||
actions = [row["action"] for row in self.auth.list_audit("", limit=50)]
|
||||
self.assertIn("oauth_login", actions)
|
||||
self.assertIn("api_key_rotated", actions)
|
||||
|
||||
def test_post_callback_success_returns_key_once(self):
|
||||
state = self._login_state()
|
||||
import json
|
||||
status, body = self._req(
|
||||
"POST", "/oauth/github/callback",
|
||||
{"code": "fake-code", "state": state,
|
||||
"client_id": "test-client",
|
||||
"redirect_uri": "http://127.0.0.1/cb"})
|
||||
parsed = json.loads(body)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertIn("api_key", parsed)
|
||||
self.assertFalse(parsed["api_key_rotated"])
|
||||
self.assertEqual(self.auth.authenticate(parsed["api_key"]),
|
||||
parsed["user_id"])
|
||||
|
||||
# -- GET 回调失败路径 ----------------------------------------------
|
||||
def test_get_callback_bad_state_400_no_user(self):
|
||||
status, page = self._req(
|
||||
"GET", "/oauth/github/callback?code=x&state=no-such-state")
|
||||
self.assertEqual(status, 400)
|
||||
self.assertNotIn("fake-token-not-real", page)
|
||||
count = self.auth.conn.execute(
|
||||
"SELECT COUNT(*) FROM users").fetchone()[0]
|
||||
self.assertEqual(count, 0)
|
||||
|
||||
def test_get_callback_expired_state_400(self):
|
||||
state = self._login_state()
|
||||
store = OAuthStateStore(self.db_path)
|
||||
try:
|
||||
store.conn.execute(
|
||||
"UPDATE oauth_states SET created_at = ? WHERE state = ?",
|
||||
(time.time() - 601, state))
|
||||
store.conn.commit()
|
||||
finally:
|
||||
store.conn.close()
|
||||
status, _ = self._req(
|
||||
"GET", "/oauth/github/callback?code=x&state=" + state)
|
||||
self.assertEqual(status, 400)
|
||||
|
||||
def test_get_callback_state_provider_mismatch_400(self):
|
||||
state = self._login_state()
|
||||
status, _ = self._req(
|
||||
"GET", "/oauth/google/callback?code=x&state=" + state)
|
||||
self.assertEqual(status, 400)
|
||||
|
||||
def test_get_callback_token_exchange_failure_400_no_user(self):
|
||||
self.server.oauth_http_client = _fake_token_client(
|
||||
error="bad_verification_code")
|
||||
state = self._login_state()
|
||||
status, _ = self._req(
|
||||
"GET", "/oauth/github/callback?code=bad&state=" + state)
|
||||
self.assertEqual(status, 400)
|
||||
count = self.auth.conn.execute(
|
||||
"SELECT COUNT(*) FROM users").fetchone()[0]
|
||||
self.assertEqual(count, 0)
|
||||
|
||||
def test_get_callback_userinfo_failure_400_no_user(self):
|
||||
self.server.oauth_userinfo_client = _fake_userinfo(exc=RuntimeError("down"))
|
||||
state = self._login_state()
|
||||
status, page = self._req(
|
||||
"GET", "/oauth/github/callback?code=x&state=" + state)
|
||||
self.assertEqual(status, 400, page)
|
||||
self.assertIn("userinfo", page)
|
||||
count = self.auth.conn.execute(
|
||||
"SELECT COUNT(*) FROM users").fetchone()[0]
|
||||
self.assertEqual(count, 0)
|
||||
|
||||
def test_get_callback_userinfo_empty_400_no_user(self):
|
||||
self.server.oauth_userinfo_client = _fake_userinfo(result={})
|
||||
state = self._login_state()
|
||||
status, _ = self._req(
|
||||
"GET", "/oauth/github/callback?code=x&state=" + state)
|
||||
self.assertEqual(status, 400)
|
||||
count = self.auth.conn.execute(
|
||||
"SELECT COUNT(*) FROM users").fetchone()[0]
|
||||
self.assertEqual(count, 0)
|
||||
|
||||
def test_get_callback_secret_missing_400_before_network(self):
|
||||
# 真实路径(不注入 fake):无 secret 时换 token 前就拒绝,
|
||||
# 不会走到真实网络;fake userinfo 在此路径下不被使用。
|
||||
self.server.oauth_http_client = None
|
||||
self.server.oauth_userinfo_client = None
|
||||
old = os.environ.pop("GITHUB_OAUTH_CLIENT_SECRET", None)
|
||||
try:
|
||||
state = self._login_state()
|
||||
status, page = self._req(
|
||||
"GET", "/oauth/github/callback?code=x&state=" + state)
|
||||
finally:
|
||||
if old is not None:
|
||||
os.environ["GITHUB_OAUTH_CLIENT_SECRET"] = old
|
||||
self.assertEqual(status, 400, page)
|
||||
self.assertIn("client_secret", page)
|
||||
|
||||
# -- AuthStore 轮换本身 ---------------------------------------------
|
||||
def test_rotate_api_key_old_key_dead(self):
|
||||
user_id, old_key = self.auth.create_user("someone")
|
||||
new_key = self.auth.rotate_api_key(user_id)
|
||||
self.assertNotEqual(old_key, new_key)
|
||||
self.assertIsNone(self.auth.authenticate(old_key))
|
||||
self.assertEqual(self.auth.authenticate(new_key), user_id)
|
||||
with self.assertRaises(ValueError):
|
||||
self.auth.rotate_api_key("no-such-user")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user