完整的飞书登录解决方案,支持客户端免登录、二维码扫码登录和账号密码登录
本项目实现了完整的飞书登录功能,支持三种登录方式:
所有登录方式都与 Parse 用户系统无缝集成,提供统一的用户认证体验。
src/app/
├── core/
│ ├── services/
│ │ ├── feishu-auth.service.ts # 飞书认证服务
│ │ └── auth.service.ts # 应用认证服务
│ └── guards/
│ └── auth.guard.ts # 路由守卫
└── modules/
└── login/
├── login.component.ts # 登录组件
├── login.component.html # 登录模板
└── login.component.scss # 登录样式
用户在飞书 App 内打开应用
↓
检测到飞书环境
↓
调用飞书 JSSDK 获取授权码
↓
发送授权码到后端 API
↓
后端返回用户信息和 sessionToken
↓
使用 sessionToken 登录 Parse
↓
更新 AuthService 认证状态
↓
跳转到首页
用户在浏览器中打开应用
↓
显示飞书二维码
↓
用户使用飞书扫码
↓
二维码 SDK 返回 tmp_code
↓
重定向到飞书授权页面(携带 tmp_code)
↓
飞书服务器处理后重定向回应用(携带 code)
↓
发送 code 到后端 API
↓
后端返回用户信息和 sessionToken
↓
使用 sessionToken 登录 Parse
↓
更新 AuthService 认证状态
↓
跳转到首页
用户输入用户名和密码
↓
调用 Parse.User.logIn()
↓
登录成功,获取 Parse User
↓
更新 AuthService 认证状态
↓
跳转到首页
feishu-auth.service.ts 依赖三个核心 SDK 来实现完整的登录功能:
https://lf1-cdn-tos.bytegoofy.com/goofy/lark/op/h5-js-sdk-1.5.44.jswindow.ttprivate loadFeishuSDK(): Promise<void> {
return new Promise((resolve, reject) => {
// 检查飞书客户端 JSSDK 是否已加载
if (window['tt']) {
resolve();
return;
}
const script = document.createElement('script');
script.src = 'https://lf1-cdn-tos.bytegoofy.com/goofy/lark/op/h5-js-sdk-1.5.44.js';
script.onload = () => resolve();
script.onerror = () => reject(new Error('Failed to load Feishu SDK'));
document.head.appendChild(script);
});
}
获取飞书客户端内的用户授权码。
参数:
{
appID: string; // 飞书应用 ID
scopeList: string[]; // 权限范围列表(可为空)
success: (res: any) => void; // 成功回调
fail: (error: any) => void; // 失败回调
}
使用示例:
window.tt.requestAccess({
appID: 'cli_a9253658eef99cd2',
scopeList: [],
success: (res) => {
const code = res.code; // 获取授权码
console.log('授权码:', code);
},
fail: (error) => {
console.error('获取授权码失败:', error);
}
});
返回值:
{
code: string; // 授权码,用于后端换取用户信息
}
isInFeishuApp(): boolean {
// 检查 window.tt 是否存在
if (typeof window['tt'] !== 'undefined' && window['tt'].requestAccess) {
return true;
}
// 检查 User Agent
const ua = navigator.userAgent.toLowerCase();
return ua.includes('lark') || ua.includes('feishu');
}
https://lf-package-cn.feishucdn.com/obj/feishu-static/lark/passport/qrcode/LarkSSOSDKWebQRCode-1.0.3.jswindow.QRLoginprivate loadQRCodeSDK(): Promise<void> {
return new Promise((resolve, reject) => {
// 检查二维码 SDK 是否已加载
if (window['QRLogin']) {
resolve();
return;
}
const script = document.createElement('script');
script.src = 'https://lf-package-cn.feishucdn.com/obj/feishu-static/lark/passport/qrcode/LarkSSOSDKWebQRCode-1.0.3.js';
script.onload = () => resolve();
script.onerror = () => reject(new Error('Failed to load Feishu QRCode SDK'));
document.head.appendChild(script);
});
}
初始化并显示飞书登录二维码。
参数:
{
id: string; // 二维码容器的 DOM ID
goto: string; // 授权跳转 URL
width: string; // 二维码宽度(如 "300")
height: string; // 二维码高度(如 "300")
}
使用示例:
// 构造授权 URL
const goto = `https://passport.feishu.cn/suite/passport/oauth/authorize?client_id=${appId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&state=success_login`;
// 初始化二维码
const QRLoginObj = window.QRLogin({
id: 'qrcode-container',
goto: goto,
width: "300",
height: "300"
});
返回值:
{
matchOrigin: (origin: string) => boolean; // 验证消息来源
matchData: (data: any) => boolean; // 验证消息数据
}
二维码 SDK 通过 postMessage 发送扫码结果:
window.addEventListener('message', (event: MessageEvent) => {
// 验证消息来源和数据
if (QRLoginObj.matchOrigin(event.origin) &&
QRLoginObj.matchData(event.data)) {
const tmp_code = event.data.tmp_code; // 临时码
// 使用 tmp_code 构造重定向 URL
const redirectUrl = `${goto}&tmp_code=${tmp_code}`;
window.location.href = redirectUrl;
}
}, false);
重要说明:
tmp_code 不是最终的授权码,只是临时码tmp_code 重定向到飞书服务器codehttps://passport.feishu.cn/suite/passport/oauth/authorize
?client_id=<APP_ID>
&redirect_uri=<REDIRECT_URI>
&response_type=code
&state=<STATE>
参数说明:
client_id: 飞书应用 IDredirect_uri: 授权回调地址(需 URL 编码)response_type: 固定为 codestate: 状态参数,用于区分登录类型(如 success_login 表示二维码登录)import { FmodeParse as Parse } from 'fmode-ng/core/parse'import { FmodeParse as Parse } from 'fmode-ng/core/parse';
// 动态获取 Parse 实例
const Parse = FmodeParse.with(PARSE_APP_ID);
使用 sessionToken 恢复用户会话。
参数:
sessionToken: string // Parse 会话令牌
使用示例:
async loginWithSessionToken(sessionToken: string): Promise<any> {
try {
// 使用 sessionToken 成为当前用户
const user = await Parse.User.become(sessionToken);
console.log('Parse 用户登录成功:', user.id);
return {
parseUser: user,
sessionToken: sessionToken,
userId: user.id,
username: user.get('username')
};
} catch (error) {
console.error('Parse 用户登录失败:', error);
throw error;
}
}
使用用户名和密码登录。
参数:
username: string // 用户名
password: string // 密码
使用示例:
async webLogin(username: string, password: string): Promise<AuthResult> {
try {
// 使用 Parse.User.logIn() 进行登录
const user = await Parse.User.logIn(username, password);
// 获取用户 sessionToken
const sessionToken = user.getSessionToken();
return {
success: true,
userInfo: {
userId: user.id,
username: user.get('username'),
sessionToken: sessionToken
},
loginType: 'web'
};
} catch (error) {
return {
success: false,
error: error.message,
loginType: 'web'
};
}
}
退出登录,清除当前用户会话。
使用示例:
async logout(): Promise<void> {
// 使用 Parse SDK 登出
await Parse.User.logOut();
// 清除本地存储的用户信息
localStorage.removeItem('feishu_user_info');
localStorage.removeItem('feishu_session_token');
localStorage.removeItem('feishu_login_time');
}
获取当前登录的用户。
使用示例:
async getCurrentParseUser(): Promise<any> {
try {
return Parse.User.current();
} catch (error) {
console.warn('获取当前 Parse 用户失败:', error);
return null;
}
}
为了在 TypeScript 中使用这些 SDK,需要添加全局类型声明:
// 飞书 SDK 全局类型声明
declare global {
interface Window {
tt?: any; // 飞书客户端 JSSDK
QRLogin?: any; // 飞书二维码登录 SDK
}
}
FeishuAuth 构造函数会自动加载飞书客户端 JSSDK:
constructor(config: FeishuAuthConfig) {
this.config = {
appId: config.appId,
redirectUri: config.redirectUri,
apiOauthWeb: config.apiOauthWeb || 'http://localhost:3000/api/feishu-v2/oauth2/login'
};
// 自动加载飞书 SDK
this.loadFeishuSDK();
}
二维码 SDK 只在需要时加载:
initQRCodeLogin(containerId: string, onSuccess, onError): void {
// 确保 SDK 已加载
this.loadQRCodeSDK().then(() => {
// 初始化二维码
const QRLoginObj = window.QRLogin({...});
});
}
try {
await this.loadFeishuSDK();
} catch (error) {
console.error('加载飞书 SDK 失败:', error);
// 降级处理:使用网页登录
}
try {
window.tt.requestAccess({
appID: this.config.appId,
scopeList: [],
success: (res) => {
// 处理成功
},
fail: (error) => {
console.error('获取授权码失败:', error);
// 错误处理
}
});
} catch (error) {
console.error('飞书 JSSDK 调用失败:', error);
// 降级处理
}
确保项目已安装以下依赖:
npm install fmode-ng
在飞书开发者后台配置:
应用凭证:
安全设置:
https://your-domain.com/loginyour-domain.com权限配置:
auth:user.id:readauth:user:email:read(如需邮箱)将以下文件复制到你的项目:
src/app/core/services/feishu-auth.service.ts
src/app/modules/login/login.component.ts
src/app/modules/login/login.component.html
src/app/modules/login/login.component.scss
在 login.component.ts 中配置飞书应用信息:
this.feishuAuth = new FeishuAuth({
appId: 'YOUR_FEISHU_APP_ID',
redirectUri: window.location.origin + '/login',
apiOauthWeb: 'https://your-backend.com/api/feishu-v2/oauth2/login'
});
在 app.routes.ts 中添加登录路由:
export const routes: Routes = [
{
path: 'login',
component: LoginComponent
},
{
path: 'homepage',
component: HomepageComponent,
canActivate: [authGuard]
}
];
export interface FeishuAuthConfig {
appId: string; // 飞书应用 App ID(必填)
redirectUri: string; // 回调地址(必填)
apiOauthWeb?: string; // OAuth2 登录接口(可选)
}
如果不指定 apiOauthWeb,将使用默认值:
apiOauthWeb: 'http://localhost:3000/api/feishu-v2/oauth2/login'
后端需要提供以下接口:
请求参数:
{
"appId": "cli_a9253658eef99cd2",
"code": "授权码",
"redirect_uri": "https://your-domain.com/login"
}
响应格式:
{
"code": 1,
"data": {
"userInfo": {
"objectId": "用户ID",
"username": "用户名",
"nickname": "昵称",
"avatar": "头像URL",
"email": "邮箱",
"mobile": "手机号",
"openId": "飞书 OpenID",
"unionId": "飞书 UnionID"
},
"sessionToken": "Parse Session Token"
},
"mess": "登录成功"
}
import { Component, OnInit } from '@angular/core';
import { Router, ActivatedRoute } from '@angular/router';
import { AuthService } from '../../core/services/auth.service';
import { FeishuAuth, AuthResult } from '../../core/services/feishu-auth.service';
@Component({
selector: 'app-login',
templateUrl: './login.component.html',
styleUrls: ['./login.component.scss']
})
export class LoginComponent implements OnInit {
private feishuAuth: FeishuAuth;
constructor(
private authService: AuthService,
private router: Router,
private activatedRoute: ActivatedRoute
) {
// 初始化飞书认证服务
this.feishuAuth = new FeishuAuth({
appId: 'YOUR_FEISHU_APP_ID',
redirectUri: window.location.origin + '/login'
});
}
async ngOnInit(): Promise<void> {
// 检查是否有回调参数
const code = this.activatedRoute.snapshot.queryParamMap.get('code');
const state = this.activatedRoute.snapshot.queryParamMap.get('state');
if (code) {
await this.handleAuthCallback(code, state || '');
}
}
// 处理认证回调
private async handleAuthCallback(code: string, state: string): Promise<void> {
const result = await this.feishuAuth.handleAuthCallback(code, state);
if (result.success) {
await this.handleLoginSuccess(result);
}
}
// 处理登录成功
private async handleLoginSuccess(result: AuthResult): Promise<void> {
// 保存用户信息
if (result.userInfo) {
this.feishuAuth.saveUserInfo(result.userInfo);
}
// 刷新认证状态
await this.authService.refreshAuthState();
// 跳转到首页
await this.router.navigate(['/homepage']);
}
}
// 检查是否在飞书客户端内
if (this.feishuAuth.isInFeishuApp()) {
console.log('在飞书客户端内');
} else {
console.log('在浏览器中');
}
async handleFeishuLogin(): Promise<void> {
const result = await this.feishuAuth.getFeishuAuthCode();
if (result.success) {
await this.handleLoginSuccess(result);
} else {
console.error('登录失败:', result.error);
}
}
initQRCodeLogin(): void {
this.feishuAuth.initQRCodeLogin(
'qrcode-container',
async (result: AuthResult) => {
if (result.success) {
await this.handleLoginSuccess(result);
}
},
(error: string) => {
console.error('二维码登录失败:', error);
}
);
}
async handleWebLogin(username: string, password: string): Promise<void> {
const result = await this.feishuAuth.webLogin(username, password);
if (result.success) {
await this.handleLoginSuccess(result);
} else {
console.error('登录失败:', result.error);
}
}
constructor(config: FeishuAuthConfig)
检查是否在飞书客户端环境内。
返回值:
true:在飞书客户端内false:在浏览器中飞书客户端内免登录,获取授权码并完成登录。
返回值:
{
success: boolean;
userInfo?: UserInfo;
error?: string;
loginType: 'feishu' | 'web' | 'qrcode';
}
初始化二维码登录。
参数:
containerId: string - 二维码容器的 DOM IDonSuccess: (result: AuthResult) => void - 登录成功回调onError: (error: string) => void - 登录失败回调处理飞书授权回调。
参数:
code: string - 授权码state: string - 状态参数返回值:
{
success: boolean;
userInfo?: UserInfo;
error?: string;
loginType: 'feishu' | 'web' | 'qrcode';
}
账号密码登录。
参数:
username: string - 用户名password: string - 密码返回值:
{
success: boolean;
userInfo?: UserInfo;
error?: string;
loginType: 'web';
}
保存用户信息到 localStorage。
参数:
userInfo: any - 用户信息对象从 localStorage 获取用户信息。
返回值:
null检查用户是否已登录(基于 localStorage)。
返回值:
true:已登录且未过期(24小时内)false:未登录或已过期退出登录,清除所有本地存储和 Parse 会话。
验证飞书应用配置。
返回值:
{
isValid: boolean;
errors: string[];
}
interface AuthResult {
success: boolean;
code?: string;
userInfo?: {
userId: string;
username: string;
avatar: string;
nickname?: string;
email?: string;
mobile?: string;
sessionToken?: string;
openId?: string;
unionId?: string;
rawUserInfo?: any;
parseUser?: any;
};
error?: string;
loginType: 'feishu' | 'web' | 'qrcode';
}
原因: AuthService 的认证状态没有更新,路由守卫拒绝跳转。
解决方案: 在登录成功后调用 authService.refreshAuthState():
private async handleLoginSuccess(result: AuthResult): Promise<void> {
if (result.userInfo) {
this.feishuAuth.saveUserInfo(result.userInfo);
}
// 刷新认证状态
await this.authService.refreshAuthState();
await this.router.navigate(['/homepage']);
}
原因: 使用了旧版登录流程的 API 端点。
解决方案: 确保使用旧版 goto URL(当前实现已正确):
const goto = `https://passport.feishu.cn/suite/passport/oauth/authorize?client_id=${appId}&redirect_uri=${redirectUri}&response_type=code&state=success_login`;
解决方案: 通过 state 参数判断:
// state 为 'success_login' 表示二维码登录
const loginType = state === 'success_login' ? 'qrcode' : 'feishu';
解决方案: 在初始化时指定 apiOauthWeb:
this.feishuAuth = new FeishuAuth({
appId: 'YOUR_FEISHU_APP_ID',
redirectUri: window.location.origin + '/login',
apiOauthWeb: 'https://your-backend.com/api/feishu-v2/oauth2/login'
});
解决方案: FeishuAuth 内置了 24 小时过期检查:
if (!this.feishuAuth.isLoggedIn()) {
// 登录已过期,跳转到登录页
this.router.navigate(['/login']);
}
使用环境变量管理不同环境的配置:
// environment.ts
export const environment = {
production: false,
feishu: {
appId: 'dev_app_id',
apiOauthWeb: 'http://localhost:3000/api/feishu-v2/oauth2/login'
}
};
// environment.prod.ts
export const environment = {
production: true,
feishu: {
appId: 'prod_app_id',
apiOauthWeb: 'https://api.your-domain.com/api/feishu-v2/oauth2/login'
}
};
// 使用
this.feishuAuth = new FeishuAuth({
appId: environment.feishu.appId,
redirectUri: window.location.origin + '/login',
apiOauthWeb: environment.feishu.apiOauthWeb
});
统一处理登录错误:
private async handleLoginError(error: string): Promise<void> {
console.error('登录失败:', error);
// 显示错误提示
this.errorMessage.set(error);
// 记录错误日志
this.logError('login_failed', { error });
}
使用 signal 管理加载状态:
loading = signal(false);
async handleFeishuLogin(): Promise<void> {
this.loading.set(true);
try {
const result = await this.feishuAuth.getFeishuAuthCode();
// 处理结果
} finally {
this.loading.set(false);
}
}
确保所有需要登录的路由都配置了守卫:
export const routes: Routes = [
{
path: 'login',
component: LoginComponent
},
{
path: '',
canActivate: [authGuard],
children: [
{ path: 'homepage', component: HomepageComponent },
{ path: 'profile', component: ProfileComponent }
]
}
];
在应用启动时自动恢复会话:
// app.component.ts
async ngOnInit(): Promise<void> {
// AuthService 会自动从 Parse 恢复会话
await this.authService.refreshAuthState();
}
MIT
如有问题或建议,请联系开发团队。