# 飞书登录实现指南
本文档包含两种飞书登录方式:
1. **扫码登录** - 用户在飞书外打开应用,扫描二维码登录
2. **静默授权(免登)** - 用户在飞书客户端内打开应用,直接获取身份登录
---
## 一、前置条件
### 1.1 飞书开放平台配置
**必须配置**(关键!否则回调不会被调用):
1. 登录 [飞书开放平台](https://open.feishu.cn/)
2. 选择应用(如 `cli_a8033d607cba500d`)
3. 进入 **权限管理** → **安全设置** → **重定向 URL**
4. 添加回调地址:`https://your-domain.com/login/callback`
5. **保存并发布应用**
### 1.2 数据库表
确保存在以下表:
```sql
-- 飞书登录会话表
CREATE TABLE feishu_login_session (
id INT AUTO_INCREMENT PRIMARY KEY,
company_id INT NOT NULL,
tenant_key VARCHAR(255) NOT NULL,
status ENUM('pending', 'scanning', 'success', 'expired') DEFAULT 'pending',
user_id INT DEFAULT NULL,
expires_at DATETIME NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 公司飞书应用配置表
CREATE TABLE company_feishu_apps (
id INT AUTO_INCREMENT PRIMARY KEY,
company_id INT NOT NULL,
app_id VARCHAR(255) NOT NULL,
app_secret VARCHAR(255) NOT NULL,
callback_url VARCHAR(500),
mobile_callback_url VARCHAR(500),
status TINYINT DEFAULT 1
);
```
---
## 二、后端实现
### 2.1 路由配置
```php
// backend/config/route.php
// 飞书扫码登录相关路由
Route::post('/api/v1/auth/qrcode-login', [app\controller\AuthController::class, 'qrcodeLogin']);
Route::get('/api/v1/auth/qrcode-login/status', [app\controller\AuthController::class, 'qrcodeLoginStatus']);
Route::get('/login/callback', [app\controller\AuthController::class, 'qrcodeLoginCallback']);
```
### 2.2 控制器实现
```php
post('tenant_key');
if (!$tenantKey) {
return $this->error('缺少租户密钥');
}
try {
$feishuService = new \app\service\FeishuService();
// 根据 tenant_key 获取公司
$company = $feishuService->getCompanyByTenantKey($tenantKey);
if (!$company) {
return $this->error('公司不存在');
}
$feishuService->setCompany($company->id);
// 获取飞书应用配置
$feishuApp = \app\model\CompanyFeishuApp::where('company_id', $company->id)
->where('system_id', 1)
->where('status', 1)
->first();
if (!$feishuApp) {
return $this->error('未配置飞书应用');
}
// 生成登录会话(5分钟过期)
$loginSession = \app\model\FeishuLoginSession::create([
'company_id' => $company->id,
'tenant_key' => $tenantKey,
'status' => 'pending',
'expires_at' => date('Y-m-d H:i:s', time() + 300),
]);
// 构建扫码登录 URL
$redirectUri = urlencode("https://your-domain.com/login/callback");
$authUrl = "https://passport.feishu.cn/suite/passport/oauth/authorize?"
. "client_id={$feishuApp->app_id}&"
. "redirect_uri={$redirectUri}&"
. "response_type=code&"
. "state={$loginSession->id}";
return $this->success([
'session_id' => $loginSession->id,
'auth_url' => $authUrl,
'expires_at' => $loginSession->expires_at,
]);
} catch (\Exception $e) {
return $this->error($e->getMessage());
}
}
/**
* 轮询扫码登录状态
* GET /api/v1/auth/qrcode-login/status?session_id=xxx
*/
public function qrcodeLoginStatus(Request $request): Response
{
$sessionId = $request->get('session_id');
if (!$sessionId) {
return $this->error('缺少会话ID');
}
$session = \app\model\FeishuLoginSession::find($sessionId);
if (!$session) {
return $this->error('登录会话不存在');
}
// 检查是否过期
if (strtotime($session->expires_at) < time()) {
$session->status = 'expired';
$session->save();
return $this->error('二维码已过期,请刷新重试');
}
// 返回当前状态
if ($session->status === 'success') {
$user = \app\model\User::find($session->user_id);
if (!$user) {
return $this->error('用户不存在');
}
$token = \app\util\JwtUtil::encode([
'user_id' => $user->id,
'company_id' => $user->company_id,
'feishu_user_id' => $user->feishu_user_id,
'union_id' => $user->feishu_union_id,
]);
// 清除会话
$session->delete();
return $this->success([
'status' => 'success',
'token' => $token,
'user' => $user->load('company'),
]);
} else if ($session->status === 'expired') {
return $this->error('二维码已过期,请刷新重试');
} else {
// pending 或 scanning
return $this->success([
'status' => $session->status,
]);
}
}
/**
* 飞书扫码登录回调
* GET /login/callback?code=xxx&state=xxx
*/
public function qrcodeLoginCallback(Request $request): Response
{
$sessionId = $request->get('state');
$authCode = $request->get('code');
error_log("🔔 QRCode callback - session_id: {$sessionId}");
// 无参数时返回空页面(SDK 初始化时的预检请求)
if (!$sessionId && !$authCode) {
return new \Webman\Http\Response(200, ['Content-Type' => 'text/html; charset=utf-8'], '');
}
if (!$sessionId || !$authCode) {
return $this->error('参数错误');
}
try {
$session = \app\model\FeishuLoginSession::find($sessionId);
if (!$session) {
return $this->error('登录会话不存在');
}
// 检查是否过期
if (strtotime($session->expires_at) < time()) {
$session->status = 'expired';
$session->save();
return $this->error('二维码已过期');
}
// 获取用户信息
$feishuService = new \app\service\FeishuService();
$feishuService->setCompany($session->company_id);
$feishuData = $feishuService->getUserInfo($authCode);
// 创建或更新用户
$user = $feishuService->createOrUpdateUser($feishuData, $session->company_id);
// 更新会话状态
$session->status = 'success';
$session->user_id = $user->id;
$session->save();
// 生成 JWT Token
$token = \app\util\JwtUtil::encode([
'user_id' => $user->id,
'company_id' => $user->company_id,
'feishu_user_id' => $user->feishu_user_id,
'union_id' => $user->feishu_union_id,
]);
// 返回包含 token 的 HTML 页面
$userName = htmlspecialchars($user->name ?? '用户');
$html = $this->getSuccessHtml($token, $user->id, $userName);
return new \Webman\Http\Response(200, ['Content-Type' => 'text/html; charset=utf-8'], $html);
} catch (\Exception $e) {
error_log("❌ Callback error: " . $e->getMessage());
return $this->error($e->getMessage());
}
}
/**
* 生成登录成功 HTML 页面
*/
private function getSuccessHtml(string $token, int $userId, string $userName): string
{
return <<
登录成功
✅
登录成功!
欢迎回来,{$userName}
正在跳转到应用...
HTML;
}
}
```
---
## 三、飞书静默授权(免登)实现
飞书静默授权是指用户在飞书客户端内打开应用时,可以直接获取用户身份登录,无需扫码或输入密码。
### 3.1 后端接口
后端已有 `/api/v1/auth/feishu-login` 接口,用于处理飞书免登:
```php
// backend/app/controller/AuthController.php
/**
* 飞书免登
*/
public function feishuLogin(Request $request): Response
{
$authCode = $request->post('auth_code');
$tenantKey = $request->post('tenant_key');
if (!$authCode) {
return $this->error('缺少授权码');
}
try {
$feishuService = new FeishuService();
// 根据 tenant_key 获取公司
$company = $feishuService->getCompanyByTenantKey($tenantKey);
if (!$company) {
return $this->error('公司不存在');
}
// 设置公司并获取用户信息
$feishuService->setCompany($company->id);
$feishuData = $feishuService->getUserInfo($authCode);
// 创建或更新用户
$user = $feishuService->createOrUpdateUser($feishuData, $company->id);
// 生成 JWT Token
$token = JwtUtil::encode([
'user_id' => $user->id,
'company_id' => $user->company_id,
'feishu_user_id' => $user->feishu_user_id,
'union_id' => $user->union_id,
]);
return $this->success([
'token' => $token,
'user' => $user->load('company'),
]);
} catch (\Exception $e) {
return $this->error($e->getMessage());
}
}
```
### 3.2 前端实现
#### 3.2.1 引入飞书 JSAPI SDK
在 `index.html` 中添加(**注意**:需要同时引入 JSAPI SDK 和 QR SDK):
```html
```
#### 3.2.2 类型声明
```typescript
// src/types/feishu.d.ts
declare global {
interface Window {
// 飞书 JSAPI(免登)
tt?: {
ready: (callback: () => void) => void
requestAuthCode: (params: {
appId: string
success?: (res: { code: string }) => void
fail?: (err: any) => void
}) => void
checkJsApi?: (params: {
jsApiList: string[]
success: (res: any) => void
fail?: (err: any) => void
}) => void
}
// 飞书 QR SDK(扫码登录)
QRLogin?: (config: {
id: string
goto: string
width?: string
height?: string
style?: string
}) => {
matchOrigin: (origin: string) => boolean
matchData: (data: any) => boolean
}
}
}
export {}
```
#### 3.2.3 登录组件实现
```typescript
// src/pages/login/index.tsx
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Toast } from 'antd-mobile'
import { useUserStore } from '@/stores/user'
export default function Login() {
const navigate = useNavigate()
const { login } = useUserStore()
const [loading, setLoading] = useState(true)
const [environment, setEnvironment] = useState<'feishu' | 'external' | null>(null)
useEffect(() => {
// 检测是否在飞书客户端内
const ua = navigator.userAgent.toLowerCase()
const isInFeishu = ua.includes('feishu') || ua.includes('lark') || window.tt !== undefined
if (isInFeishu) {
// 飞书内:静默授权
setEnvironment('feishu')
handleFeishuAutoLogin()
} else {
// 飞书外:显示登录选项
setEnvironment('external')
setLoading(false)
}
}, [])
// 飞书内静默授权
const handleFeishuAutoLogin = () => {
const appId = 'cli_a8033d607cba500d'
const maxWaitTime = 5000 // 最多等待 5 秒
const checkInterval = 100 // 每 100ms 检查一次
let elapsedTime = 0
// 等待 tt API 加载完成
const waitForTT = () => {
return new Promise((resolve, reject) => {
const checkTT = () => {
elapsedTime += checkInterval
if (window.tt && typeof window.tt.requestAuthCode === 'function') {
resolve()
} else if (elapsedTime >= maxWaitTime) {
reject(new Error('tt API 加载超时'))
} else {
setTimeout(checkTT, checkInterval)
}
}
checkTT()
})
}
// 执行飞书免登
const doFeishuLogin = () => {
if (!window.tt) {
throw new Error('tt API 未加载')
}
// 调用 tt.ready 确保 SDK 准备就绪
window.tt.ready(() => {
// 调用 requestAuthCode 获取授权码
window.tt.requestAuthCode({
appId: appId,
success: (res) => {
// 用授权码登录后端
handleFeishuLogin(res.code)
},
fail: (error: any) => {
console.error('requestAuthCode failed:', error)
setLoading(false)
Toast.show('获取授权失败')
},
})
})
}
// 开始执行
waitForTT()
.then(() => doFeishuLogin())
.catch((error) => {
console.error('Feishu auto login failed:', error)
setLoading(false)
Toast.show('飞书免登失败')
})
}
// 处理飞书登录
const handleFeishuLogin = async (authCode: string) => {
try {
const response = await fetch('/api/v1/auth/feishu-login', {
method: 'POST',
headers: { 'Content-Type' => 'application/json' },
body: JSON.stringify({
auth_code: authCode,
tenant_key: 'your_tenant_key',
}),
})
const result = await response.json()
if (result.code === 0) {
login(result.data.user, result.data.token)
Toast.show('登录成功')
navigate('/dashboard')
} else {
Toast.show(result.message || '登录失败')
setLoading(false)
}
} catch (error) {
console.error('登录失败:', error)
Toast.show('登录失败,请重试')
setLoading(false)
}
}
if (loading) {
return 正在登录...
}
return (
企业工具台
{environment === 'external' && (
💡 提示:在飞书客户端内打开可自动登录
)}
{/* 显示其他登录方式... */}
)
}
```
### 3.3 飞书免登流程
```
┌────────────────────────────────────────────────────────────┐
│ 用户在飞书客户端内打开应用 │
└───────────────────────────┬────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────┐
│ 前端检测环境 (navigator.userAgent 或 window.tt) │
│ - 检测到飞书环境 → 执行免登流程 │
│ - 未检测到 → 显示登录选项(扫码登录等) │
└───────────────────────────┬────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────┐
│ 等待飞书 JSAPI SDK 加载完成 │
│ - 检测 window.tt.requestAuthCode 是否可用 │
│ - 超时时间:5 秒 │
└───────────────────────────┬────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────┐
│ 调用 tt.ready() 确保 SDK 准备就绪 │
└───────────────────────────┬────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────┐
│ 调用 tt.requestAuthCode({ appId, success, fail }) │
│ - appId: 飞书应用 ID │
│ - success: 回调函数,返回 { code: "xxx" } │
│ - fail: 错误回调 │
└───────────────────────────┬────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────┐
│ 获取 auth_code(临时授权码) │
│ - 有效期:5 分钟 │
│ - 只能使用一次 │
└───────────────────────────┬────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────┐
│ 前端调用后端接口 POST /api/v1/auth/feishu-login │
│ { auth_code: "xxx", tenant_key: "xxx" } │
└───────────────────────────┬────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────┐
│ 后端处理流程 │
│ 1. 用 auth_code 换取 user_access_token │
│ 2. 获取用户信息 │
│ 3. 创建/更新用户记录 │
│ 4. 生成 JWT token │
│ 5. 返回 { token, user } │
└───────────────────────────┬────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────┐
│ 前端保存 token 并跳转到首页 │
│ - localStorage.setItem('token', token) │
│ - navigate('/dashboard') │
└────────────────────────────────────────────────────────────┘
```
### 3.4 关键要点
| 要点 | 说明 |
|------|------|
| **SDK 引入** | 必须引入飞书 JSAPI SDK |
| **环境检测** | 通过 `navigator.userAgent` 或 `window.tt` 检测 |
| **tt.ready** | 调用前必须确保 SDK 准备就绪 |
| **auth_code** | 临时授权码,5 分钟有效期,只能用一次 |
| **错误处理** | 需要处理 SDK 加载超时、授权失败等情况 |
---
## 四、前端实现(扫码登录)
### 4.1 引入飞书 QR SDK
在 `index.html` 中添加(**注意**:需与 JSAPI SDK 一起引入):
```html
```
### 4.2 类型声明
```typescript
// src/types/feishu.d.ts
declare global {
interface Window {
QRLogin?: (config: {
id: string
goto: string
width?: string
height?: string
style?: string
}) => {
matchOrigin: (origin: string) => boolean
matchData: (data: any) => boolean
}
}
}
export {}
```
### 3.3 React 组件实现
```typescript
// src/pages/login/QRCodeLogin.tsx
import { useState, useEffect, useRef } from 'react'
import { Dialog, Toast } from 'antd-mobile'
import { useNavigate } from 'react-router-dom'
import styles from './QRCodeLogin.module.css'
interface QRCodeLoginProps {
visible: boolean
onClose: () => void
}
export default function QRCodeLogin({ visible, onClose }: QRCodeLoginProps) {
const navigate = useNavigate()
const [status, setStatus] = useState<'loading' | 'pending' | 'scanning' | 'success'>('loading')
const [qrcodeUrl, setQrcodeUrl] = useState('')
const qrContainerRef = useRef(null)
const qrObjRef = useRef(null)
useEffect(() => {
if (visible) {
initQRCode()
}
return () => {
// 清理
if (qrObjRef.current?._cleanup) {
qrObjRef.current._cleanup()
}
if (qrContainerRef.current) {
qrContainerRef.current.innerHTML = ''
}
qrObjRef.current = null
}
}, [visible])
const initQRCode = async () => {
setStatus('loading')
setQrcodeUrl('')
try {
// 等待 SDK 加载
await new Promise(resolve => setTimeout(resolve, 100))
// 生成登录会话
const response = await fetch('/api/v1/auth/qrcode-login', {
method: 'POST',
headers: { 'Content-Type' => 'application/json' },
body: JSON.stringify({
tenant_key: 'your_tenant_key', // 替换为实际的 tenant_key
}),
})
const result = await response.json()
if (result.code === 0) {
const authUrl = result.data.auth_url
if (window.QRLogin) {
setStatus('pending')
// 等待 DOM 渲染
const initQRSDK = (retryCount = 0) => {
const container = document.getElementById('feishu-qrcode-container')
if (container) {
container.innerHTML = ''
try {
// 初始化 QR SDK
qrObjRef.current = window.QRLogin({
id: 'feishu-qrcode-container',
goto: authUrl,
width: '250',
height: '250',
style: 'width:300px;height:270px;border:none'
})
setStatus('pending')
// ⭐ 关键:监听扫码后的 message 事件
const handleMessage = (event: MessageEvent) => {
// 安全校验
if (!qrObjRef.current?.matchOrigin(event.origin)) return
if (!qrObjRef.current?.matchData(event.data)) return
// 获取临时授权码
const loginTmpCode = event.data?.tmp_code
if (loginTmpCode) {
console.log('✅ Got tmp_code:', loginTmpCode)
// ⭐ 重定向到飞书授权页面(官方推荐方式)
const authUrlWithCode = `${authUrl}&tmp_code=${loginTmpCode}`
window.location.href = authUrlWithCode
}
}
window.addEventListener('message', handleMessage)
// 保存清理函数
qrObjRef.current._cleanup = () => {
window.removeEventListener('message', handleMessage)
}
} catch (e) {
console.error('❌ QR SDK init error:', e)
Toast.show('二维码生成失败')
}
} else if (retryCount < 10) {
setTimeout(() => initQRSDK(retryCount + 1), 100)
}
}
setTimeout(() => initQRSDK(), 100)
}
} else {
Toast.show(result.message || '生成二维码失败')
}
} catch (error) {
console.error('生成二维码失败:', error)
Toast.show('生成二维码失败')
}
}
const getStatusText = () => {
switch (status) {
case 'loading': return '正在生成二维码...'
case 'pending': return '请使用飞书扫码登录'
case 'scanning': return '已扫码,请在手机上确认'
case 'success': return '登录成功!'
default: return ''
}
}
return (