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:
1 parent
5735ea16d6
commit
f12b18412a
4 files changed
+675
-28
No files matched your search
+183
-6
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user