feat(auth): 实现飞书OAuth认证及相关配置
- 新增飞书OAuth认证控制器,支持获取授权URL、登录和登录状态检查 - 实现飞书认证服务接口及实现类,完成授权码换取用户信息及用户创建或更新 - 配置Sa-Token拦截器和全局过滤器,实现登录鉴权和跨域支持 - 添加系统用户Mapper及对应XML,支持用户和角色查询及分页筛选 - 新增飞书开放平台配置类,支持应用ID、密钥和回调地址配置 - 添加开发环境配置文件,配置数据库、MinIO、MyBatis Plus、飞书和日志等相关参数 - 新增Maven项目依赖,包含Spring Boot、Sa-Token、MyBatis Plus、MinIO客户端、文档解析和飞书SDK等依赖
This commit is contained in:
7
pom.xml
7
pom.xml
@@ -145,6 +145,13 @@
|
||||
<version>1.39.0</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 飞书开放平台SDK -->
|
||||
<dependency>
|
||||
<groupId>com.larksuite.oapi</groupId>
|
||||
<artifactId>larksuite-oapi</artifactId>
|
||||
<version>2.3.1</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
|
||||
34
src/main/java/cn/yinlihupo/common/config/FeishuConfig.java
Normal file
34
src/main/java/cn/yinlihupo/common/config/FeishuConfig.java
Normal file
@@ -0,0 +1,34 @@
|
||||
package cn.yinlihupo.common.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* 飞书开放平台配置类
|
||||
*/
|
||||
@Data
|
||||
@Configuration
|
||||
@ConfigurationProperties(prefix = "feishu")
|
||||
public class FeishuConfig {
|
||||
|
||||
/**
|
||||
* 应用ID (App ID)
|
||||
*/
|
||||
private String appId;
|
||||
|
||||
/**
|
||||
* 应用密钥 (App Secret)
|
||||
*/
|
||||
private String appSecret;
|
||||
|
||||
/**
|
||||
* 授权回调地址
|
||||
*/
|
||||
private String redirectUri;
|
||||
|
||||
/**
|
||||
* 飞书开放平台域名
|
||||
*/
|
||||
private String domain = "https://open.feishu.cn";
|
||||
}
|
||||
@@ -30,6 +30,8 @@ public class SaTokenConfig implements WebMvcConfigurer {
|
||||
.excludePathPatterns(
|
||||
"/auth/login",
|
||||
"/auth/register",
|
||||
"/auth/feishu/authorize",
|
||||
"/auth/feishu/login",
|
||||
"/error",
|
||||
"/swagger-ui/**",
|
||||
"/v3/api-docs/**"
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package cn.yinlihupo.controller.auth;
|
||||
|
||||
import cn.dev33.satoken.stp.StpUtil;
|
||||
import cn.yinlihupo.common.core.BaseResponse;
|
||||
import cn.yinlihupo.common.util.ResultUtils;
|
||||
import cn.yinlihupo.domain.entity.SysUser;
|
||||
import cn.yinlihupo.service.system.FeishuAuthService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 飞书OAuth认证控制器
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/auth/feishu")
|
||||
@RequiredArgsConstructor
|
||||
public class FeishuAuthController {
|
||||
|
||||
private final FeishuAuthService feishuAuthService;
|
||||
|
||||
/**
|
||||
* 获取飞书OAuth授权URL
|
||||
*
|
||||
* @param state 可选的状态参数(防CSRF攻击)
|
||||
* @return 授权URL
|
||||
*/
|
||||
@GetMapping("/authorize")
|
||||
public BaseResponse<Map<String, String>> getAuthUrl(@RequestParam(required = false) String state) {
|
||||
String authUrl = feishuAuthService.buildAuthUrl(state);
|
||||
Map<String, String> result = new HashMap<>();
|
||||
result.put("authUrl", authUrl);
|
||||
return ResultUtils.success("获取授权URL成功",result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 飞书OAuth登录接口(前端回调后调用)
|
||||
* 前端从飞书回调中获取code,然后调用此接口完成登录
|
||||
*
|
||||
* @param code 飞书授权码
|
||||
* @return 登录结果(包含token和用户信息)
|
||||
*/
|
||||
@PostMapping("/login")
|
||||
public BaseResponse<Map<String, Object>> login(@RequestParam String code) {
|
||||
log.info("收到飞书登录请求, code: {}", code);
|
||||
|
||||
try {
|
||||
// 使用code换取用户信息并完成登录
|
||||
SysUser user = feishuAuthService.loginByCode(code);
|
||||
|
||||
// 使用Sa-Token登录
|
||||
StpUtil.login(user.getId());
|
||||
|
||||
// 构建返回数据
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("token", StpUtil.getTokenValue());
|
||||
result.put("tokenName", StpUtil.getTokenName());
|
||||
result.put("userId", user.getId());
|
||||
result.put("username", user.getUsername());
|
||||
result.put("realName", user.getRealName());
|
||||
result.put("avatar", user.getAvatar());
|
||||
result.put("phone", user.getPhone());
|
||||
result.put("email", user.getEmail());
|
||||
|
||||
log.info("飞书登录成功, userId: {}, phone: {}", user.getId(), user.getPhone());
|
||||
return ResultUtils.success("登录成功", result);
|
||||
} catch (Exception e) {
|
||||
log.error("飞书登录失败", e);
|
||||
return ResultUtils.error("登录失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查当前登录状态
|
||||
*
|
||||
* @return 登录状态
|
||||
*/
|
||||
@GetMapping("/check")
|
||||
public BaseResponse<Map<String, Object>> checkLogin() {
|
||||
boolean isLogin = StpUtil.isLogin();
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("isLogin", isLogin);
|
||||
|
||||
if (isLogin) {
|
||||
result.put("userId", StpUtil.getLoginId());
|
||||
}
|
||||
|
||||
return ResultUtils.success(isLogin ? "已登录" : "未登录", result);
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,11 @@ public interface SysUserMapper extends BaseMapper<SysUser> {
|
||||
*/
|
||||
SysUser selectByUsername(@Param("username") String username);
|
||||
|
||||
/**
|
||||
* 根据手机号查询用户
|
||||
*/
|
||||
SysUser selectByPhone(@Param("phone") String phone);
|
||||
|
||||
/**
|
||||
* 分页查询用户列表(支持按部门、状态、关键字筛选)
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package cn.yinlihupo.service.system;
|
||||
|
||||
import cn.yinlihupo.domain.entity.SysUser;
|
||||
|
||||
/**
|
||||
* 飞书认证服务接口
|
||||
*/
|
||||
public interface FeishuAuthService {
|
||||
|
||||
/**
|
||||
* 构建飞书OAuth授权URL
|
||||
*
|
||||
* @param state 状态参数(防CSRF攻击)
|
||||
* @return 授权URL
|
||||
*/
|
||||
String buildAuthUrl(String state);
|
||||
|
||||
/**
|
||||
* 通过授权码登录
|
||||
* 使用code换取accessToken,获取用户信息,完成登录
|
||||
*
|
||||
* @param code 飞书授权码
|
||||
* @return 登录后的用户信息
|
||||
*/
|
||||
SysUser loginByCode(String code);
|
||||
|
||||
/**
|
||||
* 根据手机号获取或创建用户
|
||||
* 如果手机号存在则更新用户信息,不存在则创建新用户
|
||||
*
|
||||
* @param phone 手机号
|
||||
* @param realName 真实姓名
|
||||
* @param avatar 头像URL
|
||||
* @param email 邮箱
|
||||
* @param openId 飞书OpenID
|
||||
* @return 用户实体
|
||||
*/
|
||||
SysUser getOrCreateUserByPhone(String phone, String realName, String avatar, String email, String openId);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package cn.yinlihupo.service.system.impl;
|
||||
|
||||
import cn.hutool.http.HttpRequest;
|
||||
import cn.hutool.http.HttpResponse;
|
||||
import cn.hutool.json.JSONObject;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import cn.yinlihupo.common.config.FeishuConfig;
|
||||
import cn.yinlihupo.domain.entity.SysUser;
|
||||
import cn.yinlihupo.mapper.SysUserMapper;
|
||||
import cn.yinlihupo.service.system.FeishuAuthService;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 飞书认证服务实现类
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class FeishuAuthServiceImpl implements FeishuAuthService {
|
||||
|
||||
private final FeishuConfig feishuConfig;
|
||||
private final SysUserMapper sysUserMapper;
|
||||
|
||||
/**
|
||||
* 飞书OAuth授权端点
|
||||
*/
|
||||
private static final String FEISHU_OAUTH_URL = "https://open.feishu.cn/open-apis/authen/v1/authorize";
|
||||
|
||||
/**
|
||||
* 获取访问令牌端点
|
||||
*/
|
||||
private static final String FEISHU_TOKEN_URL = "https://open.feishu.cn/open-apis/authen/v1/oidc/access_token";
|
||||
|
||||
/**
|
||||
* 获取用户信息端点
|
||||
*/
|
||||
private static final String FEISHU_USER_INFO_URL = "https://open.feishu.cn/open-apis/authen/v1/user_info";
|
||||
|
||||
/**
|
||||
* 应用访问令牌端点(用于获取应用级token)
|
||||
*/
|
||||
private static final String FEISHU_APP_TOKEN_URL = "https://open.feishu.cn/open-apis/auth/v3/app_access_token/internal";
|
||||
|
||||
@Override
|
||||
public String buildAuthUrl(String state) {
|
||||
String encodedRedirectUri = URLEncoder.encode(feishuConfig.getRedirectUri(), StandardCharsets.UTF_8);
|
||||
return FEISHU_OAUTH_URL +
|
||||
"?app_id=" + feishuConfig.getAppId() +
|
||||
"&redirect_uri=" + encodedRedirectUri +
|
||||
"&state=" + (state != null ? state : "") +
|
||||
"&scope=contact:user.base contact:user.phone:readonly";
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public SysUser loginByCode(String code) {
|
||||
log.info("处理飞书登录, code: {}", code);
|
||||
|
||||
// 1. 获取应用访问令牌
|
||||
String appAccessToken = getAppAccessToken();
|
||||
|
||||
// 2. 使用授权码获取用户访问令牌
|
||||
String userAccessToken = getUserAccessToken(code, appAccessToken);
|
||||
|
||||
// 3. 获取用户信息
|
||||
JSONObject userInfo = getUserInfo(userAccessToken);
|
||||
|
||||
// 4. 提取用户信息
|
||||
String phone = userInfo.getStr("mobile");
|
||||
String realName = userInfo.getStr("name");
|
||||
String avatar = userInfo.getStr("avatar_url");
|
||||
String email = userInfo.getStr("email");
|
||||
String openId = userInfo.getStr("open_id");
|
||||
|
||||
log.info("飞书用户信息: phone={}, name={}, openId={}", phone, realName, openId);
|
||||
|
||||
// 5. 根据手机号获取或创建用户
|
||||
return getOrCreateUserByPhone(phone, realName, avatar, email, openId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取应用访问令牌
|
||||
*/
|
||||
private String getAppAccessToken() {
|
||||
JSONObject requestBody = new JSONObject();
|
||||
requestBody.set("app_id", feishuConfig.getAppId());
|
||||
requestBody.set("app_secret", feishuConfig.getAppSecret());
|
||||
|
||||
try (HttpResponse response = HttpRequest.post(FEISHU_APP_TOKEN_URL)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(requestBody.toString())
|
||||
.execute()) {
|
||||
|
||||
String body = response.body();
|
||||
log.debug("获取应用访问令牌响应: {}", body);
|
||||
|
||||
JSONObject jsonObject = JSONUtil.parseObj(body);
|
||||
if (jsonObject.getInt("code") != 0) {
|
||||
throw new RuntimeException("获取应用访问令牌失败: " + jsonObject.getStr("msg"));
|
||||
}
|
||||
|
||||
return jsonObject.getJSONObject("app_access_token").getStr("app_access_token");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用授权码获取用户访问令牌
|
||||
*/
|
||||
private String getUserAccessToken(String code, String appAccessToken) {
|
||||
JSONObject requestBody = new JSONObject();
|
||||
requestBody.set("grant_type", "authorization_code");
|
||||
requestBody.set("code", code);
|
||||
|
||||
try (HttpResponse response = HttpRequest.post(FEISHU_TOKEN_URL)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Authorization", "Bearer " + appAccessToken)
|
||||
.body(requestBody.toString())
|
||||
.execute()) {
|
||||
|
||||
String body = response.body();
|
||||
log.debug("获取用户访问令牌响应: {}", body);
|
||||
|
||||
JSONObject jsonObject = JSONUtil.parseObj(body);
|
||||
if (jsonObject.getInt("code") != 0) {
|
||||
throw new RuntimeException("获取用户访问令牌失败: " + jsonObject.getStr("msg"));
|
||||
}
|
||||
|
||||
return jsonObject.getJSONObject("data").getStr("access_token");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户信息
|
||||
*/
|
||||
private JSONObject getUserInfo(String userAccessToken) {
|
||||
try (HttpResponse response = HttpRequest.get(FEISHU_USER_INFO_URL)
|
||||
.header("Authorization", "Bearer " + userAccessToken)
|
||||
.execute()) {
|
||||
|
||||
String body = response.body();
|
||||
log.debug("获取用户信息响应: {}", body);
|
||||
|
||||
JSONObject jsonObject = JSONUtil.parseObj(body);
|
||||
if (jsonObject.getInt("code") != 0) {
|
||||
throw new RuntimeException("获取用户信息失败: " + jsonObject.getStr("msg"));
|
||||
}
|
||||
|
||||
return jsonObject.getJSONObject("data");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public SysUser getOrCreateUserByPhone(String phone, String realName, String avatar, String email, String openId) {
|
||||
// 根据手机号查询用户
|
||||
LambdaQueryWrapper<SysUser> queryWrapper = new LambdaQueryWrapper<>();
|
||||
queryWrapper.eq(SysUser::getPhone, phone);
|
||||
SysUser user = sysUserMapper.selectOne(queryWrapper);
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
|
||||
if (user != null) {
|
||||
// 用户已存在,更新信息
|
||||
log.info("用户已存在,更新用户信息, phone: {}", phone);
|
||||
user.setRealName(realName);
|
||||
user.setAvatar(avatar);
|
||||
user.setEmail(email);
|
||||
user.setNickname(realName);
|
||||
user.setLastLoginTime(now);
|
||||
user.setUpdateTime(now);
|
||||
// 使用openId作为用户名(如果没有设置过)
|
||||
if (user.getUsername() == null || user.getUsername().isEmpty()) {
|
||||
user.setUsername(openId);
|
||||
}
|
||||
sysUserMapper.updateById(user);
|
||||
} else {
|
||||
// 用户不存在,创建新用户
|
||||
log.info("用户不存在,创建新用户, phone: {}", phone);
|
||||
user = new SysUser();
|
||||
user.setUsername(openId);
|
||||
user.setRealName(realName);
|
||||
user.setNickname(realName);
|
||||
user.setAvatar(avatar);
|
||||
user.setPhone(phone);
|
||||
user.setEmail(email);
|
||||
user.setStatus(1); // 正常状态
|
||||
user.setLastLoginTime(now);
|
||||
user.setCreateTime(now);
|
||||
user.setUpdateTime(now);
|
||||
user.setDeleted(0);
|
||||
// 飞书登录用户不需要密码,设置一个随机密码
|
||||
user.setPassword("FEISHU_OAUTH_USER");
|
||||
sysUserMapper.insert(user);
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,12 @@ mybatis-plus:
|
||||
logic-delete-value: 1
|
||||
logic-not-delete-value: 0
|
||||
|
||||
# 飞书开放平台配置
|
||||
feishu:
|
||||
app-id: cli_a94c8a7930badcd5
|
||||
app-secret: e6d39745a94b42099f014e9d6f3a25d0
|
||||
redirect-uri: http://localhost:8080/auth/feishu/callback
|
||||
|
||||
# 日志配置
|
||||
logging:
|
||||
level:
|
||||
|
||||
@@ -45,6 +45,14 @@
|
||||
AND deleted = 0
|
||||
</select>
|
||||
|
||||
<!-- 根据手机号查询用户 -->
|
||||
<select id="selectByPhone" resultMap="BaseResultMap">
|
||||
SELECT *
|
||||
FROM sys_user
|
||||
WHERE phone = #{phone}
|
||||
AND deleted = 0
|
||||
</select>
|
||||
|
||||
<!-- 分页查询用户列表(支持按部门、状态、关键字筛选) -->
|
||||
<select id="selectPageList" resultMap="BaseResultMap">
|
||||
SELECT u.*
|
||||
|
||||
Reference in New Issue
Block a user