oauth: bind existing accounts to external identities

New external_identities table (UNIQUE(provider, external_id),
UNIQUE(provider, user_id)) + bind/initiate, bindings list/unbind
endpoints. Callback branches on bind_user_id in state: bind success
shows no key and no user id; conflicts are 409
(identity_bound_to_other / user_already_bound); repeat bind to the
same user+identity is idempotent. Login resolution checks bindings
first: a bound identity lands in the bound account and rotates its
key like any OAuth login; unbound identities keep the deterministic
oauth:<provider>:<login> mapping. Unbind restores oauth mapping.
Tests: new cloud/tests/test_oauth_bind.py (11). Full suites: host
29 OK, cloud 389 OK (4 pre-existing skips).
This commit is contained in:
host committed 2026-10-26 15:47:25 +08:00
1 parent 5735ea16d6
commit f12b18412a
4 files changed
+675 -28

No files matched your search

+19 -2
View File
@@ -139,8 +139,25 @@ client_secret(只走环境变量),不是无认证后门。
已存在用户每次 OAuth 登录都会轮换 key(旧 key 立即失效),
因此不要把旧 key 存到多处长期复用;key 明文只在回调成功页/
响应里出现一次,丢了只能重新走一次 OAuth 登录拿新 key。
- 仍缺(非部署前置,但同样未完成):已有本站账号的绑定流程
(目前是确定性用户名新建/复用,不做账号绑定)。
- 已有本站账号的绑定流程(已实现):持本站 key 调
`POST /oauth/{provider}/bind/initiate`(body 含 client_id +
redirect_uri),返回 `{provider, state, authorization_url}`,
state 里带 bind_user_id(10 分钟过期、一次性);用户授权后
回调走绑定分支(GET 回调成功页只显示 provider + 外部 login,
绝不显示任何 key、也不显示本站 user_id;POST 回调返回
`{linked: true, provider, login, external_id, user_id,
already_bound}`,同样不含 key)。绑定表
`external_identities` 有两个唯一约束:一个外部身份只能绑一个
本站账号、一个本站账号每个 provider 只能绑一个外部身份,
冲突返回 409(`identity_bound_to_other` /
`user_already_bound`),同一用户 + 同一身份重复绑是幂等的。
登录解析顺序:先查绑定表——绑过的身份落到被绑账号并按 OAuth
登录规矩轮换 key;没绑过才走确定性 oauth 账号映射。以前
GitHub 登录建的 oauth 账号不会被绑定删掉:绑完后该身份登录
改落到被绑账号,旧 oauth 账号成孤儿(key 仍可用);解绑
(`DELETE /oauth/{provider}/bindings`,须持本站 key,本来就
没有返回 404)后恢复原来的 oauth 账号登录。查看自己的绑定:
`GET /oauth/{provider}/bindings`(须持本站 key,只看得到自己的)。
### 推送凭证清单
+170 -20
View File
@@ -16,8 +16,15 @@
- 本模块不存储 access_token:回调成功后只返回外部身份信息,
access_token 在提取身份后即丢弃。如后续需要用 token 调 provider
API,需另设计加密存储,本模块不提供。
- 本模块不做已有本站账号的绑定:映射仍是确定性用户名
``oauth:<provider>:<login>`` 新建/复用,绑定流程需另加。
- 已有本站账号的绑定(本模块提供,server 层接线):绑定表
``external_identities`` + ``bind_external_identity`` 等函数。
绑定流程:已登录用户(持本站 key)调
``POST /oauth/{provider}/bind/initiate`` 拿授权 URL,state 里带
``bind_user_id``;回调时 state 带 bind 标记即走绑定分支,不走
登录映射。登录解析顺序:先查绑定表(绑过的身份落到被绑账号,
并按 OAuth 登录规矩轮换 key),没绑过才走确定性用户名
``oauth:<provider>:<login>`` 新建/复用。绑定成功页绝不显示任何
key;解绑后恢复原来的 oauth 账号登录。
- 本模块不提供公网 https 入口:server 默认只绑 127.0.0.1,
真实浏览器登录还需要在部署侧配公网可达的 https 回调地址。
@@ -79,6 +86,19 @@ class OAuthError(Exception):
self.reason = reason
class BindError(Exception):
"""绑定冲突:调用方按 409 呈现,绝不静默覆盖别人的绑定。
reason 取值:``identity_bound_to_other``(该外部身份已被另
一个本站账号绑定)/ ``user_already_bound``(该本站账号已绑定
了同一 provider 的另一个外部身份,须先解绑)。
"""
def __init__(self, message, reason=None):
super().__init__(message)
self.reason = reason
def _provider_config(provider):
"""取 provider 配置,未知 provider 抛 OAuthError(不猜端点)。"""
config = PROVIDERS.get(provider)
@@ -95,7 +115,8 @@ class OAuthStateStore:
"""基于 SQLite 的 OAuth state 存储。
表 oauth_states:state(主键,随机串)、provider、redirect_uri、
created_at(epoch 秒)。与 AuthStore 的 users / project_members /
created_at(epoch 秒)、client_id、bind_user_id(绑定流程才有,
登录流程为空)。与 AuthStore 的 users / project_members /
audit_log 表名不冲突,可共用同一个 db 文件。
"""
@@ -108,21 +129,25 @@ class OAuthStateStore:
"provider TEXT, "
"redirect_uri TEXT, "
"client_id TEXT, "
"bind_user_id TEXT, "
"created_at REAL)"
)
# 老库迁移:脚手架版的表没有 client_id 列,缺了就补上。
# client_id 随 state 入库,是真实浏览器流程的关键:provider
# 重定向只带回 code + state,回调靠 state 记录取回 client_id
# 才能换 token;补列是 no-op(已有的库再跑一次不变)。
# 老库迁移:脚手架版的表没有 client_id / bind_user_id 列,
# 缺了就补上。client_id 随 state 入库,是真实浏览器流程的
# 关键;bind_user_id 随 state 入库,是绑定流程的关键
# (回调时靠它知道这次回调是绑定到哪个本站账号)。
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")
if "bind_user_id" not in cols:
self.conn.execute(
"ALTER TABLE oauth_states ADD COLUMN bind_user_id TEXT")
self.conn.commit()
def create_state(self, provider, redirect_uri, ttl_seconds=STATE_TTL_SECONDS,
client_id=None):
client_id=None, bind_user_id=None):
"""生成并入库一个随机 state,返回 state 字符串。
``ttl_seconds`` 只影响后续 ``validate_state`` 的过期判定,
@@ -131,6 +156,8 @@ class OAuthStateStore:
创建时一致,默认两边都是 600 秒)。
``client_id`` 随 state 一起入库:真实浏览器回调只带
code + state,回调方靠它取回登录时传入的 client_id。
``bind_user_id`` 非空时这个 state 是绑定流程的:回调方靠
它知道绑定到哪个本站账号,绝不走登录映射。
"""
_provider_config(provider)
if not redirect_uri or not isinstance(redirect_uri, str):
@@ -138,9 +165,10 @@ class OAuthStateStore:
state = secrets.token_urlsafe(32)
self.conn.execute(
"INSERT INTO oauth_states(state, provider, redirect_uri,"
" client_id, created_at)"
" VALUES(?, ?, ?, ?, ?)",
(state, provider, redirect_uri, client_id, time.time()),
" client_id, bind_user_id, created_at)"
" VALUES(?, ?, ?, ?, ?, ?)",
(state, provider, redirect_uri, client_id, bind_user_id,
time.time()),
)
self.conn.commit()
return state
@@ -166,13 +194,14 @@ class OAuthStateStore:
if now is None:
now = time.time()
row = self.conn.execute(
"SELECT state, provider, redirect_uri, client_id, created_at"
"SELECT state, provider, redirect_uri, client_id, bind_user_id,"
" created_at"
" FROM oauth_states WHERE state = ?",
(state,),
).fetchone()
if row is None:
return None
stored_state, stored_provider, stored_redirect_uri, stored_client_id, created_at = row
stored_state, stored_provider, stored_redirect_uri, stored_client_id, stored_bind_user_id, created_at = row
if stored_provider != provider:
return None
if redirect_uri is not None and stored_redirect_uri != redirect_uri:
@@ -190,6 +219,7 @@ class OAuthStateStore:
"provider": stored_provider,
"redirect_uri": stored_redirect_uri,
"client_id": stored_client_id,
"bind_user_id": stored_bind_user_id,
"created_at": created_at,
}
@@ -445,9 +475,11 @@ def handle_oauth_callback(state_store, provider, code, state, client_id,
ttl_seconds=STATE_TTL_SECONDS):
"""处理 provider 回调:验 state -> 换 token -> 返回外部身份。
成功返回 dict:``{"provider", "external_id", "login", "email"}``
成功返回 dict:``{"provider", "external_id", "login", "email",
"bind_user_id"}``
(缺失的字段为 None;external_id 至少与 login/email 其一存在,
否则抛 OAuthError)。
否则抛 OAuthError;``bind_user_id`` 非空表示这次回调是绑定
流程,调用方须走绑定分支、绝不走登录映射)。
失败一律抛 OAuthError,不建任何会话、不写任何用户或审计记录:
- state 不存在 -> reason=state_not_found;
@@ -484,13 +516,13 @@ 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, client_id, created_at"
"SELECT provider, redirect_uri, client_id, bind_user_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_client_id, peek_created_at = peek
peek_provider, peek_redirect_uri, peek_client_id, peek_bind_user_id, peek_created_at = peek
# 真实浏览器回调只带 code + state:client_id / redirect_uri 未传
# 时从 state 记录取回(登录时已随 state 入库);显式传入的优先。
if client_id is None:
@@ -531,6 +563,7 @@ def handle_oauth_callback(state_store, provider, code, state, client_id,
and identity["email"] is None:
raise OAuthError(
"empty external identity", reason="identity_missing")
identity["bind_user_id"] = peek_bind_user_id
return identity
@@ -542,9 +575,8 @@ def map_external_identity_to_local_user(auth_store, provider, identity):
``AuthStore.create_user`` 创建并返回 ``(user_id, True)``——
创建时生成的 API key 明文按 auth.py 约定只应返回给调用方一次,
本函数不返回它(调用方如需发 key 给登录用户,须另行决定
发放方式,本模块不代发)。这只是脚手架的默认映射:生产若要
把外部身份绑定到已有本站账号(而非每次新建),需另加绑定表
与绑定流程,本函数不假装已做绑定。
发放方式,本模块不代发)。这是确定性映射(无绑定时用):
已有本站账号的绑定走下面的绑定表与绑定流程,不走本函数。
"""
_provider_config(provider)
login = identity.get("login") or identity.get("external_id") \
@@ -595,3 +627,121 @@ def map_external_identity_to_local_user_with_key(auth_store, provider,
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
def _binding_key(identity):
"""绑定键:external_id 优先(GitHub id / Google sub),没有才
用 login;登录解析查绑定时用同一派生,键必须两边一致。"""
return identity.get("external_id") or identity.get("login")
def ensure_external_identities_table(conn):
"""建绑定表(幂等):provider + 外部键 + 外部 login + 本站
user_id + 创建时间;两个唯一约束防一对多乱绑。"""
conn.execute(
"CREATE TABLE IF NOT EXISTS external_identities("
"provider TEXT, "
"external_id TEXT, "
"external_login TEXT, "
"user_id TEXT, "
"created_at REAL, "
"UNIQUE(provider, external_id), "
"UNIQUE(provider, user_id))"
)
conn.commit()
def bind_external_identity(auth_store, provider, identity, user_id):
"""把外部身份绑到已有本站账号。
冲突一律抛 BindError(调用方按 409 呈现),绝不静默覆盖:
- 该外部身份已被另一个本站账号绑定 ->
reason=identity_bound_to_other;
- 该本站账号已绑定了同一 provider 的另一个外部身份 ->
reason=user_already_bound(须先解绑)。
同一用户 + 同一身份重复绑是幂等的:返回 already=True,不报错、
不改行。成功返回绑定 dict(含 already=False)。
"""
_provider_config(provider)
key = _binding_key(identity)
if key is None:
raise BindError("identity has no external_id/login to bind",
reason="identity_missing")
ensure_external_identities_table(auth_store.conn)
row = auth_store.conn.execute(
"SELECT user_id FROM external_identities"
" WHERE provider = ? AND external_id = ?",
(provider, key),
).fetchone()
if row is not None:
if row[0] == user_id:
return {"provider": provider, "external_id": key,
"external_login": identity.get("login"),
"user_id": user_id, "already": True}
raise BindError(
"external identity already bound to another user",
reason="identity_bound_to_other")
row = auth_store.conn.execute(
"SELECT external_id FROM external_identities"
" WHERE provider = ? AND user_id = ?",
(provider, user_id),
).fetchone()
if row is not None:
raise BindError(
"user already bound to another external identity",
reason="user_already_bound")
auth_store.conn.execute(
"INSERT INTO external_identities(provider, external_id,"
" external_login, user_id, created_at)"
" VALUES(?, ?, ?, ?, ?)",
(provider, key, identity.get("login"), user_id, time.time()),
)
auth_store.conn.commit()
return {"provider": provider, "external_id": key,
"external_login": identity.get("login"),
"user_id": user_id, "already": False}
def find_bound_user(auth_store, provider, identity):
"""登录解析用:该外部身份绑到了哪个本站账号,没有返回 None。
键派生与绑定时同一规则(external_id 优先,否则 login)。
"""
_provider_config(provider)
key = _binding_key(identity)
if key is None:
return None
ensure_external_identities_table(auth_store.conn)
row = auth_store.conn.execute(
"SELECT user_id FROM external_identities"
" WHERE provider = ? AND external_id = ?",
(provider, key),
).fetchone()
return row[0] if row is not None else None
def list_bindings(auth_store, user_id):
"""列出某本站账号的全部绑定(按 provider 排序)。"""
ensure_external_identities_table(auth_store.conn)
rows = auth_store.conn.execute(
"SELECT provider, external_id, external_login, created_at"
" FROM external_identities WHERE user_id = ? ORDER BY provider",
(user_id,),
).fetchall()
return [{"provider": r[0], "external_id": r[1],
"external_login": r[2], "created_at": r[3]} for r in rows]
def unbind_external_identity(auth_store, provider, user_id):
"""解绑某本站账号在某 provider 上的绑定:删掉返回 True,
本来就没有返回 False。解绑后该身份登录恢复原来的 oauth
账号映射(旧 oauth 账号若存在,key 仍可用)。"""
_provider_config(provider)
ensure_external_identities_table(auth_store.conn)
cur = auth_store.conn.execute(
"DELETE FROM external_identities"
" WHERE provider = ? AND user_id = ?",
(provider, user_id),
)
auth_store.conn.commit()
return cur.rowcount > 0
+183 -6
View File
@@ -65,12 +65,18 @@ from cloud.workers import (
list_workers as _registry_list,
)
from cloud.oauth import (
BindError,
OAuthError,
OAuthStateStore,
bind_external_identity,
build_authorization_url,
default_userinfo_client,
find_bound_user,
handle_oauth_callback,
list_bindings,
map_external_identity_to_local_user_with_key,
start_oauth_login,
unbind_external_identity,
)
from cloud.repos import validate_repo_url
from cloud.push import (
@@ -528,6 +534,20 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
# OAuth 登录/回调是 pre-auth 端点(用户还没本站 API key,
# 这正是 OAuth 要解决的问题),靠 state + client_secret
# 认证,不走 X-API-Key;不要把它当成无认证后门。
# 绑定端点例外:绑定必须知道绑到哪个已有账号,所以在
# 处理器内验 X-API-Key(无 key 401),分发仍走这支。
bind = self._oauth_bind_route(path)
if bind is not None:
provider, action = bind
if method == "POST" and action == "bind_initiate":
self._oauth_bind_initiate(provider)
elif method == "GET" and action == "bindings":
self._oauth_bindings_get(provider)
elif method == "DELETE" and action == "bindings":
self._oauth_bindings_delete(provider)
else:
self._send_json(404, {"error": "not found"})
return
if method == "GET" and path.endswith("/login"):
self._oauth_login(path)
elif method == "GET" and path.endswith("/callback"):
@@ -1732,13 +1752,24 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
return http_client, userinfo_client
def _oauth_finish(self, provider, code, state, client_id, redirect_uri):
"""回调共用后半段:验 state -> 换 token -> 取身份 -> 发本站 key。
"""回调共用后半段:验 state -> 换 token -> 取身份 -> 分叉。
成功返回 (local_id, created, api_key_plain, rotated, identity);
成功返回 6 元组
``(local_id, created, api_key_plain, rotated, identity, is_bind)``;
失败抛 OAuthError(调用方按 JSON/HTML 分别呈现,绝不把
access_token、client_secret、旧 API key 写进任何响应)。
access_token、client_secret、旧 API key 写进任何响应),
绑定冲突抛 BindError(调用方按 409 呈现)。
client_id / redirect_uri 为 None 时由 handle_oauth_callback
从 state 记录取回(真实浏览器回调只带 code + state)。
分叉规则(顺序即优先级):
1. state 带 bind_user_id -> 绑定分支:把身份绑到该本站
账号,返回 is_bind=True、api_key_plain=None(绑定成功页
绝不显示任何 key);冲突抛 BindError;
2. 身份已绑定到某本站账号 -> 登录落到被绑账号,并按
OAuth 登录规矩轮换 key(created=False、rotated=True),
与 oauth 账号登录同一语义,不发明新语义;
3. 否则走原来的确定性 oauth 账号映射(新建/复用 + 轮换)。
"""
store = OAuthStateStore(self.server.db_path)
try:
@@ -1752,6 +1783,22 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
except Exception:
pass
with _auth_store(self.server.db_path) as auth:
bind_user_id = identity.get("bind_user_id")
if bind_user_id:
binding = bind_external_identity(
auth, provider, identity, bind_user_id)
auth.audit(bind_user_id, "", "bind_external",
"%s:%s" % (provider, identity.get("external_id")))
identity["already_bound"] = binding["already"]
return (bind_user_id, False, None, False, identity, True)
bound_user_id = find_bound_user(auth, provider, identity)
if bound_user_id is not None:
api_key_plain = auth.rotate_api_key(bound_user_id)
auth.audit(bound_user_id, "", "oauth_login",
"%s:%s" % (provider, identity.get("external_id")))
auth.audit(bound_user_id, "", "api_key_rotated", provider)
return (bound_user_id, False, api_key_plain, True, identity,
False)
local_id, created, api_key_plain, rotated = \
map_external_identity_to_local_user_with_key(
auth, provider, identity)
@@ -1759,7 +1806,7 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
"%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
return local_id, created, api_key_plain, rotated, identity, False
def _oauth_callback(self, path):
provider = self._oauth_provider(path)
@@ -1778,12 +1825,26 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
self._send_json(400, {"error": "code and state required"})
return
try:
local_id, created, api_key_plain, rotated, identity = \
local_id, created, api_key_plain, rotated, identity, is_bind = \
self._oauth_finish(provider, code, state, client_id,
redirect_uri)
except BindError as e:
self._send_json(409, {"error": str(e), "reason": e.reason})
return
except OAuthError as e:
self._send_json(400, {"error": str(e), "reason": e.reason})
return
if is_bind:
# 绑定成功:绝不返回任何 key(绑定页的 completer 未必是
# 发起人,key 一旦上页就可能落到别人手里);发起人用
# 自己的 key 调 GET /oauth/{provider}/bindings 核对。
self._send_json(200, {"linked": True, "provider": provider,
"login": identity.get("login"),
"external_id": identity.get("external_id"),
"user_id": local_id,
"already_bound":
identity.get("already_bound", False)})
return
# api_key 明文只在这里出现一次:调用方须立即保存,
# 之后任何接口都不再返回它;轮换时旧 key 已失效。
self._send_json(200, {"user_id": local_id, "created": created,
@@ -1838,6 +1899,26 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
html.escape(str(api_key_plain)),
" Your old key has expired (rotated)." if rotated else ""))
def _oauth_bind_success_page(self, provider, login):
"""绑定成功页:只显示 provider + 外部 login + 成功提示。
绝不显示任何 key,也不显示本站 user_id:回调页的 completer
未必是绑定发起人,key 与内部 id 一旦上页就可能落到别人
手里;发起人用自己的 key 调 GET /oauth/{provider}/bindings
核对绑定。
"""
return (
"<!doctype html><html><head><meta charset='utf-8'>"
"<title>OAuth binding succeeded</title></head><body>"
"<h1>OAuth binding succeeded</h1>"
"<p>provider: %s</p>"
"<p>login: %s</p>"
"<p>Your external identity is now bound to your account.</p>"
"<p>You can verify it from your bindings list.</p>"
"</body></html>"
% (html.escape(str(provider)),
html.escape(str(login))))
def _oauth_callback_get(self, path):
provider = self._oauth_provider(path)
if provider is None:
@@ -1854,17 +1935,113 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
"missing_params", "code and state required"))
return
try:
local_id, created, api_key_plain, rotated, identity = \
local_id, created, api_key_plain, rotated, identity, is_bind = \
self._oauth_finish(provider, code, state, client_id,
redirect_uri)
except BindError as e:
self._send_html(409, self._oauth_error_page(
e.reason or "bind_conflict", str(e)))
return
except OAuthError as e:
self._send_html(400, self._oauth_error_page(
e.reason or "oauth_failed", str(e)))
return
if is_bind:
self._send_html(200, self._oauth_bind_success_page(
provider, identity.get("login")))
return
self._send_html(200, self._oauth_success_page(
provider, local_id, created, identity.get("login"),
api_key_plain, rotated))
# -- OAuth 绑定(已有账号绑外部身份)------------------------------
def _oauth_bind_route(self, path):
"""绑定相关路径解析:返回 (provider, action) 或 None。
POST /oauth/{provider}/bind/initiate -> (provider, "bind_initiate")
GET|DELETE /oauth/{provider}/bindings -> (provider, "bindings")
其它 /oauth/ 路径返回 None,走原来的登录/回调分发。
"""
parts = [p for p in path.split("/") if p]
if len(parts) == 4 and parts[0] == "oauth" \
and parts[2] == "bind" and parts[3] == "initiate":
return parts[1], "bind_initiate"
if len(parts) == 3 and parts[0] == "oauth" \
and parts[2] == "bindings":
return parts[1], "bindings"
return None
def _oauth_bind_initiate(self, provider):
"""绑定起点:须持本站 key(与登录不同,绑定必须知道绑到谁)。
body 须含 client_id + redirect_uri(与登录同款);state 里
带 bind_user_id,回调时靠它走绑定分支。只返回授权 URL,
不直接跳转;跳转由调用方决定。
"""
user_id = self._authenticate()
if user_id is None:
self._send_json(401, {"error": "unauthorized"})
return
body, err = self._read_json()
if err:
self._send_json(400, {"error": err})
return
client_id = body.get("client_id")
redirect_uri = body.get("redirect_uri")
scope = body.get("scope")
if not client_id or not redirect_uri:
self._send_json(400, {"error": "client_id and redirect_uri required"})
return
store = OAuthStateStore(self.server.db_path)
try:
state = store.create_state(
provider, redirect_uri, client_id=client_id,
bind_user_id=user_id)
url = build_authorization_url(
provider, client_id, redirect_uri, scope=scope, state=state)
except OAuthError as e:
self._send_json(400, {"error": str(e), "reason": e.reason})
return
except ValueError as e:
self._send_json(400, {"error": str(e)})
return
finally:
try:
store.conn.close()
except Exception:
pass
self._send_json(200, {"provider": provider, "state": state,
"authorization_url": url})
def _oauth_bindings_get(self, provider):
"""列出调用方自己的绑定(只看得到自己的,别人的看不到)。"""
user_id = self._authenticate()
if user_id is None:
self._send_json(401, {"error": "unauthorized"})
return
with _auth_store(self.server.db_path) as auth:
bindings = list_bindings(auth, user_id)
self._send_json(200, {"provider": provider, "bindings": bindings})
def _oauth_bindings_delete(self, provider):
"""解绑调用方自己在该 provider 上的绑定:本来就没有返回 404。
解绑后该身份登录恢复原来的 oauth 账号映射(旧 oauth 账号
若存在,key 仍可用,不会被解绑删掉)。
"""
user_id = self._authenticate()
if user_id is None:
self._send_json(401, {"error": "unauthorized"})
return
with _auth_store(self.server.db_path) as auth:
ok = unbind_external_identity(auth, provider, user_id)
if ok:
auth.audit(user_id, "", "unbind_external", provider)
if not ok:
self._send_json(404, {"error": "no binding for provider"})
return
self._send_json(200, {"unbound": True, "provider": provider})
# -- 推送订阅与分发 ---------------------------------------------------
def _push_create_endpoint(self, user_id, project_id):
body, err = self._read_json()
+303
View File
@@ -0,0 +1,303 @@
"""OAuth 绑定单测:已有本站账号绑外部身份 + 登录解析到绑定账号。
不联网、不向真实 GitHub/Google 发请求:server 的注入点
(server.oauth_http_client / server.oauth_userinfo_client)挂 fake;
测试里出现的 token、key 一律是 fake 占位,绝无真实 secret、
真实 token、真实 GitHub 账号。
"""
import http.client
import json
import os
import sqlite3
import sys
import tempfile
import threading
import unittest
from pathlib import Path
try:
from cloud.auth import AuthStore
from cloud.db import connect as _db_connect
from cloud.oauth import (
BindError,
OAuthStateStore,
bind_external_identity,
find_bound_user,
list_bindings,
unbind_external_identity,
)
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.db import connect as _db_connect
from cloud.oauth import (
BindError,
OAuthStateStore,
bind_external_identity,
find_bound_user,
list_bindings,
unbind_external_identity,
)
from cloud.server import create_server
def _fake_token_client():
def fake(token_url, payload):
return {"access_token": "fake-token-not-real"}
return fake
def _fake_userinfo(user_id=4242, login="gh-user"):
def fake(access_token):
return {"id": user_id, "login": login,
"email": "%s@example.invalid" % login}
return fake
class BindFunctionTestCase(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.user_a, _ = self.auth.create_user("user-a")
self.user_b, _ = self.auth.create_user("user-b")
def tearDown(self):
try:
self.auth.conn.close()
except Exception:
pass
self.tmp.cleanup()
def _ident(self, external_id=4242, login="gh-user"):
return {"provider": "github", "external_id": str(external_id),
"login": login, "email": None}
def test_bind_happy_and_idempotent(self):
first = bind_external_identity(
self.auth, "github", self._ident(), self.user_a)
self.assertFalse(first["already"])
again = bind_external_identity(
self.auth, "github", self._ident(), self.user_a)
self.assertTrue(again["already"])
self.assertEqual(find_bound_user(
self.auth, "github", self._ident()), self.user_a)
self.assertEqual(len(list_bindings(self.auth, self.user_a)), 1)
def test_identity_bound_to_other_conflicts(self):
bind_external_identity(
self.auth, "github", self._ident(), self.user_a)
with self.assertRaises(BindError) as ctx:
bind_external_identity(
self.auth, "github", self._ident(), self.user_b)
self.assertEqual(ctx.exception.reason, "identity_bound_to_other")
def test_user_already_bound_conflicts(self):
bind_external_identity(
self.auth, "github", self._ident(), self.user_a)
with self.assertRaises(BindError) as ctx:
bind_external_identity(
self.auth, "github", self._ident(9999, "gh-user2"),
self.user_a)
self.assertEqual(ctx.exception.reason, "user_already_bound")
def test_unbind_then_rebind_to_other_user(self):
bind_external_identity(
self.auth, "github", self._ident(), self.user_a)
self.assertTrue(unbind_external_identity(
self.auth, "github", self.user_a))
self.assertFalse(unbind_external_identity(
self.auth, "github", self.user_a))
self.assertIsNone(find_bound_user(
self.auth, "github", self._ident()))
bind_external_identity(
self.auth, "github", self._ident(), self.user_b)
self.assertEqual(find_bound_user(
self.auth, "github", self._ident()), self.user_b)
def test_state_bind_user_id_round_trip_and_migration(self):
# 老表(无 bind_user_id 列)上初始化,列须被补上。
conn = _db_connect(self.db_path)
conn.execute(
"CREATE TABLE IF NOT EXISTS oauth_states("
"state TEXT PRIMARY KEY, provider TEXT, redirect_uri TEXT,"
" client_id TEXT, created_at REAL)")
conn.commit()
conn.close()
store = OAuthStateStore(self.db_path)
cols = {row[1] for row in store.conn.execute(
"PRAGMA table_info(oauth_states)").fetchall()}
self.assertIn("bind_user_id", cols)
state = store.create_state(
"github", "https://example.invalid/cb",
client_id="cid", bind_user_id=self.user_a)
record = store.validate_state(
state, "github", redirect_uri="https://example.invalid/cb")
self.assertEqual(record["bind_user_id"], self.user_a)
store.conn.close()
class BindHttpTestCase(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.user_a, self.key_a = self.auth.create_user("user-a")
self.user_b, self.key_b = self.auth.create_user("user-b")
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()
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, key=None):
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
try:
headers = {}
payload = None
if body is not None:
payload = json.dumps(body)
headers["Content-Type"] = "application/json"
if key is not None:
headers["X-API-Key"] = key
conn.request(method, path, body=payload, headers=headers)
resp = conn.getresponse()
raw = resp.read().decode()
try:
parsed = json.loads(raw)
except ValueError:
parsed = raw
return resp.status, parsed
finally:
conn.close()
def _login_state(self):
status, body = self._req(
"GET", "/oauth/github/login?client_id=cid"
"&redirect_uri=https://example.invalid/cb")
self.assertEqual(status, 200, body)
return body["state"]
def _bind_state(self, key):
status, body = self._req(
"POST", "/oauth/github/bind/initiate",
{"client_id": "cid",
"redirect_uri": "https://example.invalid/cb"}, key=key)
self.assertEqual(status, 200, body)
return body["state"]
def _callback(self, state):
return self._req(
"POST", "/oauth/github/callback",
{"code": "fake-code", "state": state})
def test_initiate_requires_auth_and_params(self):
status, _ = self._req(
"POST", "/oauth/github/bind/initiate",
{"client_id": "cid",
"redirect_uri": "https://example.invalid/cb"})
self.assertEqual(status, 401)
status, body = self._req(
"POST", "/oauth/github/bind/initiate",
{"client_id": "cid"}, key=self.key_a)
self.assertEqual(status, 400, body)
def test_bind_then_login_resolves_to_bound_user(self):
state = self._bind_state(self.key_a)
status, body = self._callback(state)
self.assertEqual(status, 200, body)
self.assertTrue(body["linked"])
self.assertEqual(body["user_id"], self.user_a)
self.assertFalse(body["already_bound"])
self.assertNotIn("api_key", body)
status, body = self._req(
"GET", "/oauth/github/bindings", key=self.key_a)
self.assertEqual(status, 200, body)
self.assertEqual(len(body["bindings"]), 1)
self.assertEqual(body["bindings"][0]["external_login"], "gh-user")
status, body = self._req(
"GET", "/oauth/github/bindings", key=self.key_b)
self.assertEqual(body["bindings"], [])
# 登录(无 bind 标记的 state)落到被绑账号并轮换 key。
status, body = self._callback(self._login_state())
self.assertEqual(status, 200, body)
self.assertEqual(body["user_id"], self.user_a)
self.assertTrue(body["api_key_rotated"])
new_key = body["api_key"]
status, _ = self._req(
"GET", "/oauth/github/bindings", key=self.key_a)
self.assertEqual(status, 401)
status, _ = self._req(
"GET", "/oauth/github/bindings", key=new_key)
self.assertEqual(status, 200)
def test_bind_conflict_identity_bound_to_other(self):
self._callback(self._bind_state(self.key_a))
status, body = self._callback(self._bind_state(self.key_b))
self.assertEqual(status, 409, body)
self.assertEqual(body["reason"], "identity_bound_to_other")
def test_bind_conflict_user_already_bound(self):
self._callback(self._bind_state(self.key_a))
self.server.oauth_userinfo_client = _fake_userinfo(
9999, "gh-user2")
status, body = self._callback(self._bind_state(self.key_a))
self.assertEqual(status, 409, body)
self.assertEqual(body["reason"], "user_already_bound")
def test_unbind_restores_oauth_mapping(self):
self._callback(self._bind_state(self.key_a))
status, body = self._req(
"DELETE", "/oauth/github/bindings", key=self.key_a)
self.assertEqual(status, 200, body)
self.assertTrue(body["unbound"])
status, body = self._req(
"DELETE", "/oauth/github/bindings", key=self.key_a)
self.assertEqual(status, 404, body)
status, body = self._callback(self._login_state())
self.assertEqual(status, 200, body)
self.assertNotEqual(body["user_id"], self.user_a)
conn = sqlite3.connect(self.db_path)
try:
username = conn.execute(
"SELECT username FROM users WHERE id = ?",
(body["user_id"],)).fetchone()[0]
finally:
conn.close()
self.assertEqual(username, "oauth:github:gh-user")
def test_get_callback_bind_page_shows_no_key_or_user_id(self):
state = self._bind_state(self.key_a)
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
try:
conn.request(
"GET", "/oauth/github/callback?code=fake-code&state=%s"
% state)
resp = conn.getresponse()
page = resp.read().decode()
status = resp.status
finally:
conn.close()
self.assertEqual(status, 200, page)
self.assertIn("gh-user", page)
self.assertNotIn("API key", page)
self.assertNotIn(self.user_a, page)
self.assertNotIn("<code>", page)
if __name__ == "__main__":
unittest.main()