Skip to content
Carol's Blog
Go back

飞书 OAuth 一键登录接入实战

Edit page 6 分钟阅读 -- 阅读

一、背景

越来越多的企业将飞书作为内部办公协作平台,员工日常已在飞书端完成身份认证。如果后台管理系统采用独立账号体系(用户名 + 密码),员工需要额外记忆一套密码,体验割裂。

通过接入飞书 OAuth 2.0 授权(参考飞书 SSO 登录流程概览),可以实现:

价值说明
单点登录员工无需记忆独立密码,扫码或一键授权即可登录
体验一致与飞书生态无缝衔接,降低使用门槛
信息同步登录时实时校正姓名、头像等信息
安全可控飞书账号禁用后,系统登录自动失效

本文将以 OAuth 2.0 授权码模式为主线,从零到一拆解飞书登录的完整接入流程,包含前端实现、后端设计、安全防护和踩坑记录。


二、两种登录方式对比

系统登录页通常同时提供两种飞书登录方式,由用户根据当前场景自由选择:

登录方式适用场景交互方式
一键授权登录用户已在浏览器登录飞书网页版 / 桌面客户端页面跳转到飞书 OAuth 授权页,确认后自动回调
二维码扫码登录用户未在浏览器登录飞书页面内嵌飞书二维码,手机扫码后自动完成登录

技术要点:浏览器无法主动检测用户是否已登录飞书(跨域安全限制),因此登录页需同时提供两种入口。扫码登录需接入飞书 QR SDK


三、OAuth 2.0 授权码模式流程

飞书登录采用标准的 OAuth 2.0 授权码模式(Authorization Code Grant),通过飞书开放平台 OAuth 2.0 授权接口完成身份认证。

3.1 一键授权登录流程

sequenceDiagram
    autonumber
    actor User as 用户
    participant Frontend as 前端页面
    participant Backend as 后端服务
    participant Redis as Redis
    participant Feishu as 飞书 OAuth

    User->>Frontend: 点击「使用飞书登录」
    Frontend->>Backend: GET /api/login/feishu/config
    Backend->>Redis: 生成随机 state(TTL 5 分钟)
    Redis-->>Backend: state 已存储
    Backend-->>Frontend: 返回 { appId, authUrl, state }
    Frontend->>Frontend: state 写入 sessionStorage
    Frontend->>Feishu: window.location.href = authUrl(跳转授权页)
    Feishu->>User: 显示飞书授权确认页
    User->>Feishu: 确认授权
    Feishu-->>Frontend: 重定向到 /feishu/callback?code=xxx&state=xxx
    Frontend->>Frontend: 校验 state 与 sessionStorage 一致
    Frontend->>Backend: POST /api/login/feishu/verify { code, state }
    Backend->>Redis: 校验并删除 state(防重放)
    Redis-->>Backend: OK
    Backend->>Feishu: 用 code 换取 user_access_token
    Feishu-->>Backend: user_access_token
    Backend->>Feishu: 获取用户基本信息
    Feishu-->>Backend: 用户身份信息
    Backend->>Backend: 查询本地员工并下发登录凭证
    Backend-->>Frontend: 登录成功
    Frontend-->>User: 进入系统

3.2 扫码登录流程

扫码登录的前半段依赖飞书 QR SDK,后半段与一键授权完全一致:

sequenceDiagram
    autonumber
    actor User as 用户
    participant Mobile as 手机飞书
    participant Frontend as 前端页面
    participant Backend as 后端服务
    participant Feishu as 飞书 OAuth

    Frontend->>Frontend: 加载飞书 QR SDK,渲染二维码
    User->>Mobile: 用飞书扫码
    Mobile->>Feishu: 确认授权
    Feishu-->>Frontend: postMessage 返回 tmp_code
    Frontend->>Frontend: 校验 tmp_code(长度 >= 10、来源校验)
    Frontend->>Feishu: 跳转 authUrl + "&tmp_code=" + tmpCode
    Feishu-->>Frontend: 重定向到 /feishu/callback?code=xxx&state=xxx
    Note over Frontend,Backend: 后续流程与一键授权完全一致

两种方式的共同点


四、前端实现

4.1 获取配置并跳转

登录页点击「飞书登录」时,先调用后端获取配置:

// 获取飞书登录配置
async function getFeishuConfig() {
  const res = await fetch('/api/login/feishu/config')
  const { appId, authUrl, state, redirectUri } = await res.json()

  // state 写入 sessionStorage,回调时做二次校验
  sessionStorage.setItem('feishu_state', state)
  sessionStorage.setItem('feishu_redirect_uri', redirectUri)

  return { appId, authUrl, state, redirectUri }
}

4.2 一键授权登录

一键授权使用当前页面直接跳转的方式,而非弹窗:

const { authUrl } = await getFeishuConfig()

// 直接跳转(非弹窗),避免 COOP 限制导致 opener 失效
window.location.href = authUrl

为什么不用弹窗? 生产环境的 Cross-Origin-Opener-Policy: same-origin 响应头会导致弹出窗口的 window.openernull,弹窗方案不可靠。

4.3 二维码扫码登录

扫码登录需要引入飞书 QR SDK,在登录页嵌入二维码:

// 引入飞书 QR SDK(通过 script 标签或 npm 包)
// SDK 会在指定 DOM 元素内渲染二维码 iframe

const { authUrl, appId } = await getFeishuConfig()

// 初始化二维码
const qrObj = QRLogin({
  id: 'qrcode-container',   // 二维码挂载的 DOM 元素 ID
  goto: authUrl,            // 扫码成功后跳转的授权 URL
  width: '250',
  height: '250',
})

// 监听 postMessage 获取扫码结果
window.addEventListener('message', (e) => {
  // 校验消息来源
  if (!qrObj.matchOrigin(e.origin)) return

  const tmpCode = e.data?.tmp_code ?? e.data
  if (typeof tmpCode !== 'string' || tmpCode.length < 10) return

  // 携带 tmp_code 跳转飞书授权
  window.location.href = authUrl + '&tmp_code=' + tmpCode
})

4.4 OAuth 回调处理

飞书授权后重定向到前端回调地址(如 /feishu/callback),前端在应用路由初始化前拦截处理:

// 在路由初始化前拦截回调 URL
if (window.location.pathname === '/feishu/callback') {
  const params = new URLSearchParams(window.location.search)
  const code = params.get('code')
  const state = params.get('state')

  // 校验 state 与之前存入 sessionStorage 的一致
  const savedState = sessionStorage.getItem('feishu_state')
  if (state !== savedState) {
    localStorage.setItem('feishu_login_error', '非法请求,请重新登录')
    location.replace('/login')
    return
  }

  // 调用后端验证接口
  try {
    const res = await fetch('/api/login/feishu/verify', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ code, state }),
    })
    const data = await res.json()
    if (data.code === 0) {
      // 登录成功,存入 pending 让登录页读取
      localStorage.setItem('feishu_login_pending', JSON.stringify(data.data))
      location.replace('/login')
    } else {
      localStorage.setItem('feishu_login_error', data.msg)
      location.replace('/login')
    }
  } catch (err) {
    localStorage.setItem('feishu_login_error', '登录失败,请重试')
    location.replace('/login')
  }
}

五、后端实现

后端使用飞书 Java SDK(oapi-sdk)调用开放平台 API,SDK 会自动处理 tenant_access_token 的获取和缓存。

Maven 依赖示例:

<dependency>
    <groupId>com.larksuite.oapi</groupId>
    <artifactId>oapi-sdk</artifactId>
    <version>2.5.3</version>
</dependency>

5.1 获取飞书登录配置

接口GET /api/login/feishu/config(免登录)

后端生成防 CSRF 的 state 随机串,同时组装完整的飞书 OAuth 授权 URL(参考飞书 OAuth 授权接口文档):

@GetMapping("/login/feishu/config")
@NoNeedLogin
public ResponseDTO<FeishuLoginConfigVO> getLoginConfig() {
    // 生成随机 state,写入 Redis(TTL 5 分钟)
    String state = UUID.randomUUID().toString().replace("-", "");
    redisTemplate.opsForValue().set(
        "feishu:login:state:" + state,
        String.valueOf(System.currentTimeMillis()),
        5, TimeUnit.MINUTES
    );

    // 从配置中心读取飞书应用信息
    String appId = configService.getConfig(ConfigKeyEnum.FEISHU_APP_ID);
    String redirectUri = configService.getConfig(ConfigKeyEnum.FEISHU_REDIRECT_URI);

    // 组装 OAuth 授权 URL
    String authUrl = UriComponentsBuilder
        .fromHttpUrl("https://passport.feishu.cn/suite/passport/oauth/authorize")
        .queryParam("client_id", appId)
        .queryParam("redirect_uri", URLEncoder.encode(redirectUri, StandardCharsets.UTF_8))
        .queryParam("response_type", "code")
        .queryParam("state", state)
        .queryParam("scope", "contact:user.base:readonly")
        .build()
        .toUriString();

    FeishuLoginConfigVO vo = new FeishuLoginConfigVO();
    vo.setAppId(appId);
    vo.setRedirectUri(redirectUri);
    vo.setState(state);
    vo.setAuthUrl(authUrl);

    return ResponseDTO.success(vo);
}

5.2 验证授权码并登录

接口POST /api/login/feishu/verify(免登录)

核心流程为:校验 state → 调用获取 user_access_token → 调用获取登录用户身份 → 查本地员工 → 下发凭证:

@PostMapping("/login/feishu/verify")
@NoNeedLogin
public ResponseDTO<LoginResultVO> feishuVerify(
        @RequestBody @Valid FeishuLoginVerifyForm form,
        HttpServletRequest request) {

    // 1. 校验 state,防 CSRF + 防重放
    String redisKey = "feishu:login:state:" + form.getState();
    String savedState = redisTemplate.opsForValue().get(redisKey);
    if (savedState == null) {
        return ResponseDTO.error("非法请求");
    }
    // 原子消费:删除后不可再用(防重放)
    redisTemplate.delete(redisKey);

    // 2. 用 code 换取 user_access_token
    // 注意:redirect_uri 必须与授权时完全一致
    FeishuTokenResponse tokenRes = feishuApiManager.exchangeCodeForToken(
        form.getCode(),
        configService.getConfig(ConfigKeyEnum.FEISHU_REDIRECT_URI)
    );
    if (tokenRes == null || !"success".equalsIgnoreCase(tokenRes.getStatus())) {
        return ResponseDTO.error("登录授权失败,请重试");
    }

    // 3. 获取飞书用户信息
    FeishuUserInfo userInfo = feishuApiManager.getUserInfo(tokenRes.getAccessToken());

    // 4. 查询本地员工(通过 feishu_union_id 映射)
    EmployeeEntity employee = employeeService.findByFeishuUnionId(userInfo.getUnionId());
    if (employee == null) {
        return ResponseDTO.error("您的飞书账号未绑定系统账号,请联系管理员");
    }
    if (employee.getDisabledFlag()) {
        return ResponseDTO.error("您的账号已被禁用,请联系工作人员");
    }

    // 5. 下发登录凭证(如 Sa-Token)
    LoginResultVO loginResult = authService.doLogin(employee);

    // 6. 异步校正员工信息(不影响登录主流程)
    CompletableFuture.runAsync(() -> {
        feishuLoginService.syncEmployeeFromFeishu(employee.getId(), userInfo);
    });

    return ResponseDTO.success(loginResult);
}

5.3 封装飞书 API 调用

建议统一封装飞书开放平台 HTTP 调用,核心涉及两个接口:

@Component
public class FeishuApiManager {

    @Autowired
    private RestTemplate feishuRestTemplate;

    @Value("${feishu.app.id}")
    private String appId;

    @Value("${feishu.app.secret}")
    private String appSecret;

    /**
     * 用授权码换取 user_access_token
     * redirect_uri 必须与授权请求中的完全一致
     */
    public FeishuTokenResponse exchangeCodeForToken(String code, String redirectUri) {
        String url = "https://open.feishu.cn/open-apis/authentication/v2/oauth/token";
        // 此处需传入 redirect_uri,否则会报 redirect_uri_mismatch
        // 使用 application/x-www-form-urlencoded 格式
        MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
        params.add("grant_type", "authorization_code");
        params.add("client_id", appId);
        params.add("client_secret", appSecret);
        params.add("code", code);
        params.add("redirect_uri", redirectUri);

        return feishuRestTemplate.postForObject(url, params, FeishuTokenResponse.class);
    }

    /**
     * 获取登录用户基本信息
     */
    public FeishuUserInfo getUserInfo(String userAccessToken) {
        String url = "https://open.feishu.cn/open-apis/authentication/v2/user/info";
        HttpHeaders headers = new HttpHeaders();
        headers.setBearerAuth(userAccessToken);

        HttpEntity<?> entity = new HttpEntity<>(headers);
        ResponseEntity<FeishuUserResponse> resp = feishuRestTemplate.exchange(
            url, HttpMethod.GET, entity, FeishuUserResponse.class
        );
        return resp.getBody() != null ? resp.getBody().getData() : null;
    }
}

六、安全设计

6.1 CSRF 防护(state)

OAuth 流程中,state 参数是标准的 CSRF 防护手段。本项目采用双重校验

flowchart LR
    A[GET /config 生成 state] --> B[写入 Redis - TTL 5 分钟]
    A --> C[返回给前端 - 存入 sessionStorage]
    D[回调 /feishu/callback] --> E{校验 state 与<br>sessionStorage 是否一致}
    E -->|不一致| F[拒绝请求]
    E -->|一致| G[POST /verify 提交后端]
    G --> H{校验 Redis state 是否存在}
    H -->|不存在或已消费| I[拒绝请求 防重放]
    H -->|存在| J[原子删除 state 发放登录凭证]
校验层存储位置作用
前端sessionStorage防 CSRF:URL 中的 state 与本地存储不一致时直接拒绝
后端Redis(TTL 5 分钟)防重放:verify 校验后原子删除,一个 state 只能用一次

6.2 redirect_uri 一致性

这是一个容易踩坑的点:

飞书换 token 接口请求中的 redirect_uri 必须与授权请求开放平台配置中的 redirect_uri 完全一致(包括协议、域名、路径、尾部斜杠等)。

不一致会报 redirect_uri_mismatch 错误。

6.3 账号状态校验

登录时需检查员工账号状态:


七、踩坑记录

🕳️ 坑 1:弹窗 COOP 限制

现象:生产环境使用 window.open() 弹飞书授权页,授权完成后 window.openernull,无法通信。

原因:生产环境配置了 Cross-Origin-Opener-Policy: same-origin 响应头,导致跨域弹窗的 opener 被隔离。

解决:改用 window.location.href 当前页面直接跳转。

🕳️ 坑 2:redirect_uri 不匹配

现象:调用获取 user_access_token 时飞书返回 redirect_uri_mismatch

原因:换 token 请求中 redirect_uri 参数与授权请求中的 redirect_uri 不完全一致(如尾部斜杠、大小写差异)。

解决:将 redirect_uri 统一从配置中心读取,确保授权和换 token 使用同一个值。

🕳️ 坑 3:扫码 tmp_code 校验不足

现象:偶尔收到非预期的 postMessage 数据,导致错误跳转。

解决:对 tmp_code 做基础校验 —— 确保是字符串且长度 >= 10,同时用 SDK 的 matchOrigin 校验消息来源。

🕳️ 坑 4:state 未及时清理

现象:用户在登录页刷新页面或打开多个标签页,旧 state 被重放使用。

解决:后端 verify 接口校验后原子删除 Redis key,前端回调同时校验 sessionStorage,任一层校验不通过即拒绝。

🕳️ 坑 5:飞书 filter 接口参数格式

现象(部门同步场景可选参考):调用飞书 filter 接口查询部门列表时(POST /directory/v1/departments/filter),参数中的 value 需传带引号的字符串,不能传纯数字,否则报 Mismatch type string with value number


八、总结

飞书 OAuth 授权码模式接入本身并不复杂,核心链路可以概括为:

获取配置 → 跳转授权 → 回调校验 → 换 token → 查用户 → 发凭证

但实际落地中,安全防护(双重 state 校验、防重放)和边界情况(COOP 限制、redirect_uri 一致性)才是真正需要关注的点。建议在开发时参考以下飞书官方文档:

文档链接
飞书 SSO 登录流程概览查看
飞书 QR SDK 接入文档查看
获取 user_access_token查看
刷新 user_access_token查看
获取登录用户身份查看
飞书 Java SDK 使用指南查看
飞书 OAuth 授权接口查看

通过这套方案,可以实现员工使用飞书身份一键登录后台系统,提升内部系统的使用体验和安全水位。


Edit page
Share this post on:

评论


Next Post
华为昇腾 NPU 大模型部署实战:从踩坑到量产