飞书单点登录(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();
// ... 其他配置 ...
}
}