如何实现飞书授权登录
前言
最近为 One API 更新了飞书授权登录功能,在此做一些简单的摘要。
飞书本身的授权文档本身还算是比较完善的,但是飞书开放平台的 SEO 做的很差(2024年12月20日论断), 直接 Google 搜索第一页基本上找不到官方文档,另外询问各种 AI 给出的答复也都比较陈旧, 比较意外的是飞书开放平台内置的智能助手还蛮好用:
可以点击该链接体验一下。
我这里简单列出一些重点的官方文档链接,并给出 One API 中的实践,供各位接入飞书授权登录时参考。
标准的 Web 端应用授权登录流程
主要涉及三个 API:
- 获取授权码:https://open.feishu.cn/document/common-capabilities/sso/api/obtain-oauth-code
- 获取 User Access Token:https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/authentication-management/access-token/get-user-access-token
- 使用获取到的 User Access Token 调用获取用户信息接口:https://open.feishu.cn/document/server-docs/authentication-management/login-state-management/get?appId=cli_a4d2fa6f79f8500e
之后我们便可以通过响应中的 open_id
、union_id
或 user_id
等字段来唯一标识用户,
随后便可以基于这些 ID 信息签发 Session,构建应用的登录态。
实际上这篇飞书的官方教程各个流程讲的比较详细,借鉴里面一张图:
One API 中的实践
首先先说明,飞书的 OAuth 接口实际上遵循标准协议,这意味着可以用各种标准的 OAuth 客户端库来进行接入, 官方也给出了完整的代码示例。
由于我这边只是作为 AuthN 协议来用,并不涉及到 refresh token 的使用,所以这里选择了直接对接接口。 这也导致这部分代码没办法很好的复用(因为硬编码了飞书的接口地址),对接其他 OAuth 服务时还得写很多重复代码。
因此建议各位接入时还是直接用标准的 OAuth 客户端库进行接入。
闲言少叙,下面给出 One API 中的实践。
第一步:拼接授权链接,请求用户授权
实际上就是在用户点击飞书授权按钮时调整到飞书的授权页,代码如下:
export async function onLarkOAuthClicked(lark_client_id) {
const state = await getOAuthState();
if (!state) return;
let redirect_uri = `${window.location.origin}/oauth/lark`;
window.open(`https://accounts.feishu.cn/open-apis/authen/v1/authorize?redirect_uri=${redirect_uri}&client_id=${lark_client_id}&state=${state}`);
}
第二步:用户同意授权,飞书授权页携带授权码跳转至重定向地址,得到授权码
在配置飞书应用时,我们有填入重定向地址,例如:https://openai.justsong.cn/oauth/lark
在用户确认授权后,飞书授权页会重定向到该地址,并携带 code 参数,例如:https://openai.justsong.cn/oauth/lark?code=xxx
此时我们的前端代码从 query 参数中解析出授权码,并发送给后端服务:
useEffect(() => {
let code = searchParams.get('code');
let state = searchParams.get('state');
sendCode(code, state, 0).then();
}, []);
其中 sendCode 的实现如下:
const sendCode = async (code, state, count) => {
const res = await API.get(`/api/oauth/lark?code=${code}&state=${state}`);
const { success, message, data } = res.data;
if (success) {
if (message === 'bind') {
showSuccess('绑定成功!');
navigate('/setting');
} else {
userDispatch({ type: 'login', payload: data });
localStorage.setItem('user', JSON.stringify(data));
showSuccess('登录成功!');
navigate('/');
}
} else {
showError(message);
if (count === 0) {
setPrompt(`操作失败,重定向至登录界面中...`);
navigate('/setting'); // in case this is failed to bind lark
return;
}
count++;
setPrompt(`出现错误,第 ${count} 次重试中...`);
await new Promise((resolve) => setTimeout(resolve, count * 2000));
await sendCode(code, state, count);
}
};
第三步:服务端接收到授权码,兑换 User Access Token,并借此获取用户信息,构建登录态
兑换 User Access Token:
values := map[string]string{
"client_id": config.LarkClientId,
"client_secret": config.LarkClientSecret,
"code": code,
"grant_type": "authorization_code",
"redirect_uri": fmt.Sprintf("%s/oauth/lark", config.ServerAddress),
}
jsonData, err := json.Marshal(values)
if err != nil {
return nil, err
}
req, err := http.NewRequest("POST", "https://open.feishu.cn/open-apis/authen/v2/oauth/token", bytes.NewBuffer(jsonData))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
client := http.Client{
Timeout: 5 * time.Second,
}
res, err := client.Do(req)
if err != nil {
logger.SysLog(err.Error())
return nil, errors.New("无法连接至飞书服务器,请稍后重试!")
}
defer res.Body.Close()
var oAuthResponse LarkOAuthResponse
err = json.NewDecoder(res.Body).Decode(&oAuthResponse)
if err != nil {
return nil, err
}
然后获取用户信息:
req, err = http.NewRequest("GET", "https://passport.feishu.cn/suite/passport/oauth/userinfo", nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", oAuthResponse.AccessToken))
res2, err := client.Do(req)
if err != nil {
logger.SysLog(err.Error())
return nil, errors.New("无法连接至飞书服务器,请稍后重试!")
}
var larkUser LarkUser
err = json.NewDecoder(res2.Body).Decode(&larkUser)
if err != nil {
return nil, err
}
return &larkUser, nil
然后利用获取到的 Open ID 查询数据库:
注意处理首次登录的情况(即注册),这里为了简洁起见略过
user := model.User{
LarkId: larkUser.OpenID,
}
err := user.FillUserByLarkId()
// ...
之后便可以构建用户 session,设置 Cookie:
session := sessions.Default(c)
session.Set("id", user.Id)
session.Set("username", user.Username)
session.Set("role", user.Role)
session.Set("status", user.Status)
err := session.Save()
如果你比较好奇究竟是哪里返回的 Set-Cookie
Header,答案是在 session.Save 的实现里:
func (s *CookieStore) Save(r *http.Request, w http.ResponseWriter,
session *Session) error {
encoded, err := securecookie.EncodeMulti(session.Name(), session.Values,
s.Codecs...)
if err != nil {
return err
}
http.SetCookie(w, NewCookie(session.Name(), encoded, session.Options))
return nil
}
至此实际上用户的浏览器已经被种上了我们的应用的登录态。
第四步:前端获知登录成功的响应,跳转页面
实际上还是之前的函数处理的,这里再复制一次,以便读者查阅:
const sendCode = async (code, state, count) => {
const res = await API.get(`/api/oauth/lark?code=${code}&state=${state}`);
const { success, message, data } = res.data;
if (success) {
if (message === 'bind') {
showSuccess('绑定成功!');
navigate('/setting');
} else {
userDispatch({ type: 'login', payload: data });
localStorage.setItem('user', JSON.stringify(data));
showSuccess('登录成功!');
navigate('/');
}
} else {
showError(message);
if (count === 0) {
setPrompt(`操作失败,重定向至登录界面中...`);
navigate('/setting'); // in case this is failed to bind lark
return;
}
count++;
setPrompt(`出现错误,第 ${count} 次重试中...`);
await new Promise((resolve) => setTimeout(resolve, count * 2000));
await sendCode(code, state, count);
}
};
可以看到当检测到登录成功后便跳转回首页,实际上业界常规的做法是跳转回用户原本被登录流程打断的页面, 这里还没做,有兴趣的欢迎 PR。
Bonus:OAuth 流程中的 state 参数与 CSRF 的防护
如果你观察仔细,会发现我们在发起授权的时候还传入了一个 state 参数, 并在飞书授权端回调时其返回了该参数,我们在兑换 User Access Token 前检查了两个值是否一致。
具体来说,我们有一个专门的获取 state 的接口:
export async function getOAuthState() {
const res = await API.get('/api/oauth/state');
const { success, message, data } = res.data;
if (success) {
return data;
} else {
showError(message);
return '';
}
}
对应的后端的实现:
func GenerateOAuthCode(c *gin.Context) {
session := sessions.Default(c)
state := random.GetRandomString(12)
session.Set("oauth_state", state)
err := session.Save()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": state,
})
}
实际上就是生成了一个随机数,并设置到 Cookie 里以供后续校验,并返回给前端供发起授权时拼接到授权页上, 注意看 window.open 的参数中的 state query 参数:
export async function onLarkOAuthClicked(lark_client_id) {
const state = await getOAuthState();
if (!state) return;
let redirect_uri = `${window.location.origin}/oauth/lark`;
window.open(
`https://open.feishu.cn/open-apis/authen/v1/index?redirect_uri=${redirect_uri}&app_id=${lark_client_id}&state=${state}`
);
}
后续飞书授权页在重定向到我们的回调页面时,也传入了 state 参数:
useEffect(() => {
let code = searchParams.get('code');
let state = searchParams.get('state');
sendCode(code, state, 0).then();
}, []);
我们的前端页面在传递授权码 code 到后端时,也传入了 state 参数:
const res = await API.get(`/api/oauth/lark?code=${code}&state=${state}`);
随后后端代码从 Cookie 中取出之前保存的 state 值,并与前端返回的值进行比对:
session := sessions.Default(c)
state := c.Query("state")
if state == "" || session.Get("oauth_state") == nil || state != session.Get("oauth_state").(string) {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": "state is empty or not same",
})
return
}
如果不一致,则说明存在 CSRF 攻击的风险,此时直接终止登录流程。
为什么我们需要检查该 state 参数呢?设想以下场景:
- 攻击者发起授权,在授权页携带 code 重定向到我们的回调页面前,暂停流程。
- 然后把携带有 code 的链接(例如 https://openai.justsong.cn/oauth/lark?code=xxx)发送给受害者。
- 受害者在有登录态的浏览器中打开了该地址,此时取决于不同的系统,有可能会导致攻击者的飞书账号绑定到了受害者的系统账号上;
- 之后攻击者便可以通过自己的飞书账号登录该系统。
注意这里只是拿飞书举例,实际上任何 OAuth 服务都有可能受此攻击。 防御方法就是检查 state 参数,由于受害者的浏览器里面没有对应 state Cookie,因此 state 一致性校验不通过。
除此之外,state 除了起到 CSRF Token 防止 CSRF 攻击的作用外,也有用于恢复授权前状态的作用, 我猜测这也是 state 为什么叫 state 的原因之一。
Links: feishu-oauth-login