跳到主要内容

H5网页接入飞书:免登与OAuth登录

· 阅读需 6 分钟

最近做的 H5 页面需要在飞书里直接打开就能免登录访问,在浏览器里打开则走飞书 OAuth 授权。整体是前端 + Java 后端的组合,简单记录下。

整体思路

登录分两种场景,最后都统一走后端同一个接口换 token:

  1. 飞书内打开:通过飞书 JS-SDK 的 requestAccess 免登接口,静默拿到 code,用户无感知。
  2. 浏览器打开:跳飞书 OAuth 授权页,用户点同意后回调页拿到 code。

拿到 code 后统一调后端 feishuLogin:后端用 code 换 user_access_token,再拉取用户信息,校验通过就给前端发业务 token。

前置准备

飞书开放平台创建企业自建应用:

  • 拿到 App ID / App Secret
  • 开通需要的权限 scope,比如 contact:user.base:readonlycontact:contact.base:readonlycontact:user.employee:readonly
  • 配置重定向 URI(OAuth 回调地址)

前端

判断是否在飞书内

用 UA 判断是不是在飞书里打开的:

export function isInFeishu() {
const ua = navigator.userAgent.toLowerCase()
return ua.includes('lark') || ua.includes('feishu')
}

飞书内:requestAccess 免登

JS-SDK 从 CDN 动态加载,加载一次缓存到 window.h5sdk

function loadFeishuSDK() {
return new Promise((resolve, reject) => {
if (window.h5sdk) {
resolve(window.h5sdk)
return
}
const script = document.createElement('script')
script.src = 'https://lf3-cdn-tos.bytegoofy.com/obj/goofy/locl/lark/external_js_sdk/h5-js-sdk-1.2.21.js'
script.onload = () => {
if (window.h5sdk) {
resolve(window.h5sdk)
} else {
reject(new Error('飞书 SDK 加载失败'))
}
}
script.onerror = () => reject(new Error('飞书 SDK 加载失败'))
document.head.appendChild(script)
})
}

SDK ready 之后调 requestAccess,静默拿到 code。接口说明见飞书文档

async function getCodeInFeishu() {
await loadFeishuSDK()
return new Promise((resolve, reject) => {
window.h5sdk.ready(() => {
window.h5sdk.requestAccess({}, result => {
console.log('[飞书免登] requestAccess 结果:', result)
if (result.code === 0 && result.data && result.data.code) {
resolve(result.data.code)
} else {
reject(new Error(result.message || '飞书免登获取 code 失败'))
}
})
})
})
}

浏览器内:OAuth 授权

不在飞书里就跳 OAuth 授权页。先拼好授权 URL 跳过去:

const oauthUrl =
'https://accounts.feishu.cn/open-apis/authen/v1/authorize' +
`?client_id=${FEISHU_APP_ID}` +
'&response_type=code' +
`&redirect_uri=${redirectUri}` +
`&scope=${encodeURIComponent(FEISHU_SCOPE)}` +
`&state=${state}`

console.log('[飞书免登] 跳转 OAuth 授权:', oauthUrl)
window.location.replace(oauthUrl)

用户同意后飞书会带 codestate 跳回回调页。回调页处理 code,清理 URL 上的 code 要趁早,在换 token 之前,防止页面重载时重复使用。文档见获取 OAuth code

async function handleFeishuCallback() {
// 先从 URL 上取 code,清理要趁早(before loginWithCode)
const urlParams = new URLSearchParams(window.location.search)
const code = urlParams.get('code')
const state = urlParams.get('state')

// 无论有没有 code,先把 URL 清理干净,防止页面重载时再次使用
cleanUrlParams()

if (!code) {
status.value = 'error'
errorMsg.value = 'URL 上缺少 code 参数,请重新授权'
return
}

try {
status.value = 'loading'
errorMsg.value = ''

const token = await loginWithCode(code, window.location.origin + '/callback')
if (token) {
// 登录成功,跳回原始页面
if (state) {
window.location.replace(decodeURIComponent(state))
} else {
router.replace('/')
}
}
} catch (err) {
console.error('[Callback] 登录失败:', err)
status.value = 'error'
errorMsg.value = err.message || '未知错误'
}
}

换 token

两种流程拿到 code 后都调 loginWithCode,走后端接口换业务 token:

export async function loginWithCode(code, redirectUri) {
const url = redirectUri || ''
console.log('[飞书免登] 用 code 换取 token:', code, 'redirectUri:', url)
const res = await feishuLogin(code, url)
console.log('[飞书免登] 后端登录接口返回数据为:', JSON.stringify(res.data?.data))
const token = res.data?.data?.token || res.token
const userInfo = res.data?.data?.staffInfo || res.userInfo
if (token) {
setToken(token)
sessionStorage.setItem('userInfo', JSON.stringify(userInfo))
console.log('[飞书免登] 登录成功,token 已存储')
return token
}
throw new Error('后端未返回 token')
}

后端

后端是 Spring Boot,用 RestTemplate 调飞书接口。

用 code 换 user_access_token

public FeishuOauthTokenRes feishuOauthToken(String code, String redirectUri) throws BizException {
if (redirectUri == null) {
redirectUri = "";
}
String url = "https://accounts.feishu.cn/oauth/v3/token";

HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);

Map<String, Object> params = new HashMap<>();
params.put("grant_type", "authorization_code");
params.put("client_id", appId);
params.put("client_secret", appSecret);
params.put("code", code);
params.put("redirect_uri", redirectUri);
params.put("code_verifier", "");
params.put("scope", "contact:user.base:readonly contact:contact.base:readonly contact:user.employee:readonly");

HttpEntity<Map<String, Object>> requestEntity = new HttpEntity<>(params, headers);

FeishuOauthTokenRes res;
try {
res = restTemplate.postForObject(url, requestEntity, FeishuOauthTokenRes.class);
} catch (Exception e) {
log.error("调用飞书授权token接口失败, code={}", code, e);
throw new BizException("获取飞书token失败");
}

if (res == null || res.getCode() == null || res.getCode() != 0) {
log.error("获取飞书token失败, response={}", res);
throw new BizException("获取飞书token失败");
}
return res;
}

拉取用户信息

private FeishuUserInfoData feishuUserInfo(String accessToken) {
String url = "https://open.feishu.cn/open-apis/authen/v1/user_info";

HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(accessToken);

HttpEntity<Void> requestEntity = new HttpEntity<>(headers);

FeishuApiRet<FeishuUserInfoData> res;
try {
ResponseEntity<FeishuApiRet<FeishuUserInfoData>> response = restTemplate.exchange(url, HttpMethod.GET, requestEntity, new ParameterizedTypeReference<>() {
});
res = response.getBody();
} catch (Exception e) {
log.error("调用飞书用户信息接口失败, accessToken={}", accessToken, e);
return null;
}

if (res == null || res.getCode() == null || res.getCode() != 0 || res.getData() == null) {
log.error("获取飞书用户信息失败, response={}", res);
return null;
}
return res.getData();
}

登录主流程

飞书认证成功后,给前端发放业务 token:

public FeishuLoginRes login(String code, String redirectUri) throws BizException {
FeishuLoginRes res = new FeishuLoginRes();

var fsToken = feishuOauthToken(code, redirectUri);
if (fsToken == null || StringUtils.isBlank(fsToken.getAccessToken())) {
throw new BizException("获取飞书token失败");
}

var fsUserInfo = feishuUserInfo(fsToken.getAccessToken());
if (fsUserInfo == null || StringUtils.isBlank(fsUserInfo.getUserId())) {
throw new BizException("获取飞书用户信息失败");
}

// ... 飞书认证成功,发放 token
res.setToken(token);
return res;
}

踩坑记录

OAuth 流程必须传 redirectUri。 免登的 requestAccess 不需要 redirectUri,但 OAuth 换 token 时必须带上,否则飞书直接报错:

400 Bad Request: "{"error":"invalid_request","error_description":"The request is missing a required parameter: redirect_uri.","code":20063}"

所以后端参数上免登场景可以传空,OAuth 场景一定要把回调地址传过去。

其他注意点:

  • URL 上的 code 要及时清理,在换 token 之前就清,防止页面刷新时拿着旧的 code 再走一次流程。
  • code 是一次性的,换过一次 token 后不能再用。