feat(pigx-upms-biz): 增加飞书用户身份绑定功能

- 在 SysUserMapper.xml 中添加对飞书用户的查询支持
- 新增 FeishuLoginHandlerTests 测试类,验证飞书登录处理逻辑
- 测试覆盖飞书身份的代码交换和用户信息查询功能

(cherry picked from commit 2a0796cce45efd4e0444b0e2a6ef0a9e530dd226)
This commit is contained in:
PIGCLOUD committed 2026-09-22 21:52:04 +08:00
1 parent 75ec99389c
commit 1dc58c60cf
10 files changed
+324 -70

No files matched your search

+1
View File
@@ -339,6 +339,7 @@ INSERT INTO `sys_dict_item` VALUES (95, 29, '0', '黑名单', 'sensitive_type',
INSERT INTO `sys_dict_item` VALUES (96, 29, '1', '白名单', 'sensitive_type', '失败', NULL, 3, 'admin', ' ', '2023-10-27 10:30:47', NULL, NULL, '0', 1);
INSERT INTO `sys_dict_item` VALUES (97,2,'MINI','小程序登录','social_type','小程序登录',NULL,3,' ',' ','2022-02-18 13:56:25','2022-02-18 13:56:28',NULL,'0',1);
INSERT INTO `sys_dict_item` VALUES (98,2,'clarity','clarity','social_type','clarity',NULL,3,' ',' ','2022-02-18 13:56:25','2022-02-18 13:56:28',NULL,'0',1);
INSERT INTO `sys_dict_item` VALUES (100,2,'FEISHU','企业飞书','social_type','企业飞书扫码登录',NULL,5,' ',' ','2026-09-18 00:00:00','2026-09-18 00:00:00',NULL,'0',1);
INSERT INTO `sys_dict_item` VALUES (99,2,'umami','umami','social_type','umami',NULL,4,' ',' ','2026-06-23 00:00:00','2026-06-23 00:00:00',NULL,'0',1);
COMMIT;
+1
View File
@@ -320,6 +320,7 @@ INSERT INTO `sys_dict_item` VALUES (95, 29, '0', '黑名单', 'sensitive_type',
INSERT INTO `sys_dict_item` VALUES (96, 29, '1', '白名单', 'sensitive_type', '失败', NULL, 3, 'admin', ' ', '2023-10-27 10:30:47', NULL, NULL, '0', 1);
INSERT INTO `sys_dict_item` VALUES (97,2,'MINI','小程序登录','social_type','小程序登录',NULL,3,' ',' ','2022-02-18 13:56:25','2022-02-18 13:56:28',NULL,'0',1);
INSERT INTO `sys_dict_item` VALUES (98,2,'clarity','clarity','social_type','clarity',NULL,3,' ',' ','2022-02-18 13:56:25','2022-02-18 13:56:28',NULL,'0',1);
INSERT INTO `sys_dict_item` VALUES (100,2,'FEISHU','企业飞书','social_type','企业飞书扫码登录',NULL,5,' ',' ','2026-09-18 00:00:00','2026-09-18 00:00:00',NULL,'0',1);
INSERT INTO `sys_dict_item` VALUES (99,2,'umami','umami','social_type','umami',NULL,4,' ',' ','2026-06-23 00:00:00','2026-06-23 00:00:00',NULL,'0',1);
COMMIT;
@@ -60,6 +60,9 @@ public enum LoginTypeEnum {
*/
DINGTALK("DINGTALK", "钉钉"),
/** 企业飞书 */
FEISHU("FEISHU", "企业飞书"),
/**
* 企业微信
*/
+28
View File
@@ -0,0 +1,28 @@
# 企业飞书扫码登录
使用企业自建飞书应用,通过官方授权页面扫码,UPMS 使用 OAuth v3 换取用户凭证,再用 `open_id` 查询 `sys_user_social` 中已绑定的系统账号。
## 配置
1. 存量系统在 UPMS 数据库执行 `db/upgrade_feishu_login.sql`,刷新 `social_type` 字典缓存。全新系统的 `0pigxx_boot.sql`、`2pigxx.sql` 已包含 `FEISHU` 字典项。
2. 在飞书开发者后台创建并启用企业自建应用,发布版本,确保使用者位于应用可用范围。
3. 在飞书应用安全设置中登记回调地址,例如 `https://admin.example.com/#/authredirect`。
4. 在管理端的第三方应用配置中选择「企业飞书」,填写 App ID、App Secret、同一个完整回调地址。回调地址必须与当前管理端同源,hash 为 `#/authredirect`,不携带查询参数;扩展字段留空。
5. 用户先以现有方式登录系统,在个人中心绑定「企业飞书」,完成扫码授权。之后可在登录页选择「企业飞书」扫码登录,也可在个人中心解绑。
前后端必须使用完全相同的回调地址。飞书会把 `code` 和 `state` 放在 `#` 前面,现有回调组件同时支持 query 和 hash 参数。飞书授权状态有效期为五分钟且只能消费一次,拒绝授权或过期后需要重新发起。
未绑定身份不会自动注册,也不会按手机号或邮箱匹配系统账号。应用仅获取基础身份,不申请手机号、邮箱、通讯录或离线访问权限。App Secret 仅由后端使用,不出现在公开应用列表中。更换 App ID 后,原应用的 `open_id` 绑定不能直接复用。
## 验收
- 已绑定用户扫码后成功登录,权限与原系统账号一致。
- 未绑定用户不能登录;个人中心绑定后显示已绑定,解绑后不能再用该身份登录。
- 拒绝授权、无效或过期的 code/state 不得登录或写入绑定。
- 本地自动化只验证协议和查询路径;真实扫码需要部署配置完成后的飞书企业应用与账号。
## 官方接口
- [获取授权码](https://open.feishu.cn/document/authentication-management/access-token/obtain-oauth-code)
- [获取 user_access_token(v3)](https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/authentication-management/access-token/get-user-access-token-v3)
- [获取用户信息](https://open.feishu.cn/document/server-docs/authentication-management/login-state-management/get)
@@ -35,69 +35,72 @@ import java.util.List;
@EqualsAndHashCode(callSuper = true)
public class UserDTO extends SysUser {
/**
* 微信 openid
*/
@Schema(description = "微信openid")
private String wxOpenid;
/**
* 微信 openid
*/
@Schema(description = "微信openid")
private String wxOpenid;
/**
* 企业微信 userid
*/
@Schema(description = "企业微信userid")
private String wxCpUserid;
/**
* 企业微信 userid
*/
@Schema(description = "企业微信userid")
private String wxCpUserid;
/**
* 钉钉 openid
*/
@Schema(description = "钉钉openid")
private String wxDingUserid;
/**
* 钉钉 openid
*/
@Schema(description = "钉钉openid")
private String wxDingUserid;
/**
* 微信小程序 openid
*/
@Schema(description = "微信小程序openid")
private String miniOpenid;
@Schema(description = "飞书open_id")
private String feishuOpenid;
/**
* QQ openid
*/
@Schema(description = "QQ openid")
private String qqOpenid;
/**
* 微信小程序 openid
*/
@Schema(description = "微信小程序openid")
private String miniOpenid;
/**
* 角色ID
*/
@Schema(description = "角色id集合")
private List<Long> role;
/**
* QQ openid
*/
@Schema(description = "QQ openid")
private String qqOpenid;
/**
* 部门id
*/
@Schema(description = "部门id")
private Long deptId;
/**
* 角色ID
*/
@Schema(description = "角色id集合")
private List<Long> role;
/**
* 部门id集合(支持多部门)
*/
@Schema(description = "部门id集合")
private List<Long> deptIds;
/**
* 部门id
*/
@Schema(description = "部门id")
private Long deptId;
/**
* 岗位ID
*/
private List<Long> post;
/**
* 部门id集合(支持多部门)
*/
@Schema(description = "部门id集合")
private List<Long> deptIds;
/**
* 新密码
*/
@Schema(description = "新密码")
private String newpassword1;
/**
* 岗位ID
*/
private List<Long> post;
/**
* 验证码
*/
@Schema(description = "验证码")
private String code;
/**
* 新密码
*/
@Schema(description = "新密码")
private String newpassword1;
/**
* 验证码
*/
@Schema(description = "验证码")
private String code;
}
@@ -12,22 +12,25 @@ import lombok.Data;
@Data
public class SysSocialDetailsVO {
/**
* 账号
*/
@Schema(description = "appId")
private String appId;
/**
* 账号
*/
@Schema(description = "appId")
private String appId;
/**
* 类型
*/
@Schema(description = "账号类型")
private String type;
/**
* 类型
*/
@Schema(description = "账号类型")
private String type;
/**
* 扩展字段
*/
@Schema(description = "扩展字段")
private String ext;
/**
* 扩展字段
*/
@Schema(description = "扩展字段")
private String ext;
@Schema(description = "授权回调地址")
private String redirectUrl;
}
@@ -0,0 +1,130 @@
package com.pig4cloud.pigx.admin.handler;
import cn.hutool.core.util.StrUtil;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.pig4cloud.pigx.admin.api.dto.UserDTO;
import com.pig4cloud.pigx.admin.api.dto.UserInfo;
import com.pig4cloud.pigx.admin.api.entity.SysSocialDetails;
import com.pig4cloud.pigx.admin.api.entity.SysUser;
import com.pig4cloud.pigx.admin.api.entity.SysUserSocial;
import com.pig4cloud.pigx.admin.mapper.SysSocialDetailsMapper;
import com.pig4cloud.pigx.admin.mapper.SysUserSocialMapper;
import com.pig4cloud.pigx.admin.service.SysUserService;
import com.pig4cloud.pigx.common.core.constant.enums.LoginTypeEnum;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
/**
* 企业自建飞书应用登录处理器。使用 UPMS 配置交换授权码,以应用内 open_id 查询或绑定已有系统账号;不按手机号匹配,也不自动注册用户。
*/
@Component("FEISHU")
@RequiredArgsConstructor
public class FeishuLoginHandler extends AbstractLoginHandler {
private final SysUserService sysUserService;
private final SysSocialDetailsMapper sysSocialDetailsMapper;
private final SysUserSocialMapper sysUserSocialMapper;
/**
* 使用配置中的应用凭证和回调地址交换授权码,再向飞书查询用户身份。
* @param code 飞书回调的一次性授权码,不可为空
* @return 飞书应用内非空的 open_id
* @throws IllegalArgumentException 授权码为空
* @throws IllegalStateException 应用配置不完整、飞书返回失败或缺少访问凭证、用户身份
* @throws cn.hutool.core.io.IORuntimeException 网络请求失败或超时
* @throws cn.hutool.json.JSONException 飞书响应无法解析为 JSON
*/
@Override
public String identify(String code) {
if (StrUtil.isBlank(code)) {
throw new IllegalArgumentException("飞书授权码不能为空");
}
SysSocialDetails details = sysSocialDetailsMapper.selectOne(
Wrappers.<SysSocialDetails>lambdaQuery().eq(SysSocialDetails::getType, LoginTypeEnum.FEISHU.getType()));
if (details == null || StrUtil.hasBlank(details.getAppId(), details.getAppSecret(), details.getRedirectUrl())) {
throw new IllegalStateException("请先配置企业飞书 App ID、App Secret 和回调地址");
}
JSONObject token = request(HttpRequest.post("https://accounts.feishu.cn/oauth/v3/token")
.form("grant_type", "authorization_code")
.form("client_id", details.getAppId())
.form("client_secret", details.getAppSecret())
.form("code", code)
.form("redirect_uri", details.getRedirectUrl()));
String accessToken = token.getStr("access_token");
if (StrUtil.isBlank(accessToken)) {
throw new IllegalStateException("飞书未返回有效访问凭证,请重新扫码");
}
JSONObject user = request(HttpRequest.get("https://open.feishu.cn/open-apis/authen/v1/user_info")
.header("Authorization", "Bearer " + accessToken)).getJSONObject("data");
String openId = user == null ? null : user.getStr("open_id");
if (StrUtil.isBlank(openId)) {
throw new IllegalStateException("飞书未返回用户身份,请重新扫码");
}
return openId;
}
// 统一限制请求时长并关闭响应;错误信息不包含访问凭证或完整用户信息。
JSONObject request(HttpRequest request) {
try (HttpResponse response = request.timeout(10000).execute()) {
if (!response.isOk()) {
throw new IllegalStateException("飞书授权请求失败,请检查应用配置或重新扫码");
}
JSONObject body = JSONUtil.parseObj(response.body());
if (body.getInt("code", -1) != 0) {
// 不记录完整响应,避免泄露访问凭证或用户信息。
throw new IllegalStateException("飞书授权失败,错误码:" + body.getInt("code", -1));
}
return body;
}
}
/**
* 按 FEISHU 类型和 open_id 查询已绑定账号,由用户服务组装角色与权限。
* @param openId 飞书应用内的用户标识,允许为空
* @return 已绑定的系统用户信息;标识为空或未匹配到有效用户时返回 null
*/
@Override
public UserInfo info(String openId) {
if (StrUtil.isBlank(openId)) {
return null;
}
UserDTO query = new UserDTO();
query.setFeishuOpenid(openId);
return sysUserService.getUserInfo(query).getData();
}
/**
* 将已验证的飞书身份绑定到系统用户,沿用社交身份表的唯一约束。 同一用户已有飞书绑定时更新 identifier,否则新增;不创建系统用户。
* @param user 待绑定的系统用户,不可为 null,且 userId 必须有效
* @param identify 经 identify 验证的非空飞书 open_id
* @return 插入或更新影响行数大于零时为 true,否则为 false
* @throws IllegalArgumentException 飞书身份为空
* @throws org.springframework.dao.DuplicateKeyException 该飞书身份已绑定其他用户
*/
@Override
public Boolean bind(SysUser user, String identify) {
if (StrUtil.isBlank(identify)) {
throw new IllegalArgumentException("飞书用户身份不能为空");
}
SysUserSocial existing = sysUserSocialMapper.selectOne(Wrappers.<SysUserSocial>lambdaQuery()
.eq(SysUserSocial::getUserId, user.getUserId())
.eq(SysUserSocial::getType, LoginTypeEnum.FEISHU.getType()));
if (existing != null) {
existing.setIdentifier(identify);
return sysUserSocialMapper.updateById(existing) > 0;
}
SysUserSocial social = new SysUserSocial();
social.setUserId(user.getUserId());
social.setType(LoginTypeEnum.FEISHU.getType());
social.setIdentifier(identify);
return sysUserSocialMapper.insert(social) > 0;
}
}
@@ -84,7 +84,7 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> impl
private static final PasswordEncoder ENCODER = new BCryptPasswordEncoder();
private static final Set<String> UNBIND_TYPES = Set.of(LoginTypeEnum.WECHAT.getType(),
LoginTypeEnum.WEIXIN_CP.getType(), LoginTypeEnum.DINGTALK.getType());
LoginTypeEnum.WEIXIN_CP.getType(), LoginTypeEnum.DINGTALK.getType(), LoginTypeEnum.FEISHU.getType());
private final SysMenuService sysMenuService;
@@ -68,6 +68,10 @@
INNER JOIN sys_user_social dingtalk_social ON dingtalk_social.user_id = u.user_id
AND dingtalk_social.type = 'DINGTALK' AND dingtalk_social.identifier = #{query.wxDingUserid}
</if>
<if test="query.feishuOpenid != null and query.feishuOpenid != ''">
INNER JOIN sys_user_social feishu_social ON feishu_social.user_id = u.user_id
AND feishu_social.type = 'FEISHU' AND feishu_social.identifier = #{query.feishuOpenid}
</if>
<if test="query.wxOpenid != null and query.wxOpenid != ''">
INNER JOIN sys_user_social wx_social ON wx_social.user_id = u.user_id
AND wx_social.type = 'WX' AND wx_social.identifier = #{query.wxOpenid}
@@ -0,0 +1,81 @@
package com.pig4cloud.pigx.admin.handler;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.pig4cloud.pigx.admin.api.dto.UserDTO;
import com.pig4cloud.pigx.admin.api.dto.UserInfo;
import com.pig4cloud.pigx.admin.api.entity.SysSocialDetails;
import com.pig4cloud.pigx.admin.mapper.SysSocialDetailsMapper;
import com.pig4cloud.pigx.admin.mapper.SysUserSocialMapper;
import com.pig4cloud.pigx.admin.service.SysUserService;
import com.pig4cloud.pigx.common.core.util.R;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
class FeishuLoginHandlerTests {
private final SysSocialDetailsMapper configMapper = mock(SysSocialDetailsMapper.class);
private final SysUserService users = mock(SysUserService.class);
private final SysUserSocialMapper bindings = mock(SysUserSocialMapper.class);
private FeishuLoginHandler handler() {
SysSocialDetails config = new SysSocialDetails();
config.setAppId("cli_test");
config.setAppSecret("test-secret");
config.setRedirectUrl("https://example.com/#/authredirect");
when(configMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(config);
return spy(new FeishuLoginHandler(users, configMapper, bindings));
}
@Test
void exchangesCodeAndLooksUpBoundFeishuIdentity() {
FeishuLoginHandler handler = handler();
UserInfo expected = new UserInfo();
when(users.getUserInfo(any(UserDTO.class))).thenReturn(R.ok(expected));
doAnswer(invocation -> {
HttpRequest request = invocation.getArgument(0);
if (request.getUrl().equals("https://accounts.feishu.cn/oauth/v3/token")) {
assertThat(request.form()).containsEntry("grant_type", "authorization_code")
.containsEntry("client_id", "cli_test")
.containsEntry("client_secret", "test-secret")
.containsEntry("code", "test-code")
.containsEntry("redirect_uri", "https://example.com/#/authredirect");
return JSONUtil.parseObj("{\"code\":0,\"access_token\":\"test-token\"}");
}
assertThat(request.getUrl()).isEqualTo("https://open.feishu.cn/open-apis/authen/v1/user_info");
assertThat(request.header("Authorization")).isEqualTo("Bearer test-token");
return JSONUtil.parseObj("{\"code\":0,\"data\":{\"open_id\":\"ou_test\"}}");
}).when(handler).request(any(HttpRequest.class));
assertThat(handler.handle("test-code")).isSameAs(expected);
verify(handler, times(2)).request(any(HttpRequest.class));
ArgumentCaptor<UserDTO> query = ArgumentCaptor.forClass(UserDTO.class);
verify(users).getUserInfo(query.capture());
assertThat(query.getValue().getFeishuOpenid()).isEqualTo("ou_test");
}
@Test
void rejectedAuthorizationCannotQueryOrBindUser() {
FeishuLoginHandler handler = handler();
HttpRequest request = mock(HttpRequest.class, RETURNS_SELF);
HttpResponse response = mock(HttpResponse.class);
when(request.execute()).thenReturn(response);
when(response.isOk()).thenReturn(true);
when(response.body()).thenReturn("{\"code\":20004}");
FeishuLoginHandler transport = new FeishuLoginHandler(users, configMapper, bindings);
doAnswer(invocation -> transport.request(request)).when(handler).request(any(HttpRequest.class));
assertThatThrownBy(() -> handler.handle("expired-code")).isInstanceOf(IllegalStateException.class)
.hasMessageContaining("20004");
verifyNoInteractions(users, bindings);
verify(response).close();
}
}