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
+170
-20
@@ -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
|
||||
Reference in new issue
Block a user