如何实现飞书授权登录

标签: OAuth 发布于:2024-12-20 23:32:40 编辑于:2024-12-21 14:17:40 浏览量:107

前言

最近为 One API 更新了飞书授权登录功能,在此做一些简单的摘要。

飞书本身的授权文档本身还算是比较完善的,但是飞书开放平台的 SEO 做的很差(2024年12月20日论断), 直接 Google 搜索第一页基本上找不到官方文档,另外询问各种 AI 给出的答复也都比较陈旧, 比较意外的是飞书开放平台内置的智能助手还蛮好用: 飞书智能助手-如何实现飞书授权登录

可以点击该链接体验一下。

我这里简单列出一些重点的官方文档链接,并给出 One API 中的实践,供各位接入飞书授权登录时参考。

标准的 Web 端应用授权登录流程

主要涉及三个 API:

  1. 获取授权码:https://open.feishu.cn/document/common-capabilities/sso/api/obtain-oauth-code
  2. 获取 User Access Token:https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/authentication-management/access-token/get-user-access-token
  3. 使用获取到的 User Access Token 调用获取用户信息接口:https://open.feishu.cn/document/server-docs/authentication-management/login-state-management/get?appId=cli_a4d2fa6f79f8500e

之后我们便可以通过响应中的 open_idunion_iduser_id 等字段来唯一标识用户, 随后便可以基于这些 ID 信息签发 Session,构建应用的登录态。

实际上这篇飞书的官方教程各个流程讲的比较详细,借鉴里面一张图: Web 端应用授权登录飞书流程总览

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 参数呢?设想以下场景:

  1. 攻击者发起授权,在授权页携带 code 重定向到我们的回调页面前,暂停流程。
  2. 然后把携带有 code 的链接(例如 https://openai.justsong.cn/oauth/lark?code=xxx)发送给受害者。
  3. 受害者在有登录态的浏览器中打开了该地址,此时取决于不同的系统,有可能会导致攻击者的飞书账号绑定到了受害者的系统账号上;
  4. 之后攻击者便可以通过自己的飞书账号登录该系统。

注意这里只是拿飞书举例,实际上任何 OAuth 服务都有可能受此攻击。 防御方法就是检查 state 参数,由于受害者的浏览器里面没有对应 state Cookie,因此 state 一致性校验不通过。

除此之外,state 除了起到 CSRF Token 防止 CSRF 攻击的作用外,也有用于恢复授权前状态的作用, 我猜测这也是 state 为什么叫 state 的原因之一。

未经允许,禁止转载,本文源站链接:https://iamazing.cn/