From b9aacf0f10be58ee4b05d53598d2f73444c002d9 Mon Sep 17 00:00:00 2001 From: JiaoTianBo Date: Fri, 27 Mar 2026 16:36:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E5=AE=9E=E7=8E=B0=E9=A3=9E?= =?UTF-8?q?=E4=B9=A6OAuth=E8=AE=A4=E8=AF=81=E5=8F=8A=E7=9B=B8=E5=85=B3?= =?UTF-8?q?=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增飞书OAuth认证控制器,支持获取授权URL、登录和登录状态检查 - 实现飞书认证服务接口及实现类,完成授权码换取用户信息及用户创建或更新 - 配置Sa-Token拦截器和全局过滤器,实现登录鉴权和跨域支持 - 添加系统用户Mapper及对应XML,支持用户和角色查询及分页筛选 - 新增飞书开放平台配置类,支持应用ID、密钥和回调地址配置 - 添加开发环境配置文件,配置数据库、MinIO、MyBatis Plus、飞书和日志等相关参数 - 新增Maven项目依赖,包含Spring Boot、Sa-Token、MyBatis Plus、MinIO客户端、文档解析和飞书SDK等依赖 --- pom.xml | 7 + .../yinlihupo/common/config/FeishuConfig.java | 34 +++ .../common/config/SaTokenConfig.java | 2 + .../controller/auth/FeishuAuthController.java | 94 ++++++++ .../cn/yinlihupo/mapper/SysUserMapper.java | 5 + .../service/system/FeishuAuthService.java | 39 ++++ .../system/impl/FeishuAuthServiceImpl.java | 206 ++++++++++++++++++ src/main/resources/application-dev.yaml | 6 + src/main/resources/mapper/SysUserMapper.xml | 8 + 9 files changed, 401 insertions(+) create mode 100644 src/main/java/cn/yinlihupo/common/config/FeishuConfig.java create mode 100644 src/main/java/cn/yinlihupo/controller/auth/FeishuAuthController.java create mode 100644 src/main/java/cn/yinlihupo/service/system/FeishuAuthService.java create mode 100644 src/main/java/cn/yinlihupo/service/system/impl/FeishuAuthServiceImpl.java diff --git a/pom.xml b/pom.xml index ed2782f..11e5194 100644 --- a/pom.xml +++ b/pom.xml @@ -145,6 +145,13 @@ 1.39.0 + + + com.larksuite.oapi + larksuite-oapi + 2.3.1 + + org.springframework.boot spring-boot-starter-test diff --git a/src/main/java/cn/yinlihupo/common/config/FeishuConfig.java b/src/main/java/cn/yinlihupo/common/config/FeishuConfig.java new file mode 100644 index 0000000..0ac892e --- /dev/null +++ b/src/main/java/cn/yinlihupo/common/config/FeishuConfig.java @@ -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"; +} diff --git a/src/main/java/cn/yinlihupo/common/config/SaTokenConfig.java b/src/main/java/cn/yinlihupo/common/config/SaTokenConfig.java index f0b712b..b3a038a 100644 --- a/src/main/java/cn/yinlihupo/common/config/SaTokenConfig.java +++ b/src/main/java/cn/yinlihupo/common/config/SaTokenConfig.java @@ -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/**" diff --git a/src/main/java/cn/yinlihupo/controller/auth/FeishuAuthController.java b/src/main/java/cn/yinlihupo/controller/auth/FeishuAuthController.java new file mode 100644 index 0000000..c69578e --- /dev/null +++ b/src/main/java/cn/yinlihupo/controller/auth/FeishuAuthController.java @@ -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> getAuthUrl(@RequestParam(required = false) String state) { + String authUrl = feishuAuthService.buildAuthUrl(state); + Map result = new HashMap<>(); + result.put("authUrl", authUrl); + return ResultUtils.success("获取授权URL成功",result); + } + + /** + * 飞书OAuth登录接口(前端回调后调用) + * 前端从飞书回调中获取code,然后调用此接口完成登录 + * + * @param code 飞书授权码 + * @return 登录结果(包含token和用户信息) + */ + @PostMapping("/login") + public BaseResponse> login(@RequestParam String code) { + log.info("收到飞书登录请求, code: {}", code); + + try { + // 使用code换取用户信息并完成登录 + SysUser user = feishuAuthService.loginByCode(code); + + // 使用Sa-Token登录 + StpUtil.login(user.getId()); + + // 构建返回数据 + Map 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> checkLogin() { + boolean isLogin = StpUtil.isLogin(); + Map result = new HashMap<>(); + result.put("isLogin", isLogin); + + if (isLogin) { + result.put("userId", StpUtil.getLoginId()); + } + + return ResultUtils.success(isLogin ? "已登录" : "未登录", result); + } +} diff --git a/src/main/java/cn/yinlihupo/mapper/SysUserMapper.java b/src/main/java/cn/yinlihupo/mapper/SysUserMapper.java index 806245d..8ff4eaa 100644 --- a/src/main/java/cn/yinlihupo/mapper/SysUserMapper.java +++ b/src/main/java/cn/yinlihupo/mapper/SysUserMapper.java @@ -23,6 +23,11 @@ public interface SysUserMapper extends BaseMapper { */ SysUser selectByUsername(@Param("username") String username); + /** + * 根据手机号查询用户 + */ + SysUser selectByPhone(@Param("phone") String phone); + /** * 分页查询用户列表(支持按部门、状态、关键字筛选) */ diff --git a/src/main/java/cn/yinlihupo/service/system/FeishuAuthService.java b/src/main/java/cn/yinlihupo/service/system/FeishuAuthService.java new file mode 100644 index 0000000..d5ca166 --- /dev/null +++ b/src/main/java/cn/yinlihupo/service/system/FeishuAuthService.java @@ -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); +} diff --git a/src/main/java/cn/yinlihupo/service/system/impl/FeishuAuthServiceImpl.java b/src/main/java/cn/yinlihupo/service/system/impl/FeishuAuthServiceImpl.java new file mode 100644 index 0000000..ad852ad --- /dev/null +++ b/src/main/java/cn/yinlihupo/service/system/impl/FeishuAuthServiceImpl.java @@ -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 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; + } +} diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml index 228ab06..539f238 100644 --- a/src/main/resources/application-dev.yaml +++ b/src/main/resources/application-dev.yaml @@ -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: diff --git a/src/main/resources/mapper/SysUserMapper.xml b/src/main/resources/mapper/SysUserMapper.xml index aa8dd33..ad58c26 100644 --- a/src/main/resources/mapper/SysUserMapper.xml +++ b/src/main/resources/mapper/SysUserMapper.xml @@ -45,6 +45,14 @@ AND deleted = 0 + + +