diff --git a/db/0pigxx_boot.sql b/db/0pigxx_boot.sql index 7b8e7c472..cf4d5c314 100644 --- a/db/0pigxx_boot.sql +++ b/db/0pigxx_boot.sql @@ -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; diff --git a/db/2pigxx.sql b/db/2pigxx.sql index 4004f39cf..83eda17ee 100644 --- a/db/2pigxx.sql +++ b/db/2pigxx.sql @@ -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; diff --git a/pigx-common/pigx-common-core/src/main/java/com/pig4cloud/pigx/common/core/constant/enums/LoginTypeEnum.java b/pigx-common/pigx-common-core/src/main/java/com/pig4cloud/pigx/common/core/constant/enums/LoginTypeEnum.java index 7c953dc0a..a206fe9bb 100644 --- a/pigx-common/pigx-common-core/src/main/java/com/pig4cloud/pigx/common/core/constant/enums/LoginTypeEnum.java +++ b/pigx-common/pigx-common-core/src/main/java/com/pig4cloud/pigx/common/core/constant/enums/LoginTypeEnum.java @@ -60,6 +60,9 @@ public enum LoginTypeEnum { */ DINGTALK("DINGTALK", "钉钉"), + /** 企业飞书 */ + FEISHU("FEISHU", "企业飞书"), + /** * 企业微信 */ diff --git a/pigx-upms/FEISHU-LOGIN.md b/pigx-upms/FEISHU-LOGIN.md new file mode 100644 index 000000000..db0298fcf --- /dev/null +++ b/pigx-upms/FEISHU-LOGIN.md @@ -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) diff --git a/pigx-upms/pigx-upms-api/src/main/java/com/pig4cloud/pigx/admin/api/dto/UserDTO.java b/pigx-upms/pigx-upms-api/src/main/java/com/pig4cloud/pigx/admin/api/dto/UserDTO.java index f6d4c4e34..954532bd5 100644 --- a/pigx-upms/pigx-upms-api/src/main/java/com/pig4cloud/pigx/admin/api/dto/UserDTO.java +++ b/pigx-upms/pigx-upms-api/src/main/java/com/pig4cloud/pigx/admin/api/dto/UserDTO.java @@ -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 role; + /** + * QQ openid + */ + @Schema(description = "QQ openid") + private String qqOpenid; - /** - * 部门id - */ - @Schema(description = "部门id") - private Long deptId; + /** + * 角色ID + */ + @Schema(description = "角色id集合") + private List role; - /** - * 部门id集合(支持多部门) - */ - @Schema(description = "部门id集合") - private List deptIds; + /** + * 部门id + */ + @Schema(description = "部门id") + private Long deptId; - /** - * 岗位ID - */ - private List post; + /** + * 部门id集合(支持多部门) + */ + @Schema(description = "部门id集合") + private List deptIds; - /** - * 新密码 - */ - @Schema(description = "新密码") - private String newpassword1; + /** + * 岗位ID + */ + private List post; - /** - * 验证码 - */ - @Schema(description = "验证码") - private String code; + /** + * 新密码 + */ + @Schema(description = "新密码") + private String newpassword1; + + /** + * 验证码 + */ + @Schema(description = "验证码") + private String code; } diff --git a/pigx-upms/pigx-upms-api/src/main/java/com/pig4cloud/pigx/admin/api/vo/SysSocialDetailsVO.java b/pigx-upms/pigx-upms-api/src/main/java/com/pig4cloud/pigx/admin/api/vo/SysSocialDetailsVO.java index 8f517d825..4e675c171 100644 --- a/pigx-upms/pigx-upms-api/src/main/java/com/pig4cloud/pigx/admin/api/vo/SysSocialDetailsVO.java +++ b/pigx-upms/pigx-upms-api/src/main/java/com/pig4cloud/pigx/admin/api/vo/SysSocialDetailsVO.java @@ -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; } diff --git a/pigx-upms/pigx-upms-biz/src/main/java/com/pig4cloud/pigx/admin/handler/FeishuLoginHandler.java b/pigx-upms/pigx-upms-biz/src/main/java/com/pig4cloud/pigx/admin/handler/FeishuLoginHandler.java new file mode 100644 index 000000000..b19b0e19d --- /dev/null +++ b/pigx-upms/pigx-upms-biz/src/main/java/com/pig4cloud/pigx/admin/handler/FeishuLoginHandler.java @@ -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.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.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; + } + +} diff --git a/pigx-upms/pigx-upms-biz/src/main/java/com/pig4cloud/pigx/admin/service/impl/SysUserServiceImpl.java b/pigx-upms/pigx-upms-biz/src/main/java/com/pig4cloud/pigx/admin/service/impl/SysUserServiceImpl.java index 6e425abaf..3c8ae0366 100644 --- a/pigx-upms/pigx-upms-biz/src/main/java/com/pig4cloud/pigx/admin/service/impl/SysUserServiceImpl.java +++ b/pigx-upms/pigx-upms-biz/src/main/java/com/pig4cloud/pigx/admin/service/impl/SysUserServiceImpl.java @@ -84,7 +84,7 @@ public class SysUserServiceImpl extends ServiceImpl impl private static final PasswordEncoder ENCODER = new BCryptPasswordEncoder(); private static final Set 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; diff --git a/pigx-upms/pigx-upms-biz/src/main/resources/mapper/SysUserMapper.xml b/pigx-upms/pigx-upms-biz/src/main/resources/mapper/SysUserMapper.xml index 4bcd94655..8978e87e1 100644 --- a/pigx-upms/pigx-upms-biz/src/main/resources/mapper/SysUserMapper.xml +++ b/pigx-upms/pigx-upms-biz/src/main/resources/mapper/SysUserMapper.xml @@ -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} + + 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} + 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} diff --git a/pigx-upms/pigx-upms-biz/src/test/java/com/pig4cloud/pigx/admin/handler/FeishuLoginHandlerTests.java b/pigx-upms/pigx-upms-biz/src/test/java/com/pig4cloud/pigx/admin/handler/FeishuLoginHandlerTests.java new file mode 100644 index 000000000..93245bc34 --- /dev/null +++ b/pigx-upms/pigx-upms-biz/src/test/java/com/pig4cloud/pigx/admin/handler/FeishuLoginHandlerTests.java @@ -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 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(); + } + +}