Merge pull request #34 from openqj/bind-external-identity
oauth: bind existing accounts to external identities
This commit is contained in:
4 files changed
+675
-28
No files matched your search
+19
-2
@@ -139,8 +139,25 @@ client_secret(只走环境变量),不是无认证后门。
|
|||||||
已存在用户每次 OAuth 登录都会轮换 key(旧 key 立即失效),
|
已存在用户每次 OAuth 登录都会轮换 key(旧 key 立即失效),
|
||||||
因此不要把旧 key 存到多处长期复用;key 明文只在回调成功页/
|
因此不要把旧 key 存到多处长期复用;key 明文只在回调成功页/
|
||||||
响应里出现一次,丢了只能重新走一次 OAuth 登录拿新 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
@@ -16,8 +16,15 @@
|
|||||||
- 本模块不存储 access_token:回调成功后只返回外部身份信息,
|
- 本模块不存储 access_token:回调成功后只返回外部身份信息,
|
||||||
access_token 在提取身份后即丢弃。如后续需要用 token 调 provider
|
access_token 在提取身份后即丢弃。如后续需要用 token 调 provider
|
||||||
API,需另设计加密存储,本模块不提供。
|
API,需另设计加密存储,本模块不提供。
|
||||||
- 本模块不做已有本站账号的绑定:映射仍是确定性用户名
|
- 已有本站账号的绑定(本模块提供,server 层接线):绑定表
|
||||||
``oauth:<provider>:<login>`` 新建/复用,绑定流程需另加。
|
``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 入口:server 默认只绑 127.0.0.1,
|
||||||
真实浏览器登录还需要在部署侧配公网可达的 https 回调地址。
|
真实浏览器登录还需要在部署侧配公网可达的 https 回调地址。
|
||||||
|
|
||||||
@@ -79,6 +86,19 @@ class OAuthError(Exception):
|
|||||||
self.reason = reason
|
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):
|
def _provider_config(provider):
|
||||||
"""取 provider 配置,未知 provider 抛 OAuthError(不猜端点)。"""
|
"""取 provider 配置,未知 provider 抛 OAuthError(不猜端点)。"""
|
||||||
config = PROVIDERS.get(provider)
|
config = PROVIDERS.get(provider)
|
||||||
@@ -95,7 +115,8 @@ class OAuthStateStore:
|
|||||||
"""基于 SQLite 的 OAuth state 存储。
|
"""基于 SQLite 的 OAuth state 存储。
|
||||||
|
|
||||||
表 oauth_states:state(主键,随机串)、provider、redirect_uri、
|
表 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 文件。
|
audit_log 表名不冲突,可共用同一个 db 文件。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -108,21 +129,25 @@ class OAuthStateStore:
|
|||||||
"provider TEXT, "
|
"provider TEXT, "
|
||||||
"redirect_uri TEXT, "
|
"redirect_uri TEXT, "
|
||||||
"client_id TEXT, "
|
"client_id TEXT, "
|
||||||
|
"bind_user_id TEXT, "
|
||||||
"created_at REAL)"
|
"created_at REAL)"
|
||||||
)
|
)
|
||||||
# 老库迁移:脚手架版的表没有 client_id 列,缺了就补上。
|
# 老库迁移:脚手架版的表没有 client_id / bind_user_id 列,
|
||||||
# client_id 随 state 入库,是真实浏览器流程的关键:provider
|
# 缺了就补上。client_id 随 state 入库,是真实浏览器流程的
|
||||||
# 重定向只带回 code + state,回调靠 state 记录取回 client_id
|
# 关键;bind_user_id 随 state 入库,是绑定流程的关键
|
||||||
# 才能换 token;补列是 no-op(已有的库再跑一次不变)。
|
# (回调时靠它知道这次回调是绑定到哪个本站账号)。
|
||||||
cols = {row[1] for row in self.conn.execute(
|
cols = {row[1] for row in self.conn.execute(
|
||||||
"PRAGMA table_info(oauth_states)").fetchall()}
|
"PRAGMA table_info(oauth_states)").fetchall()}
|
||||||
if "client_id" not in cols:
|
if "client_id" not in cols:
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"ALTER TABLE oauth_states ADD COLUMN client_id TEXT")
|
"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()
|
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):
|
client_id=None, bind_user_id=None):
|
||||||
"""生成并入库一个随机 state,返回 state 字符串。
|
"""生成并入库一个随机 state,返回 state 字符串。
|
||||||
|
|
||||||
``ttl_seconds`` 只影响后续 ``validate_state`` 的过期判定,
|
``ttl_seconds`` 只影响后续 ``validate_state`` 的过期判定,
|
||||||
@@ -131,6 +156,8 @@ class OAuthStateStore:
|
|||||||
创建时一致,默认两边都是 600 秒)。
|
创建时一致,默认两边都是 600 秒)。
|
||||||
``client_id`` 随 state 一起入库:真实浏览器回调只带
|
``client_id`` 随 state 一起入库:真实浏览器回调只带
|
||||||
code + state,回调方靠它取回登录时传入的 client_id。
|
code + state,回调方靠它取回登录时传入的 client_id。
|
||||||
|
``bind_user_id`` 非空时这个 state 是绑定流程的:回调方靠
|
||||||
|
它知道绑定到哪个本站账号,绝不走登录映射。
|
||||||
"""
|
"""
|
||||||
_provider_config(provider)
|
_provider_config(provider)
|
||||||
if not redirect_uri or not isinstance(redirect_uri, str):
|
if not redirect_uri or not isinstance(redirect_uri, str):
|
||||||
@@ -138,9 +165,10 @@ class OAuthStateStore:
|
|||||||
state = secrets.token_urlsafe(32)
|
state = secrets.token_urlsafe(32)
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"INSERT INTO oauth_states(state, provider, redirect_uri,"
|
"INSERT INTO oauth_states(state, provider, redirect_uri,"
|
||||||
" client_id, created_at)"
|
" client_id, bind_user_id, created_at)"
|
||||||
" VALUES(?, ?, ?, ?, ?)",
|
" VALUES(?, ?, ?, ?, ?, ?)",
|
||||||
(state, provider, redirect_uri, client_id, time.time()),
|
(state, provider, redirect_uri, client_id, bind_user_id,
|
||||||
|
time.time()),
|
||||||
)
|
)
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
return state
|
return state
|
||||||
@@ -166,13 +194,14 @@ class OAuthStateStore:
|
|||||||
if now is None:
|
if now is None:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
row = self.conn.execute(
|
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 = ?",
|
" FROM oauth_states WHERE state = ?",
|
||||||
(state,),
|
(state,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if row is None:
|
if row is None:
|
||||||
return 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:
|
if stored_provider != provider:
|
||||||
return None
|
return None
|
||||||
if redirect_uri is not None and stored_redirect_uri != redirect_uri:
|
if redirect_uri is not None and stored_redirect_uri != redirect_uri:
|
||||||
@@ -190,6 +219,7 @@ class OAuthStateStore:
|
|||||||
"provider": stored_provider,
|
"provider": stored_provider,
|
||||||
"redirect_uri": stored_redirect_uri,
|
"redirect_uri": stored_redirect_uri,
|
||||||
"client_id": stored_client_id,
|
"client_id": stored_client_id,
|
||||||
|
"bind_user_id": stored_bind_user_id,
|
||||||
"created_at": created_at,
|
"created_at": created_at,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -445,9 +475,11 @@ def handle_oauth_callback(state_store, provider, code, state, client_id,
|
|||||||
ttl_seconds=STATE_TTL_SECONDS):
|
ttl_seconds=STATE_TTL_SECONDS):
|
||||||
"""处理 provider 回调:验 state -> 换 token -> 返回外部身份。
|
"""处理 provider 回调:验 state -> 换 token -> 返回外部身份。
|
||||||
|
|
||||||
成功返回 dict:``{"provider", "external_id", "login", "email"}``
|
成功返回 dict:``{"provider", "external_id", "login", "email",
|
||||||
|
"bind_user_id"}``
|
||||||
(缺失的字段为 None;external_id 至少与 login/email 其一存在,
|
(缺失的字段为 None;external_id 至少与 login/email 其一存在,
|
||||||
否则抛 OAuthError)。
|
否则抛 OAuthError;``bind_user_id`` 非空表示这次回调是绑定
|
||||||
|
流程,调用方须走绑定分支、绝不走登录映射)。
|
||||||
|
|
||||||
失败一律抛 OAuthError,不建任何会话、不写任何用户或审计记录:
|
失败一律抛 OAuthError,不建任何会话、不写任何用户或审计记录:
|
||||||
- state 不存在 -> reason=state_not_found;
|
- state 不存在 -> reason=state_not_found;
|
||||||
@@ -484,13 +516,13 @@ def handle_oauth_callback(state_store, provider, code, state, client_id,
|
|||||||
if now is None:
|
if now is None:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
peek = state_store.conn.execute(
|
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 = ?",
|
" FROM oauth_states WHERE state = ?",
|
||||||
(state,),
|
(state,),
|
||||||
).fetchone() if state else None
|
).fetchone() if state else None
|
||||||
if peek is None:
|
if peek is None:
|
||||||
raise OAuthError("unknown state", reason="state_not_found")
|
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 未传
|
# 真实浏览器回调只带 code + state:client_id / redirect_uri 未传
|
||||||
# 时从 state 记录取回(登录时已随 state 入库);显式传入的优先。
|
# 时从 state 记录取回(登录时已随 state 入库);显式传入的优先。
|
||||||
if client_id is None:
|
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:
|
and identity["email"] is None:
|
||||||
raise OAuthError(
|
raise OAuthError(
|
||||||
"empty external identity", reason="identity_missing")
|
"empty external identity", reason="identity_missing")
|
||||||
|
identity["bind_user_id"] = peek_bind_user_id
|
||||||
return identity
|
return identity
|
||||||
|
|
||||||
|
|
||||||
@@ -542,9 +575,8 @@ def map_external_identity_to_local_user(auth_store, provider, identity):
|
|||||||
``AuthStore.create_user`` 创建并返回 ``(user_id, True)``——
|
``AuthStore.create_user`` 创建并返回 ``(user_id, True)``——
|
||||||
创建时生成的 API key 明文按 auth.py 约定只应返回给调用方一次,
|
创建时生成的 API key 明文按 auth.py 约定只应返回给调用方一次,
|
||||||
本函数不返回它(调用方如需发 key 给登录用户,须另行决定
|
本函数不返回它(调用方如需发 key 给登录用户,须另行决定
|
||||||
发放方式,本模块不代发)。这只是脚手架的默认映射:生产若要
|
发放方式,本模块不代发)。这是确定性映射(无绑定时用):
|
||||||
把外部身份绑定到已有本站账号(而非每次新建),需另加绑定表
|
已有本站账号的绑定走下面的绑定表与绑定流程,不走本函数。
|
||||||
与绑定流程,本函数不假装已做绑定。
|
|
||||||
"""
|
"""
|
||||||
_provider_config(provider)
|
_provider_config(provider)
|
||||||
login = identity.get("login") or identity.get("external_id") \
|
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
|
return user_id, True, api_key_plain, False
|
||||||
api_key_plain = auth_store.rotate_api_key(row[0])
|
api_key_plain = auth_store.rotate_api_key(row[0])
|
||||||
return row[0], False, api_key_plain, True
|
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
@@ -65,12 +65,18 @@ from cloud.workers import (
|
|||||||
list_workers as _registry_list,
|
list_workers as _registry_list,
|
||||||
)
|
)
|
||||||
from cloud.oauth import (
|
from cloud.oauth import (
|
||||||
|
BindError,
|
||||||
OAuthError,
|
OAuthError,
|
||||||
OAuthStateStore,
|
OAuthStateStore,
|
||||||
|
bind_external_identity,
|
||||||
|
build_authorization_url,
|
||||||
default_userinfo_client,
|
default_userinfo_client,
|
||||||
|
find_bound_user,
|
||||||
handle_oauth_callback,
|
handle_oauth_callback,
|
||||||
|
list_bindings,
|
||||||
map_external_identity_to_local_user_with_key,
|
map_external_identity_to_local_user_with_key,
|
||||||
start_oauth_login,
|
start_oauth_login,
|
||||||
|
unbind_external_identity,
|
||||||
)
|
)
|
||||||
from cloud.repos import validate_repo_url
|
from cloud.repos import validate_repo_url
|
||||||
from cloud.push import (
|
from cloud.push import (
|
||||||
@@ -528,6 +534,20 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
|||||||
# OAuth 登录/回调是 pre-auth 端点(用户还没本站 API key,
|
# OAuth 登录/回调是 pre-auth 端点(用户还没本站 API key,
|
||||||
# 这正是 OAuth 要解决的问题),靠 state + client_secret
|
# 这正是 OAuth 要解决的问题),靠 state + client_secret
|
||||||
# 认证,不走 X-API-Key;不要把它当成无认证后门。
|
# 认证,不走 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"):
|
if method == "GET" and path.endswith("/login"):
|
||||||
self._oauth_login(path)
|
self._oauth_login(path)
|
||||||
elif method == "GET" and path.endswith("/callback"):
|
elif method == "GET" and path.endswith("/callback"):
|
||||||
@@ -1732,13 +1752,24 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
|||||||
return http_client, userinfo_client
|
return http_client, userinfo_client
|
||||||
|
|
||||||
def _oauth_finish(self, provider, code, state, client_id, redirect_uri):
|
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 分别呈现,绝不把
|
失败抛 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
|
client_id / redirect_uri 为 None 时由 handle_oauth_callback
|
||||||
从 state 记录取回(真实浏览器回调只带 code + state)。
|
从 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)
|
store = OAuthStateStore(self.server.db_path)
|
||||||
try:
|
try:
|
||||||
@@ -1752,6 +1783,22 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
with _auth_store(self.server.db_path) as auth:
|
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 = \
|
local_id, created, api_key_plain, rotated = \
|
||||||
map_external_identity_to_local_user_with_key(
|
map_external_identity_to_local_user_with_key(
|
||||||
auth, provider, identity)
|
auth, provider, identity)
|
||||||
@@ -1759,7 +1806,7 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
|||||||
"%s:%s" % (provider, identity.get("external_id")))
|
"%s:%s" % (provider, identity.get("external_id")))
|
||||||
if rotated:
|
if rotated:
|
||||||
auth.audit(local_id, "", "api_key_rotated", provider)
|
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):
|
def _oauth_callback(self, path):
|
||||||
provider = self._oauth_provider(path)
|
provider = self._oauth_provider(path)
|
||||||
@@ -1778,12 +1825,26 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
|||||||
self._send_json(400, {"error": "code and state required"})
|
self._send_json(400, {"error": "code and state required"})
|
||||||
return
|
return
|
||||||
try:
|
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,
|
self._oauth_finish(provider, code, state, client_id,
|
||||||
redirect_uri)
|
redirect_uri)
|
||||||
|
except BindError as e:
|
||||||
|
self._send_json(409, {"error": str(e), "reason": e.reason})
|
||||||
|
return
|
||||||
except OAuthError as e:
|
except OAuthError as e:
|
||||||
self._send_json(400, {"error": str(e), "reason": e.reason})
|
self._send_json(400, {"error": str(e), "reason": e.reason})
|
||||||
return
|
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 明文只在这里出现一次:调用方须立即保存,
|
# api_key 明文只在这里出现一次:调用方须立即保存,
|
||||||
# 之后任何接口都不再返回它;轮换时旧 key 已失效。
|
# 之后任何接口都不再返回它;轮换时旧 key 已失效。
|
||||||
self._send_json(200, {"user_id": local_id, "created": created,
|
self._send_json(200, {"user_id": local_id, "created": created,
|
||||||
@@ -1838,6 +1899,26 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
|||||||
html.escape(str(api_key_plain)),
|
html.escape(str(api_key_plain)),
|
||||||
" Your old key has expired (rotated)." if rotated else ""))
|
" 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):
|
def _oauth_callback_get(self, path):
|
||||||
provider = self._oauth_provider(path)
|
provider = self._oauth_provider(path)
|
||||||
if provider is None:
|
if provider is None:
|
||||||
@@ -1854,17 +1935,113 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
|||||||
"missing_params", "code and state required"))
|
"missing_params", "code and state required"))
|
||||||
return
|
return
|
||||||
try:
|
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,
|
self._oauth_finish(provider, code, state, client_id,
|
||||||
redirect_uri)
|
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:
|
except OAuthError as e:
|
||||||
self._send_html(400, self._oauth_error_page(
|
self._send_html(400, self._oauth_error_page(
|
||||||
e.reason or "oauth_failed", str(e)))
|
e.reason or "oauth_failed", str(e)))
|
||||||
return
|
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(
|
self._send_html(200, self._oauth_success_page(
|
||||||
provider, local_id, created, identity.get("login"),
|
provider, local_id, created, identity.get("login"),
|
||||||
api_key_plain, rotated))
|
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):
|
def _push_create_endpoint(self, user_id, project_id):
|
||||||
body, err = self._read_json()
|
body, err = self._read_json()
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in new issue
Block a user