官方文档写的不是很好, 陆陆续续研究了一天。特意整理记录
一、环境
前端: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}×tamp={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、飞书自己创建的应用添加【网页应用】的能力并将主页配置进去
三、网页应用鉴权获取用户信息
应用内鉴权流程走一遍之后,这里就好做多了。这里是官网指导: 前往官方
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
的地址作为回调配置到应用中
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()