飞书单点登录(SSO)


为实现飞书单点登录功能需求,以下为详细配置流程记录。

飞书配置

权限管理(需要开启的权限)

获取员工的工号
在这里插入图片描述

配置安全设置

添加需要访问的页面地址在这里插入图片描述

前端页面

登录界面,login.vue

添加登录入口

      <div class="social-login-icons">
        <img src="@/assets/images/飞书.svg" alt="飞书登录" @click.prevent="handleFeiShuLogin" class="social-icon"/>
        <!-- 如果未来有企业微信、钉钉等,可以继续在这里添加 -->
      </div>
    handleFeiShuLogin() {
      // 1. 在飞书开放平台获取你的 App ID
      const APP_ID = "****"; // !!!【重要】替换成你在飞书开放平台应用的真实 App ID

      // 2. 在飞书开放平台配置你的重定向URL,并在这里保持完全一致
      //    这个URL必须指向你系统里一个能接收和处理回调的页面路由
      const protocol = window.location.protocol;
      const host = window.location.host;

      // 获取基础路径 (Base Path),这部分通常在 vue.config.js 中配置
      // 对于若依框架,它通常是 process.env.VUE_APP_BASE_API 的前半部分
      // 如果您的基础路径是 /gms/,一个更健壮的方式是直接硬编码这部分,或者从环境变量读取
      // 为简单起见,我们先假设基础路径是固定的,如果它会变,我们再优化
      const basePath = '/gms/';

      // 拼接成最终的回调地址
      // 对于 hash 模式,回调页面路由是 /#/callback
      const REDIRECT_URI = `${protocol}//${host}${basePath}#/callback`;

      // 3. 对重定向URL进行URL编码,防止特殊字符导致解析失败
      const ENCODED_REDIRECT_URI = encodeURIComponent(REDIRECT_URI);

      // 4. (可选但强烈推荐)生成一个随机字符串作为 state,用于防止CSRF攻击
      //    你可以将它存入 localStorage,在回调页面进行验证
      const state = Math.random().toString(36).substring(2);
      localStorage.setItem('feishu_oauth_state', state);

      // 5. 拼接完整的飞书授权URL
      const feishuAuthUrl = `https://passport.feishu.cn/suite/passport/oauth/authorize?client_id=${APP_ID}&redirect_uri=${ENCODED_REDIRECT_URI}&response_type=code&state=${state}`;

      // 6. 指示浏览器跳转到该地址
      window.location.href = feishuAuthUrl;
    }

登录回调页面配置

<template>
  <div class="loading-container">
    <p>正在验证身份,请稍候...</p>
  </div>
</template>

<script>
export default {
  name: "FeiShuSsoCallback",
  created() {
    // 在组件创建时,立即处理回调逻辑
    this.handleFeiShuCallback();
  },
  methods: {
    /**
     * @description 从URL的查询字符串(search部分)中获取指定参数的值
     * @param {string} paramName - 要获取的参数名, 例如 'code' 或 'state'
     * @returns {string | null} - 返回参数的值,如果不存在则返回null
     */
    getQueryParam(paramName) {
      // 使用 URLSearchParams 是更现代、更简洁的方法
      const urlParams = new URLSearchParams(window.location.search);
      return urlParams.get(paramName);
    },

    handleFeiShuCallback() {
      // 【关键】调用我们的辅助函数来获取 code 和 state
      const code = this.getQueryParam('code');
      const state = this.getQueryParam('state');

      // console.log("成功从 window.location.search 中获取到 code:", code);
      // console.log("成功从 window.location.search 中获取到 state:", state);

      // --- 接下来的逻辑是验证和发送code到后端 ---

      // 1. 验证 state,防止跨站请求伪造 (CSRF) 攻击
      const localState = localStorage.getItem('feishu_oauth_state');
      if (!state || state !== localState) {
        this.$message.error('安全验证失败,请重新登录。');
        localStorage.removeItem('feishu_oauth_state'); // 清理
        this.$router.push('/login');
        return;
      }
      // 验证成功后,立即清理本地存储的 state
      localStorage.removeItem('feishu_oauth_state');

      // 2. 检查 code 是否存在
      if (code) {
        // 3. 将 code 发送给后端进行登录处理 (通常通过 Vuex Action)
        this.$store.dispatch('LoginByFeiShu', code)
          .then(() => {
            // 后端验证成功,并返回了token(已在action中存好)
            this.$message.success('飞书登录成功!');
            // 跳转到系统主页
            this.$router.push({path: '/'});
          }).catch(error => {
          // 后端处理失败
          console.error("飞书登录后端处理失败:", error);
          this.$message.error(error.message || '飞书登录失败,请稍后重试。');
          this.$router.push('/login');
        });
      } else {
        // 如果URL中连code都没有
        this.$message.error('未获取到飞书授权信息,请重试。');
        this.$router.push('/login');
      }
    }
  }
};
</script>

<style scoped>
.loading-container {
  display: flex;
  justify-content: center;
  align-items: center;
  height: 100vh;
  font-size: 1.2em;
  color: #606266;
  background-color: #f4f4f5;
}
</style>

user.js 创建action

在 Vuex 的 user 模块(通常是 src/store/modules/user.js 文件)中,创建名为 loginByFeishu 的 action。
首先在login.js中添加访问后端的function(loginByFeiShu)

src\api\login.js
/**
 * @description 使用飞书授权码进行登录
 * @param {string} code - 从飞书回调URL中获取的授权码
 */
export function loginByFeiShu(code) {
  return request({
    // 【重要】将URL替换成您后端真实的处理飞书登录的接口地址
    url: '/sso/feiShu/login',
    method: 'post',
    data: {
      code: code // 将code作为请求体发送给后端
    }
  })
}

LoginByFeiShu({commit}, code) { // action名称根据import改为 loginByFeiShu
      return new Promise((resolve, reject) => {
        // 1. 调用API函数,将code传过去
        loginByFeiShu(code).then(res => {
          // 2. 直接从 res 中获取 token,不再需要解构 data
          if (res && res.data) {
            // 3. 将token存入Vuex state
            commit('SET_TOKEN', res.data)
            // 4. 将token存入Cookie
            setToken(res.data)
            // 5. Promise成功
            resolve()
          } else {
            // 如果后端没有按预期返回token
            reject(new Error('飞书登录凭证交换失败,请联系管理员。'))
          }
        }).catch(error => {
          // 6. 如果API请求本身就失败了(如网络错误,服务器500等)
          reject(error)
        })
      })
    }

为回调页面设置“白名单”

修改 src/permission.js 文件:

const whiteList = ['/login', '/callback'] // 在白名单中加入 '/callback'

在index.js中添加回调路由

  {
    path: '/callback',
    name: 'Callback',
    component: () => import('@/views/FeiShuSsoCallback.vue'), // 确保路径正确
    hidden: true // 在侧边栏中隐藏
  },

后端配置

添加controller方法

import cn.hutool.cache.CacheUtil;
import cn.hutool.cache.impl.TimedCache;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.core.domain.model.LoginUser;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.framework.web.service.SysPermissionService;
import com.ruoyi.framework.web.service.TokenService;
import com.ruoyi.system.service.ISysUserService;
import lombok.Cleanup;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;
import java.util.Map;
import java.util.Set;

/**
 * 飞书单点登录处理
 *
 * @date 2025/08/04
 */
@Slf4j
@RestController
@RequestMapping("/sso") // 建议为SSO相关接口统一添加一个前缀
public class SsoLoginController {

    // --- 自动注入若依框架的核心服务 ---
    @Resource
    private ISysUserService userService;

    @Resource
    private TokenService tokenService;

    @Resource
    private SysPermissionService permissionService;


    // --- 从 application.yml 读取飞书配置 ---
    @Value("${feiShu.appId}")
    private String appId;

    @Value("${feiShu.appSecret}")
    private String appSecret;


    // --- 飞书官方接口地址定义 ---
    private static final String APP_ACCESS_TOKEN_URL = "https://open.feishu.cn/open-apis/auth/v3/app_access_token/internal";
    private static final String USER_ACCESS_TOKEN_URL = "https://open.feishu.cn/open-apis/authen/v1/access_token";
    private static final String USER_INFO_URL = "https://open.feishu.cn/open-apis/contact/v3/users/";


    // --- 关键组件:创建一个定时缓存用于存储 app_access_token ---
    // 飞书 app_access_token 有效期为2小时,我们设置缓存有效期为1小时55分钟,提前刷新
    private final TimedCache<String, String> timedCache = CacheUtil.newTimedCache(6900 * 1000);


    /**
     * 【主接口】处理前端发送过来的飞书授权码(code)
     *
     * @param body 包含 "code" 的请求体, 例如: {"code": "xxx..."}
     * @return 包含系统token的AjaxResult
     */
    @PostMapping("/feiShu/login")
    public AjaxResult feiShuLogin(@RequestBody Map<String, String> body) {
        String code = body.get("code");
        if (StringUtils.isEmpty(code)) {
            return AjaxResult.error("飞书授权码不能为空");
        }

        try {
            // 1. 用 code 从飞书换取用户信息
            JSONObject feiShuUserInfo = getFeiShuUserInfoByCode(code);

            // 2. 根据飞书用户信息(手机号)查找或创建系统用户
            SysUser user = findOrCreateUserByFeiShuInfo(feiShuUserInfo);

            // 3. 为该用户生成系统Token (复用若依的逻辑)
            String token = generateSystemToken(user);

            return AjaxResult.success("登录成功", token);

        } catch (Exception e) {
            // 捕获所有自定义异常,返回给前端
            log.error("飞书登录失败:{}", e.getMessage(), e);
            return AjaxResult.error("飞书登录失败:" + e.getMessage());
        }
    }


    /**
     * 核心业务逻辑1:通过code从飞书获取用户完整信息
     */
    private JSONObject getFeiShuUserInfoByCode(String code) {
        // Step 1.1: 获取 app_access_token (带缓存)
        String appAccessToken = getAppAccessToken();

        // Step 1.2: 使用 code 换取 user_access_token
        JSONObject userAccessTokenData = getUserAccessToken(appAccessToken, code);
        String userAccessToken = userAccessTokenData.getStr("access_token");
        String openId = userAccessTokenData.getStr("open_id"); // 获取 open_id

        // Step 1.3: 使用 user_access_token 和 open_id 获取用户详细信息 (如手机号)
        return getUserDetailInfo(userAccessToken, openId);
    }


    /**
     * 核心业务逻辑2:根据飞书信息查找或创建系统用户
     */
    private SysUser findOrCreateUserByFeiShuInfo(JSONObject feishuUserInfo) {
        String employeeNo = feishuUserInfo.getStr("user_id");
        if (StringUtils.isEmpty(employeeNo)) {
            throw new RuntimeException("无法获取您的飞书工号,请确认应用已开通“获取用户工号”权限或联系管理员。");
        }

        // 尝试通过手机号从数据库查找用户
        SysUser user = userService.selectUserByUserName(employeeNo);

        // 如果用户不存在,执行自动注册逻辑
        if (user == null) {
            throw new RuntimeException("用户不存在,请联系管理员。");
        }
        return user;
    }


    /**
     * 核心业务逻辑3:为指定用户生成系统Token
     */
    private String generateSystemToken(SysUser user) {
        // 这部分逻辑完全拷贝自若依的 `SysLoginServiceImpl`
        // 目的是为登录用户创建一个包含权限信息的 LoginUser 对象
        Set<String> permissions = permissionService.getMenuPermission(user);
        LoginUser loginUser = new LoginUser(user.getUserId(), user.getDeptId(), user, permissions);

        // 调用TokenService生成并返回JWT Token
        return tokenService.createToken(loginUser);
    }


    // --- 以下是与飞书服务器交互的纯工具方法 ---

    /**
     * 工具方法1:获取 App Access Token (带缓存)
     */
    private String getAppAccessToken() {
        String token = timedCache.get("app_access_token", false);
        if (token != null)
            return token;

        JSONObject payload = new JSONObject().set("app_id", appId).set("app_secret", appSecret);
        @Cleanup HttpResponse execute = HttpRequest.post(APP_ACCESS_TOKEN_URL).body(payload.toString()).execute();
        String body = execute.body();

        JSONObject json = JSONUtil.parseObj(body);

        if (json.getInt("code") == 0) {
            String appAccessToken = json.getStr("app_access_token");
            timedCache.put("app_access_token", appAccessToken);
            return appAccessToken;
        }
        throw new RuntimeException("获取飞书App Access Token失败, 响应: " + body);
    }

    /**
     * 工具方法2:使用 code 换取用户的 Access Token
     */
    private JSONObject getUserAccessToken(String appAccessToken, String code) {
        JSONObject payload = new JSONObject().set("grant_type", "authorization_code").set("code", code);
        @Cleanup HttpResponse res = HttpRequest.post(USER_ACCESS_TOKEN_URL)
                .header("Authorization", "Bearer " + appAccessToken)
                .body(payload.toString())
                .execute();

        String body = res.body();

        JSONObject json = JSONUtil.parseObj(body);

        if (json.getInt("code") == 0) {
            return json.getJSONObject("data");
        }
        throw new RuntimeException("使用code换取用户token失败, 响应: " + body);
    }

    /**
     * 工具方法3:获取用户的详细信息
     */
    private JSONObject getUserDetailInfo(String userAccessToken, String openId) {
        String url = USER_INFO_URL + openId + "?user_id_type=open_id";
        @Cleanup HttpResponse res = HttpRequest.get(url)
                .header("Authorization", "Bearer " + userAccessToken)
                .execute();

        String body = res.body();

        JSONObject json = JSONUtil.parseObj(body);

        if (json.getInt("code") == 0) {
            return json.getJSONObject("data").getJSONObject("user");
        }

        throw new RuntimeException("获取用户详细信息失败, 响应: " + body);
    }
}

配置 Spring Security 白名单

// 文件路径: ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java

// ... 其他 import ...
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
// ...

@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    
    // ... 其他 Bean 定义 ...

    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception {
        // ...
        httpSecurity
                // ... 其他配置 ...
                .and()
                .authorizeRequests()
                // 【修改这里】在匿名访问的 antMatchers 列表中,加入我们的飞书登录接口
                .antMatchers(
                        "/login", 
                        "/register", 
                        "/captchaImage", 
                        "/getSysVersion", 
                        "/getResetCode", 
                        "/system/user/profile/app/resetPassword",
                        // ================= 【在这里添加我们的接口】 =================
                        "/sso/feiShu/login" 
                        // =======================================================
                 ).permitAll()
                // 静态资源,可匿名访问
                .antMatchers(HttpMethod.GET, "/", "/*.html", "/**/*.html", "/**/*.css", "/**/*.js", "/profile/**").permitAll()
                .antMatchers("/swagger-ui.html", "/swagger-resources/**", "/webjars/**", "/*/api-docs", "/druid/**").permitAll()
                // 除上面外的所有请求全部需要鉴权认证
                .anyRequest().authenticated()
                .and()
                .headers().frameOptions().disable();
        // ... 其他配置 ...
    }
}
<think>嗯,用户想了解飞书的单登录配置或集成方法。首先,我得回忆一下之前提供的引用内容。引用里提到飞书的单登录需要“飞书集成平台”,而不是常见的开放平台,所以可能需要先确认权限问题。企业管理员需要开通“身份集成”权限,这可能是个关键步骤。 然后,用户可能需要具体的配置步骤。根据引用中的信息,飞书集成平台可能涉及到SAML或者OAuth的设置,比如配置服务提供商元数据,设置断言属性等。另外,ADFS的集成也可能相关,比如引用2提到ADFS配置AD证书服务和联合身份验证,这可能与飞书SSO集成有关联,但需要进一步确认飞书是否支持ADFS作为身份提供商。 接下来,我需要考虑用户可能的实际应用场景。他们可能是在企业内部系统集成飞书登录,或者开发第三方应用需要飞书SSO支持。这时候,单登录的实现方式,比如基于SAML、OAuth还是CAS,需要根据飞书支持的协议来决定。引用3提到多种单登录方式,但飞书可能主要支持其中几种,比如SAML或OIDC。 另外,用户可能遇到权限问题,比如如何让企业管理员开通飞书集成平台的权限,这里需要指导用户联系管理员并勾选“身份集成”权限。同时,配置过程中可能需要下载飞书的元数据文件,或者在服务提供商那边上传自己的元数据,这需要详细步骤说明。 测试环节也很重要,用户完成配置后需要验证SSO是否生效,可能需要指导如何通过飞书访问应用,并检查登录流程是否顺畅。如果遇到问题,可能需要检查证书是否有效,或者属性映射是否正确。 最后,用户可能想知道相关的优化方法或常见问题,比如如何处理多个应用的SSO配置,或者如何更新证书等。这时候需要提供一些扩展建议,比如定期维护证书,或者利用飞书的API进行自动化管理。</think>### 飞书登录SSO)配置与集成方法 #### 一、前提条件 1. **权限开通**:企业管理员需在飞书集成平台中勾选“身份集成”权限[^1]。 2. **身份提供商(IdP)选择**:飞书支持基于SAML、OAuth 2.0或OpenID Connect协议的集成,需确认目标系统支持的协议类型[^3]。 --- #### 二、配置步骤(以SAML为例) 1. **飞书集成平台配置** - 登录飞书集成平台,进入「单登录」模块。 - 创建新应用,填写服务提供商(SP)元数据: - **断言消费者服务URL**:`https://your-app.com/saml/acs` - **实体ID**:应用唯一标识符(如`urn:your-app:sso`) - 下载飞书提供的元数据文件(含公钥和IdP端信息)。 2. **服务提供商(SP)配置** - 上传飞书元数据文件至目标系统(如ADFS、CAS等)。 - 设置属性映射(例如将`employee_number`映射为用户ID字段)。 - 配置证书信任链,确保SP信任飞书的签名证书[^2]。 3. **ADFS集成示例(如目标系统为微软生态)** ```xml <!-- ADFS信赖方信任配置片段 --> <ClaimsProvider> <DisplayName>Feishu SSO</DisplayName> <TechnicalProfile ReferenceId="Feishu-SAML2-TP"> <DisplayName>Feishu</DisplayName> <Protocol Name="SAML2"/> <Metadata> <Item Key="WantsSignedAssertions">false</Item> <Item Key="PartnerEntity">https://sso.feishu.cn/saml/metadata</Item> </Metadata> </TechnicalProfile> </ClaimsProvider> ``` --- #### 三、测试与验证 1. 通过飞书工作台访问目标应用,观察是否自动跳转登录。 2. 使用浏览器开发者工具检查SAML断言传输是否加密(HTTPS)。 3. 验证用户属性(如邮箱、部门)是否准确传递。 --- #### 四、扩展优化 1. **多应用集成**:在飞书集成平台为不同系统创建独立SSO配置,复用同一套身份源。 2. **证书轮换**:定期更新飞书元数据中的X.509证书,避免过期导致登录失败。 3. **日志监控**:通过飞书管理后台跟踪SSO登录事件,分析异常行为。 ---
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值