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

+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