从密码后台到飞书扫码登录:一次多公司管理员登录改造复盘
这篇文章记录一次用户管理后台的登录改造:我们把原来依赖固定后台口令的登录方式,调整成基于飞书 OAuth 的管理员扫码登录。改造过程中还有一个很现实的问题:两个不同公司的飞书用户不能简单塞进同一个飞书应用里一起登录。最后我们采用了“一个后台入口 + 多个飞书应用 Provider + 各自白名单”的设计。
文中的域名、公司名、应用 ID、应用 Secret、Open ID、Token 都做了脱敏。示例只展示结构,不展示真实生产配置。
背景
用户管理后台负责创建用户、充值、查看流水、配置倍率等操作。这些都是高权限能力,不能再靠一个可传来传去的固定口令保护。
我们想要的目标很直接:
- 管理员用飞书扫码登录。
- 只有指定的飞书用户能进后台。
- 登录系统不能影响已有客户 API Key、余额、流水和 MCP 调用。
- 后续加管理员时,尽量只改白名单,不改代码。
- 支持两个不同公司的管理员一起使用同一个后台。
最终落地后,后台业务用户数据没有迁移,也没有改表结构。飞书登录只保护管理后台,不参与客户 API Key 的鉴权链路。
最终架构
整体结构可以理解为三层:
浏览器
|
| GET /admin-ui
v
用户管理前端
|
| GET /auth/me
v
用户服务:返回当前登录状态 + 可用飞书 Provider 列表
|
| 点击某个 Provider 的飞书登录
v
GET /login/feishu?provider=company_a
|
| 302 跳转到飞书授权页
v
飞书 OAuth
|
| 回调 /auth/feishu/callback?code=...&state=...
v
用户服务:换 token -> 拉用户信息 -> 校验白名单 -> 写 session cookie
|
v
进入管理后台
管理后台接口本身统一挂管理员认证依赖:
router = APIRouter(
prefix="/admin",
tags=["admin"],
dependencies=[Depends(verify_admin)],
)
也就是说,前端页面可以打开,但真正读取用户列表、创建用户、充值、看流水、改倍率时,必须有合法管理员 session。
为什么一开始不能一个飞书应用解决所有人
我们最初的直觉是:建一个飞书应用,配置一个回调地址,然后把几个管理员的账号都加到白名单里。这样看起来最简单。
实际测试时发现,不同公司的人不一定能直接使用同一个公司的飞书应用授权登录。典型现象是:
你没有“某某后台登录”的使用权限
当前登录账号为 XXX(另一个组织),下列账号均无权限,你可使用其他账号登录
这说明问题不在我们的白名单逻辑,而在飞书应用本身的组织边界。一个企业自建应用天然归属于某个租户/组织。另一个公司的用户即使扫码,也可能在飞书授权阶段就被拦住,根本到不了我们的回调接口。
所以最后的设计不是“一个飞书应用管所有公司”,而是:
同一个后台系统
同一个回调接口
多个飞书应用配置
每个公司一个 Provider
每个 Provider 一份自己的管理员白名单
用户仍然访问同一个后台地址,只是在登录时选择自己所属公司的飞书登录入口。选择之后,系统会跳到对应公司的飞书应用完成授权。
配置模型
代码里保留了单应用配置,也新增了多 Provider 配置。多 Provider 配置存在时,优先使用多 Provider。
脱敏后的配置结构如下:
ADMIN_SESSION_SECRET=change-to-a-long-random-string
ADMIN_UI_PATH=/admin-ui
FEISHU_REDIRECT_URI=https://admin.example.com/auth/feishu/callback
FEISHU_ALLOW_ALL=false
FEISHU_PROVIDERS_JSON=[
{
"id": "company_a",
"name": "公司 A",
"app_id": "<FEISHU_APP_ID_A>",
"app_secret": "<FEISHU_APP_SECRET_A>",
"admin_open_ids": "<OPEN_ID_A_1>,<OPEN_ID_A_2>",
"allow_all": false
},
{
"id": "company_b",
"name": "公司 B",
"app_id": "<FEISHU_APP_ID_B>",
"app_secret": "<FEISHU_APP_SECRET_B>",
"admin_open_ids": "<OPEN_ID_B_1>,<OPEN_ID_B_2>",
"allow_all": false
}
]
真实生产环境里,这个 JSON 是一行环境变量,不提交到仓库。app_secret 只放在服务器环境变量或 .env 中,不出现在前端,也不写入博客、README 或 issue。
代码里对应的数据结构是:
@dataclass(frozen=True)
class FeishuProvider:
id: str
name: str
app_id: str
app_secret: str
admin_emails: str = ""
admin_open_ids: str = ""
allow_all: bool = False
Provider 的 id 用来区分不同公司,name 用来给前端显示,app_id/app_secret 用来和飞书 OAuth 交互,admin_open_ids 是这一家公司的管理员白名单。
登录入口怎么生成
前端先调用:
GET /auth/me
后端返回当前是否已登录,以及哪些飞书 Provider 可用:
{
"authenticated": false,
"user": null,
"feishu_enabled": true,
"feishu_providers": [
{"id": "company_a", "name": "公司 A"},
{"id": "company_b", "name": "公司 B"}
],
"legacy_password_enabled": false
}
注意这里不会返回 app_secret,也不会返回白名单。前端只知道 Provider 的 id 和展示名称。
前端根据 Provider 渲染登录按钮:
box.innerHTML = providers.map(p => `
<button onclick="location.href='/login/feishu?provider=${encodeURIComponent(p.id)}'">
${providers.length > 1 ? p.name + ' · ' : ''}飞书登录
</button>
`).join('');
用户点击“公司 A · 飞书登录”时,请求会进入:
GET /login/feishu?provider=company_a
后端找到对应 Provider,生成飞书授权地址:
state = make_oauth_state(feishu_provider.id)
query = {
"client_id": feishu_provider.app_id,
"redirect_uri": redirect_uri,
"state": state,
}
return RedirectResponse(f"{feishu_auth_url}?{urlencode(query)}")
这里有一个关键点:state 里带了 Provider ID,并且是签名过的。这样回调回来时,我们知道这次登录来自哪个公司的飞书应用,不能被用户随便伪造。
二维码是谁生成的
这里的“扫码登录”不是我们自己生成二维码。我们的系统只负责把浏览器重定向到飞书 OAuth 授权地址:
https://accounts.feishu.cn/open-apis/authen/v1/authorize?client_id=...&redirect_uri=...&state=...
用户看到的二维码、扫码确认、账号选择、组织权限判断,都是飞书授权页完成的。我们只在飞书回调回来以后处理 code 和 state。
所以多公司方案里,每个公司实际对应一个不同的 client_id。用户从同一个后台入口出发,但点的是不同 Provider 的登录按钮,最终跳到对应公司的飞书应用授权页。
飞书应用侧要配置什么
每接入一个公司,就需要在这个公司的飞书开放平台里准备一个应用。核心配置只有几类:
飞书开放平台应用管理入口:
https://open.feishu.cn/app?lang=zh-CN
这里进入的是飞书开放平台的应用管理台。登录后,选择对应公司/组织,再创建或进入用于后台登录的应用。我们不是在自己的系统里创建二维码,也不是自己管理飞书账号密码,而是把“用户身份确认”交给飞书应用完成。
- 应用凭据:拿到 App ID 和 App Secret,放到服务端环境变量。
- 回调地址:配置成同一个后端回调接口,例如:
https://admin.example.com/auth/feishu/callback
- 登录能力:开启网页登录/扫码登录相关能力,让飞书能把授权结果回调给我们。
- 应用可用范围:确保该公司的管理员账号有权限使用这个应用。
- 用户信息权限:至少能在回调后拿到基础用户信息,尤其是 Open ID。
这里最容易出错的是回调地址。飞书后台配置的回调地址,必须和服务端传给飞书的 redirect_uri 完全一致。域名、协议、路径、末尾斜杠不一致,都可能触发“重定向 URL 有误”。
回调地址的原理是:用户在飞书页面扫码确认后,飞书不会直接告诉前端“这个人是谁”,而是把浏览器重定向回我们配置好的 callback,并带上一次性 code 和原样返回的 state。我们的后端收到 callback 后,再用服务端保存的 App Secret 去飞书换取 access token,随后调用 user_info 拿到用户身份。这样 App Secret 不会暴露在浏览器里。
state 为什么要签名
OAuth 回调里会带回 state。如果不签名,用户理论上可以改 URL 里的 Provider ID,让系统拿错应用配置处理回调。
我们的 state 是一个带过期时间的 HMAC 签名 token:
def make_signed_token(payload: dict, ttl_seconds: int) -> str:
now = int(time.time())
body = dict(payload)
body["iat"] = now
body["exp"] = now + ttl_seconds
data = base64url(json.dumps(body))
return f"{data}.{hmac_sha256(data, secret)}"
解析时会做三件事:
- 校验签名是否匹配。
- 校验
exp是否过期。 - 校验
kind是否是feishu_oauth_state。
所以回调只能使用我们刚刚发出去的 state,而且短时间内有效。
回调处理的数据流
飞书授权完成后,会回调:
GET /auth/feishu/callback?code=xxx&state=yyy
后端处理顺序是:
1. 检查 code 是否存在
2. 解析并校验 state
3. 从 state 里拿 provider_id
4. 找到对应 FeishuProvider
5. 用 code + app_id + app_secret 向飞书换 access_token
6. 用 access_token 调飞书 user_info
7. 从 user_info 里拿 name/email/open_id/union_id
8. 校验该用户是否在该 Provider 的管理员白名单里
9. 通过后生成后台 session cookie
10. 跳回 /admin-ui
核心代码逻辑如下:
token_resp = await client.post(
feishu_token_url,
json={
"grant_type": "authorization_code",
"client_id": provider.app_id,
"client_secret": provider.app_secret,
"code": code,
"redirect_uri": redirect_uri,
},
)
access_token = token_payload["data"]["access_token"]
user_resp = await client.get(
feishu_user_info_url,
headers={"Authorization": f"Bearer {access_token}"},
)
user = user_payload["data"]
用户信息示意:
{
"name": "管理员姓名",
"email": "optional@example.com",
"open_id": "<OPEN_ID>",
"union_id": "<UNION_ID>",
"tenant_key": "tenant_xxx"
}
飞书不一定返回 email,尤其是权限、通讯录范围或账号设置不同的时候。因此我们最后主要用 open_id 做白名单。
白名单判断
白名单判断故意做得很简单:
def feishu_admin_allowed(user: dict, provider: FeishuProvider) -> bool:
if provider.allow_all:
return True
allowed_emails = split_csv(provider.admin_emails)
allowed_open_ids = split_csv(provider.admin_open_ids)
email = str(user.get("email") or "").lower()
open_id = str(user.get("open_id") or "").lower()
if allowed_emails and email in allowed_emails:
return True
if allowed_open_ids and open_id in allowed_open_ids:
return True
return False
生产环境里 allow_all 必须保持 false。否则只要能通过对应飞书应用授权的人都能进入后台,风险太大。
如果用户飞书登录成功,但不在白名单里,系统会返回 403 页面,并展示脱敏前的关键信息:
飞书登录成功,但该账号未加入管理员白名单
公司/应用:公司 A
姓名:xxx
Email:-
Open ID:<OPEN_ID>
这个页面的作用很实际:第一次添加管理员时,不需要查数据库,也不需要额外写脚本。让对方扫码一次,失败页会告诉我们应该把哪个 Open ID 加到哪个 Provider 的白名单。
session 怎么做
白名单通过后,后端不会把飞书 access token 存下来,而是生成自己的后台 session:
payload = {
"kind": "admin_session",
"provider": "feishu",
"provider_id": provider.id,
"provider_name": provider.name,
"open_id": user.get("open_id") or "",
"union_id": user.get("union_id") or "",
"email": user.get("email") or "",
"name": user.get("name") or "",
}
session = make_signed_token(payload, ttl_seconds=86400)
然后写入 cookie:
response.set_cookie(
"admin_session_cookie_name",
session,
max_age=86400,
httponly=True,
secure=True,
samesite="lax",
)
几个安全点:
httponly=True:前端 JS 读不到 cookie。secure=True:只走 HTTPS。samesite="lax":降低跨站请求风险,同时不影响正常 OAuth 回跳。- session 自带过期时间。
- session 用服务端 secret 签名,客户端不能改。
退出登录时删除这个 cookie:
GET /logout
为什么没有影响客户 MCP 调用
这是这次改造里最重要的一点。
后台管理员登录和客户 API Key 鉴权是两条链路:
管理员后台:
浏览器 -> 用户服务 /admin-ui -> 飞书 session -> /admin/*
客户 MCP / Skills 调用:
客户端 -> Gateway -> 用户服务 /internal/check -> skill 后端
飞书登录只保护 /admin/* 管理接口,不参与 Gateway 的 Authorization: Bearer sk-... 检查,也不改客户余额表、流水表、API Key 生成逻辑。
所以改造时我们遵守了一个原则:只替换“管理员是谁”的认证方式,不碰“客户是谁、余额多少、能不能调用 skill”的业务链路。
两家公司共用后台的最终方案
最终方案可以画成这样:
┌────────────────────┐
│ 同一个用户管理后台 │
└─────────┬──────────┘
│
/auth/me 返回两个 Provider
│
┌───────────────┴───────────────┐
│ │
公司 A 飞书登录 公司 B 飞书登录
│ │
app_id/app_secret A app_id/app_secret B
│ │
open_id 白名单 A open_id 白名单 B
│ │
└───────────────┬───────────────┘
│
同一个 /auth/feishu/callback
│
state 里区分 provider_id
这个设计的好处:
- 后台地址只有一个,不需要部署两套用户系统。
- 每个公司的飞书应用独立,符合飞书组织边界。
- 每个公司的管理员白名单独立,避免互相污染。
- 回调接口只有一个,Caddy 和后端路由更简单。
- 后续再加第三家公司时,只需要新增一个 Provider 配置。
代价也很明确:
- 前端登录页需要让用户选择所属公司。
- 每个公司都要单独建飞书应用、配置回调地址。
- 每个 Provider 都有自己的 Open ID,不能把 A 公司的 Open ID 直接拿去 B 公司用。
新增一个公司管理员的操作流程
后续添加管理员时,我们按下面流程走:
- 确认这个人属于哪个公司。
- 让他从后台登录页选择对应公司飞书登录。
- 如果还没加入白名单,会看到“未授权”页面。
- 从页面里复制 Open ID。
- 把 Open ID 追加到对应 Provider 的
admin_open_ids。 - 重启用户服务,让环境变量生效。
- 让他重新扫码登录。
只加同公司管理员时,不需要新建飞书应用。只有新增另一个飞书组织/公司时,才需要新建 Provider。
新增一个公司 Provider 的操作流程
如果要接入第三家公司,大致步骤如下:
- 打开飞书开放平台应用管理台:
https://open.feishu.cn/app?lang=zh-CN
- 在该公司的飞书开放平台创建应用,或进入已有的后台登录应用。
- 开启网页登录/扫码登录能力。
- 配置回调地址:
https://admin.example.com/auth/feishu/callback
这个 callback 必须和服务器环境变量里的 FEISHU_REDIRECT_URI 一致。后端发起飞书授权时,会把同一个地址作为 redirect_uri 传给飞书;如果两边不一致,飞书会直接拒绝并提示重定向 URL 有误。
- 获取该应用的 App ID 和 App Secret。
- 在服务器环境变量里给
FEISHU_PROVIDERS_JSON追加一项:
{
"id": "company_c",
"name": "公司 C",
"app_id": "<FEISHU_APP_ID_C>",
"app_secret": "<FEISHU_APP_SECRET_C>",
"admin_open_ids": "",
"allow_all": false
}
- 重启用户服务。
- 让管理员扫码一次,拿到 Open ID。
- 把 Open ID 加入该 Provider 的白名单,再重启一次。
常见问题
1. 飞书提示“重定向 URL 有误”
这通常是飞书应用后台配置的回调地址和代码里传给飞书的 redirect_uri 不一致。
需要检查三处是否完全一致:
飞书应用后台回调地址
FEISHU_REDIRECT_URI
代码中实际发给飞书的 redirect_uri
协议、域名、路径、末尾斜杠都要一致。
2. 飞书提示“没有应用使用权限”
这通常发生在跨公司登录时。用户属于另一个飞书组织,但当前选择的是公司 A 的飞书应用。解决办法不是加白名单,而是给他的公司单独建飞书应用 Provider。
3. 登录成功但显示“不在管理员白名单”
说明飞书授权已经成功,我们也拿到了用户信息,只是白名单没放行。
处理方式:
复制页面上的 Open ID
追加到对应 Provider 的 admin_open_ids
重启用户服务
重新登录
4. 为什么不用手机号做白名单
手机号通常不是 OAuth 用户信息的默认字段,涉及更高权限和隐私授权。为了最小化权限,我们没有把手机号作为主识别方式。
实际实现里,email 也可能拿不到,所以最终以 Open ID 白名单为主。
5. Open ID 会不会变
Open ID 通常在同一个飞书应用/同一个租户上下文里稳定,但它不是跨公司、跨应用都通用的全局 ID。因此我们的白名单是挂在 Provider 下面的:
公司 A 的 open_id 只放公司 A Provider
公司 B 的 open_id 只放公司 B Provider
如果将来要做更复杂的跨组织身份统一,可以再评估 union_id、飞书开放平台应用形态和通讯录权限。但对管理员后台来说,Provider 级 Open ID 白名单更简单,也更可控。
安全边界
这次改造里,我们刻意做了几件事:
- 不再使用 URL token 进入管理后台。
/admin/*统一要求飞书管理员 session。- App Secret 只在服务端环境变量里使用。
- 前端只拿 Provider 名称和 ID,不拿密钥和白名单。
- OAuth state 签名并设置短 TTL。
- 后台 session 签名并设置过期时间。
- Cookie 开启
HttpOnly、Secure、SameSite=Lax。 allow_all默认关闭。- 用户服务数据库不因飞书登录改造而迁移或重建。
- 客户 API Key 鉴权链路不走飞书,不受影响。
还有一个部署层面的要求:用户服务、数据库、上游代理等内部端口应该只监听本机或内网,由反向代理暴露必要路径。管理后台可以走公网域名,但数据库和内部服务不能直接暴露。
这次设计的核心经验
飞书扫码登录本身并不复杂,复杂的是组织边界和运维边界。
如果只有一个公司,一个飞书应用 + 一个白名单就够了。可一旦涉及多个公司,不能假设所有人都能进同一个飞书应用授权页。更稳妥的方式是把“公司/组织”抽象成 Provider:
Provider = 一套飞书应用凭据 + 一个展示名 + 一份管理员白名单
前端只负责让管理员选 Provider,后端通过签名 state 记住 Provider,回调时用对应的 App Secret 换 token,再按 Provider 自己的白名单放行。
这个设计保持了后台入口统一,也尊重了飞书的组织隔离。更重要的是,它没有侵入客户侧 API Key、余额、计费和 MCP 调用链路。管理员登录升级了,客户调用继续保持原样。
陕公网安备61011302002223号