飞书鉴权

117 阅读6分钟

官方文档写的不是很好, 陆陆续续研究了一天。特意整理记录

一、环境

前端:vue3组合式API

后端:fastAPI

二、应用内鉴权获取用户信息

主要是通过配置网页应用的方式, 让应用可以在飞书客户端内进行授权登录,并获取到用户信息: 什么是网页应用?

1、前端修改index.html

这里主要是引入jssdk

<!DOCTYPE html>
<html lang="" >
  <head>
    <meta charset="UTF-8">
    <link rel="icon" href="src/assets/icons/logo_black.png">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>KURO QA</title>
    <!-- 引入 JSSDK -->
    <!-- JS 文件版本在升级功能时地址会变化,如有需要(比如使用新增的 API),请重新引用「网页应用开发指南」中的JSSDK链接,确保你当前使用的JSSDK版本是最新的。-->
    <script
      type="text/javascript"
      src="https://lf1-cdn-tos.bytegoofy.com/goofy/lark/op/h5-js-sdk-1.5.35.js"
    ></script>
    <!-- 在页面上添加VConsole方便调试-->
    <script src="https://unpkg.com/vconsole/dist/vconsole.min.js"></script>
    <script>
      var vConsole = new window.VConsole();
    </script>
  </head>
  <body style="margin: 0">
    <div id="app" ></div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

2、前端修改App.vue

注意: 这里的代码不能照搬:

1)httpAPI:是我自己对axios的封装,参考之前的文章

2)paths:是我自己定义的跟后端交互的地址集, 参考①文章也能看到大概的思路

然后就是具体的鉴权代码了, 这里进行了比较详细的注释

<template>
  <router-view></router-view>
</template>

<script setup>
  import { onMounted } from 'vue'
  import httpAPI from "@/https/index";
  import paths from "@/https/path";
  import { ref } from 'vue'

  // 网页应用登录
  onMounted(() => {
    const sessionKey = ref('');
    const url = encodeURIComponent(location.href.split("#")[0]);
    console.log(`接入方前端将需要鉴权的url发给接入方服务端,url为: ${paths.baseUrl}/auth/get_config_parameters?url=${url}`);
    // 向接入方服务端发起请求,获取鉴权参数(appId、timestamp、nonceStr、signature)
    httpAPI.getRequest("/auth/get_config_parameters?url=" + url).then(res => {
      console.log("接入方服务端返回给接入方前端的结果(前端调用config接口的所需参数):", res.data.data.appid);
      // 调用config接口进行鉴权
      window.h5sdk.config({
        appId: res.data.data.appid,
        timestamp: res.data.data.timestamp,
        nonceStr: res.data.data.noncestr,
        signature: res.data.data.signature,
        jsApiList: [],
        //鉴权成功回调
        onSuccess: (res) => {
          console.log(`config success: ${JSON.stringify(res)}`);
          sessionKey.value = res.session_key;
          console.log(`sessionKey = ${sessionKey.value}`);
        },
        //鉴权失败回调
        onFail: (err) => {
          throw `config failed: ${JSON.stringify(err)}`;
        },
      });
      // 完成鉴权后,便可在 window.h5sdk.ready 里调用 JSAPI
      window.h5sdk.ready(() => {
        // window.h5sdk.ready回调函数在环境准备就绪时触发
        // 调用 getUserInfo API 获取已登录用户的基本信息,详细文档参见:https://open.feishu.cn/document/uYjL24iN/ucjMx4yNyEjL3ITM
        tt.getUserInfo({
          withCredentials: true,
          // getUserInfo API 调用成功回调
          success(res) {
            console.log(`getUserInfo success: ${JSON.stringify(res)}`);

            // 进行敏感数据解密:
            const data = {
              iv: res.iv,
              encryptedData: res.encryptedData,
              sessionKey: sessionKey.value
            }
            httpAPI.postRequest("/auth/get_encrypted_data", data).then(res => {
              if (res.status === 200){
                ElMessage.closeAll()  // 有可能之前有错误
                console.log("解密结果:", res.data);
              }

            })
          },
          // getUserInfo API 调用失败回调
          fail(err) {
            console.log(`getUserInfo failed:`, JSON.stringify(err));
          },
        });
      });
    })
  })


</script>

<style scoped>

</style>

3、后端fastAPI路由核心代码:

1) get_config_parameters: 获取鉴权参数

import hashlib
import os
import time

from dotenv import load_dotenv, find_dotenv
from fastapi import APIRouter, Request, Cookie

# 引入数据校验模型
from server.pydanticDatas import RequestData, ResponseData

# 引入日志模块
from uvicorn.config import logger

# 引入自定义库
from server.utils import feishuAuthUtils

import base64
import json
from Crypto.Cipher import AES


"""获取鉴权参数"""
@router.get('/get_config_parameters')
def get_config_parameters(url: str):
    
    # 随机字符串,用于签名生成加密使用
    NONCE_STR = "13oEviLbrTo458A3NjrOwS70oTOXVOAm"

    # 从 .env 文件加载环境变量参数
    load_dotenv(find_dotenv())
    
    # 获取环境变量
    APP_ID = os.getenv("APP_ID")
    APP_SECRET = os.getenv("APP_SECRET")
    FEISHU_HOST = os.getenv("FEISHU_HOST")

    # 用获取的环境变量初始化Auth类,由APP ID和APP SECRET获取access token,进而获取jsapi_ticket
    auth = feishuAuthUtils.Auth(FEISHU_HOST, APP_ID, APP_SECRET)

    # 初始化Auth类时获取的jsapi_ticket
    ticket = auth.get_ticket()

    # 当前时间戳,毫秒级
    timestamp = int(time.time()) * 1000

    # 拼接成字符串
    verify_str = f"jsapi_ticket={ticket}&noncestr={NONCE_STR}&timestamp={timestamp}&url={url}"

    # 对字符串做sha1加密,得到签名signature
    signature = hashlib.sha1(verify_str.encode("utf-8")).hexdigest()

    return ResponseData.response_success(data={"appid": APP_ID, "signature": signature, "noncestr": NONCE_STR, "timestamp": timestamp})

上述代码中Auth类代码: 这里除了日志模块是自定义的, 其他都是直接官网demo拿过来的

import requests
# 引入日志模块
from uvicorn.config import logger

# const
# 开放接口 URI
TENANT_ACCESS_TOKEN_URI = "/open-apis/auth/v3/tenant_access_token/internal"
JSAPI_TICKET_URI = "/open-apis/jssdk/ticket/get"


class Auth(object):
    def __init__(self, feishu_host, app_id, app_secret):
        self.feishu_host = feishu_host
        self.app_id = app_id
        self.app_secret = app_secret
        self.tenant_access_token = ""

    def get_ticket(self):
        # 获取jsapi_ticket,具体参考文档:https://open.feishu.cn/document/ukTMukTMukTM/uYTM5UjL2ETO14iNxkTN/h5_js_sdk/authorization
        self.authorize_tenant_access_token()
        url = "{}{}".format(self.feishu_host, JSAPI_TICKET_URI)
        headers = {
            "Authorization": "Bearer " + self.tenant_access_token,
            "Content-Type": "application/json",
        }
        resp = requests.post(url=url, headers=headers)
        Auth._check_error_response(resp)
        return resp.json().get("data").get("ticket", "")

    def authorize_tenant_access_token(self):
        # 获取tenant_access_token,基于开放平台能力实现,具体参考文档:https://open.feishu.cn/document/ukTMukTMukTM/ukDNz4SO0MjL5QzM/auth-v3/auth/tenant_access_token_internal
        url = "{}{}".format(self.feishu_host, TENANT_ACCESS_TOKEN_URI)
        req_body = {"app_id": self.app_id, "app_secret": self.app_secret}
        response = requests.post(url, req_body)
        Auth._check_error_response(response)
        self.tenant_access_token = response.json().get("tenant_access_token")

    @staticmethod
    def _check_error_response(resp):
        # 检查响应体是否包含错误信息
        if resp.status_code != 200:
            raise resp.raise_for_status()
        response_dict = resp.json()
        code = response_dict.get("code", -1)
        if code != 0:
            logger.error(response_dict)
            raise FeishuException(code=code, msg=response_dict.get("msg"))


class FeishuException(Exception):
    # 处理并展示飞书侧返回的错误码和错误信息
    def __init__(self, code=0, msg=None):
        self.code = code
        self.msg = msg

    def __str__(self) -> str:
        return "{}:{}".format(self.code, self.msg)

    __repr__ = __str__

2)get_encrypted_data: 敏感数据解密

import hashlib
import os
import time

from dotenv import load_dotenv, find_dotenv
from fastapi import APIRouter, Request, Cookie

# 引入数据校验模型
from server.pydanticDatas import RequestData, ResponseData

# 引入日志模块
from uvicorn.config import logger

# 引入自定义库
from server.utils import feishuAuthUtils

import base64
import json
from Crypto.Cipher import AES


@router.post('/get_encrypted_data', summary='敏感数据解密')
def get_encrypted_data(data: RequestData.request_encrypted_data):
    """敏感数据解密"""
    session_key = bytes.fromhex(data.sessionKey)
    iv = bytes.fromhex(data.iv)
    message = base64.b64decode(data.encryptedData)
    decipher = AES.new(session_key, AES.MODE_CBC, iv)
    decodedMessage = decipher.decrypt(message)
    unpad = lambda s: s[0:-s[-1]]
    decodedMessage = unpad(decodedMessage)
    data = json.loads(decodedMessage)
    logger.info(f'解密后的数据: {data}')
    return data

4、飞书自己创建的应用添加【网页应用】的能力并将主页配置进去

image.png

三、网页应用鉴权获取用户信息

应用内鉴权流程走一遍之后,这里就好做多了。这里是官网指导: 前往官方

1、创建一个login.vue

这里主要实现了构建授权链接的功能,点击按钮后,向授权链接发送get请求,然后需要一个界面接受请求回调拿到授权码

<!--应用外免密登录-->

<template>
  <dev class="container">
    <el-card style="width: 500px" body-style="background-color:#FFFFFF;text-align:center;position:relative">
      <img :src="logo_text" width="250" style="margin-bottom: 5px"/>
      <el-divider class="splitLine">测试管理平台</el-divider>
      <el-tabs type="border-card" stretch style="margin-top: 25px">
        <el-button @click="loginHandle" :loading="loadingState" type="primary" size="large" style="width: 100%">飞书授权登录</el-button>
      </el-tabs>
    </el-card>
  </dev>
</template>

<script setup>
  import{ ref } from 'vue';
  import logo_text from "../assets/icons/logo_text.png";
  import {useRouter} from "vue-router";
  import httpAPI from "../https/index";
  import paths from "../https/path"

  const router = useRouter();

  const loginHandle = () => {
    // 构建飞书授权链接所需参数
    const appid = import.meta.env.VITE_APP_ID  // 应用appid
    // const redirectUri = encodeURIComponent(location.href.split("#")[0]+"Callback");  // 前端页面重定向的url
    const redirectUri = encodeURIComponent(import.meta.env.VITE_REDIRECT_URL)
    const state = Date.now().toString()  // # 取当前时间戳用来维护请求和回调状态的附加字符串,在授权完成回调时会附加此参数,应用可以根据此字符串来判断上下文关系
    const scope = 'contact:user.employee_id:readonly' // 为由空格分隔的需要用户授权的三方应用权限,权限点位需要在步骤一中已申请开通,注意一样需要进行 URL 编码(空格会被自动替换为 %20)

    // 构建飞书授权链接并进行页面跳转: https://open.feishu.cn/document/common-capabilities/sso/api/obtain-oauth-code
    const authUrl = `https://accounts.feishu.cn/open-apis/authen/v1/authorize?client_id=${appid}&redirect_uri=${redirectUri}&state=${state}&scope=${scope}`
    console.error(authUrl)
    location.href = authUrl
  }

</script>

<style>
.container {
  display: flex;
  justify-content: center; /* 水平居中 */
  align-items: center;     /* 垂直居中 */
  height: 90vh;           /* 容器高度充满视口 */
}

</style>

2、创建一个loginCallback.vue

这里接受回调,然后将授权码发给自己的服务器后端

<template>

</template>

<script setup>
  import { onMounted } from 'vue'
  import { useRouter } from 'vue-router';
  import httpAPI from "@/https/index";
  import paths from "@/https/path";
  const router = useRouter();
  onMounted(() => {
    // 让后端通过授权码获取 user_access_token并进行登录: https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/authentication-management/access-token/get-user-access-token
    const data = {
      grantType: 'authorization_code',   // 授权类型:这里是固定值
      code: router.currentRoute.value.query.code,   // 回调页面获取到的授权码
      redirectUri: import.meta.env.VITE_REDIRECT_URL   // 回调页面地址
    }
    httpAPI.postRequest(paths.loginOutsideUrl, data).then(res => {
      if (res.status === 200){
        // 登录成功后跳转到项目列表页面

        router.push({name: 'login'})
      }
    })
  });
</script>

<style scoped>

</style>

3、将logginCallback.vue的地址作为回调配置到应用中

image.png

4、fastAPI路由

userAccessToken就是用户的授权码,然后通过官网接口即可拿到用户信息

@router.post('/loginOutside', summary='外部登录')
def login_outside(authData: RequestData.request_login_outside_data, request: Request):
    userIp = request.client.host
    # 获取 user_access_token: https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/authentication-management/access-token/get-user-access-token#f47475f8
    # 从 .env 文件加载环境变量参数
    load_dotenv(find_dotenv())
    data = {
        "grant_type": "authorization_code",
        "client_id": os.getenv("APP_ID"),
        "client_secret": os.getenv("APP_SECRET"),
        "code": authData.code,
        "redirect_uri": authData.redirectUri
    }
    logger.info(data)
    response = requests.post(url=r"https://open.feishu.cn/open-apis/authen/v2/oauth/token", json=data, headers={"Content-Type": "application/json; charset=utf-8"})
    if response.status_code != 200:
        return ResponseData.response_error(code=response.status_code, msg="用户授权登录失败")
    else:
        if response.json()['code'] != 0:
            logger.error(f'获取 user_access_token 出错: code: {response.json()["code"]}, error: {response.json()["error"]}, error_description: {response.json()["error_description"]}')
            return ResponseData.response_error(code=response.json()['code'], msg=f'{response.json()["error"]}: {response.json()["error_description"]}')
        else:
            # 通过 user_access_token 获取用户信息
            userAccessToken = response.json()['access_token']
            logger.info(f"获取 user_access_token 成功: userAccessToken = {userAccessToken}")


    return ResponseData.response_success()