From cbbb048a4dbe49dfab676def75fd63a582daba80 Mon Sep 17 00:00:00 2001 From: lzh Date: Fri, 24 Apr 2026 13:32:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(system):=20=E6=8C=89=20OAuth2=20=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=AB=AF=20platform=20=E8=BF=87=E6=BB=A4=E8=8F=9C?= =?UTF-8?q?=E5=8D=95=EF=BC=8C=E6=94=AF=E6=8C=81=E4=B8=9A=E5=8A=A1/?= =?UTF-8?q?=E7=89=A9=E8=81=94=E5=8F=8C=E5=89=8D=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:业务平台(biz)和物联运维平台(iot)共用一套用户体系和菜单表,但每个前端 只该看到自己域的菜单。原来没有按客户端过滤的机制。 方案:在 OAuth2 客户端维度打 platform 标签(biz/iot/NULL),菜单也打同样标签, 登录时下发菜单按二者匹配过滤。 链路: - OAuth2AccessTokenCheckRespDTO / LoginUser(framework + gateway)新增 clientId 字段 - TokenAuthenticationFilter(framework + gateway)把 accessToken.clientId 带进 LoginUser - WebFrameworkUtils.HEADER_CLIENT_ID="X-Client-Id":登录/refresh 等"无 token 入口" 允许前端声明 client,避免硬编码 default - AdminAuthServiceImpl.resolveClientId:未传 Header 时回退 OAuth2ClientConstants.CLIENT_ID_DEFAULT - MenuDO / OAuth2ClientDO 各加 platform 列 - MenuService.filterMenusByPlatform:platform 为空(全平台共用)或匹配即保留 SQL 迁移按字母序编号: - _01_oauth2_client_platform.sql:加列 + 给 default/iot-client 客户端打标 + 递归标 IoT 菜单子树(root id=4000)为 iot - _02_bulk_mark_biz_menus.sql:其余 platform=NULL 的菜单兜底标 biz - 顺序依赖:_01 标完 iot 后 _02 才动剩余 NULL,避免 _02 把 IoT 菜单错标 biz Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-04-20_01_oauth2_client_platform.sql | 51 ++ .../2026-04-20_02_bulk_mark_biz_menus.sql | 18 + .../dto/OAuth2AccessTokenCheckRespDTO.java | 3 + .../framework/security/core/LoginUser.java | 8 + .../filter/TokenAuthenticationFilter.java | 3 +- .../web/core/util/WebFrameworkUtils.java | 415 +++++----- .../gateway/filter/security/LoginUser.java | 4 + .../security/TokenAuthenticationFilter.java | 3 +- .../admin/permission/vo/menu/MenuRespVO.java | 3 + .../admin/permission/vo/menu/MenuSaveVO.java | 3 + .../dal/dataobject/oauth2/OAuth2ClientDO.java | 7 + .../dal/dataobject/permission/MenuDO.java | 7 + .../service/auth/AdminAuthServiceImpl.java | 740 +++++++++--------- .../service/permission/MenuService.java | 9 + .../service/permission/MenuServiceImpl.java | 14 + 15 files changed, 729 insertions(+), 559 deletions(-) create mode 100644 sql/mysql/migrations/2026-04-20_01_oauth2_client_platform.sql create mode 100644 sql/mysql/migrations/2026-04-20_02_bulk_mark_biz_menus.sql diff --git a/sql/mysql/migrations/2026-04-20_01_oauth2_client_platform.sql b/sql/mysql/migrations/2026-04-20_01_oauth2_client_platform.sql new file mode 100644 index 00000000..aae289fa --- /dev/null +++ b/sql/mysql/migrations/2026-04-20_01_oauth2_client_platform.sql @@ -0,0 +1,51 @@ +-- ============================================================ +-- 多前端按 client → platform 过滤菜单 +-- 配合后端代码:AuthController.getPermissionInfo / MenuService.filterMenusByPlatform +-- 日期:2026-04-20 +-- +-- platform 取值约定: +-- biz = 业务平台(对应 OAuth2 客户端 default) +-- iot = 物联运维平台(对应 OAuth2 客户端 iot-client) +-- NULL = 两个平台都展示(通用菜单,如系统管理、用户、部门等) +-- ============================================================ + +-- 1. system_oauth2_client 加 platform 列 +ALTER TABLE `system_oauth2_client` + ADD COLUMN `platform` VARCHAR(10) NULL DEFAULT NULL + COMMENT '平台标识:biz-业务平台,iot-物联运维平台,NULL-不按客户端过滤菜单' + AFTER `additional_information`; + +-- 2. 矫正 system_menu.platform 列的注释(旧注释写的是 ops/sys,与代码约定不一致,更新为 biz/iot) +ALTER TABLE `system_menu` + MODIFY COLUMN `platform` VARCHAR(10) NULL DEFAULT NULL + COMMENT '平台标识:biz-业务平台,iot-物联运维平台,NULL-两个平台都展示'; + +-- 3. 给两个内部 SSO 客户端打 platform 标 +-- 业务平台复用 yudao 默认 default 客户端 → biz +-- 物联运维平台用独立 iot-client → iot +UPDATE `system_oauth2_client` SET `platform` = 'biz' WHERE `client_id` = 'default'; +UPDATE `system_oauth2_client` SET `platform` = 'iot' WHERE `client_id` = 'iot-client'; + +-- 4. 给 IoT 模块的菜单打 iot 标记。从 sql/mysql/system_menu.sql 看, +-- IoT 设备接入 root id=4000,整个子树都属于 iot 平台。 +UPDATE `system_menu` SET `platform` = 'iot' +WHERE `id` IN ( + SELECT t.id FROM ( + -- 递归取 4000 子树。MySQL 8 用 CTE,旧版自行替换为多次 UPDATE + WITH RECURSIVE iot_tree(id) AS ( + SELECT id FROM system_menu WHERE id = 4000 + UNION ALL + SELECT m.id FROM system_menu m JOIN iot_tree it ON m.parent_id = it.id + ) + SELECT id FROM iot_tree + ) t +); + +-- 5. 业务平台独有菜单标 biz(可选;不标的话默认 NULL = 两边都显示) +-- 例如 OA 示例(id=5): +-- UPDATE `system_menu` SET `platform` = 'biz' WHERE `id` IN (5); + +-- 6. 系统管理 / 基础设施 / 用户 / 部门 / 字典 等通用菜单保持 NULL,两边共用。 + +-- 7. 改完客户端后,记得在后台 OAuth2 客户端管理页面"保存"一次刷新缓存; +-- 或重启后端清缓存(Redis key: oauth2_client)。 diff --git a/sql/mysql/migrations/2026-04-20_02_bulk_mark_biz_menus.sql b/sql/mysql/migrations/2026-04-20_02_bulk_mark_biz_menus.sql new file mode 100644 index 00000000..9588cd00 --- /dev/null +++ b/sql/mysql/migrations/2026-04-20_02_bulk_mark_biz_menus.sql @@ -0,0 +1,18 @@ +-- ============================================================ +-- 批量给「非 IoT 菜单」打上 platform='biz' +-- 策略:iot 平台只看设备接入子树(4000),其余(含系统管理、基础设施、OA、各 demo)一律归业务平台 +-- 日期:2026-04-20 +-- ============================================================ + +-- 所有 platform 还是 NULL 的,一律改为 biz +-- (platform='iot' 的行已经在上一次迁移里设过,不会被动) +UPDATE system_menu +SET platform = 'biz' +WHERE deleted = 0 AND platform IS NULL; + +-- 验证 +SELECT platform, COUNT(*) AS cnt FROM system_menu WHERE deleted = 0 GROUP BY platform; +-- 预期: +-- biz = 大部分(系统管理/基础设施/OA/demos 等) +-- iot = 设备接入子树(~50) +-- NULL = 0 diff --git a/viewsh-framework/viewsh-common/src/main/java/com/viewsh/framework/common/biz/system/oauth2/dto/OAuth2AccessTokenCheckRespDTO.java b/viewsh-framework/viewsh-common/src/main/java/com/viewsh/framework/common/biz/system/oauth2/dto/OAuth2AccessTokenCheckRespDTO.java index 7257f9cd..944b58a5 100644 --- a/viewsh-framework/viewsh-common/src/main/java/com/viewsh/framework/common/biz/system/oauth2/dto/OAuth2AccessTokenCheckRespDTO.java +++ b/viewsh-framework/viewsh-common/src/main/java/com/viewsh/framework/common/biz/system/oauth2/dto/OAuth2AccessTokenCheckRespDTO.java @@ -18,6 +18,9 @@ public class OAuth2AccessTokenCheckRespDTO implements Serializable { @Schema(description = "用户类型,参见 UserTypeEnum 枚举", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") private Integer userType; + @Schema(description = "OAuth2 客户端编号,用于按客户端区分前端来源(如 default、iot-client)", example = "iot-client") + private String clientId; + @Schema(description = "用户信息", example = "{\"nickname\": \"芋道\"}") private Map userInfo; diff --git a/viewsh-framework/viewsh-spring-boot-starter-security/src/main/java/com/viewsh/framework/security/core/LoginUser.java b/viewsh-framework/viewsh-spring-boot-starter-security/src/main/java/com/viewsh/framework/security/core/LoginUser.java index 11bd62a5..e9698012 100644 --- a/viewsh-framework/viewsh-spring-boot-starter-security/src/main/java/com/viewsh/framework/security/core/LoginUser.java +++ b/viewsh-framework/viewsh-spring-boot-starter-security/src/main/java/com/viewsh/framework/security/core/LoginUser.java @@ -43,6 +43,14 @@ public class LoginUser { * 授权范围 */ private List scopes; + /** + * OAuth2 客户端编号 + * + * 用于区分用户从哪个前端登录进来,做按客户端的菜单/权限过滤。客户端 → platform 映射: + * - {@code default}(业务平台) → platform=biz + * - {@code iot-client}(物联运维平台) → platform=iot + */ + private String clientId; /** * 过期时间 */ diff --git a/viewsh-framework/viewsh-spring-boot-starter-security/src/main/java/com/viewsh/framework/security/core/filter/TokenAuthenticationFilter.java b/viewsh-framework/viewsh-spring-boot-starter-security/src/main/java/com/viewsh/framework/security/core/filter/TokenAuthenticationFilter.java index cc7768a0..6d8215fe 100644 --- a/viewsh-framework/viewsh-spring-boot-starter-security/src/main/java/com/viewsh/framework/security/core/filter/TokenAuthenticationFilter.java +++ b/viewsh-framework/viewsh-spring-boot-starter-security/src/main/java/com/viewsh/framework/security/core/filter/TokenAuthenticationFilter.java @@ -97,7 +97,8 @@ public class TokenAuthenticationFilter extends OncePerRequestFilter { return new LoginUser().setId(accessToken.getUserId()).setUserType(accessToken.getUserType()) .setInfo(accessToken.getUserInfo()) // 额外的用户信息 .setTenantId(accessToken.getTenantId()).setScopes(accessToken.getScopes()) - .setExpiresTime(accessToken.getExpiresTime()); + .setExpiresTime(accessToken.getExpiresTime()) + .setClientId(accessToken.getClientId()); // OAuth2 客户端编号,用于按前端来源过滤菜单 } catch (ServiceException serviceException) { // 校验 Token 不通过时,考虑到一些接口是无需登录的,所以直接返回 null 即可 return null; diff --git a/viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/web/core/util/WebFrameworkUtils.java b/viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/web/core/util/WebFrameworkUtils.java index 58102568..bf338666 100644 --- a/viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/web/core/util/WebFrameworkUtils.java +++ b/viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/web/core/util/WebFrameworkUtils.java @@ -1,196 +1,219 @@ -package com.viewsh.framework.web.core.util; - -import cn.hutool.core.util.NumberUtil; -import cn.hutool.extra.servlet.ServletUtil; -import com.viewsh.framework.common.enums.RpcConstants; -import com.viewsh.framework.common.enums.TerminalEnum; -import com.viewsh.framework.common.enums.UserTypeEnum; -import com.viewsh.framework.common.pojo.CommonResult; -import com.viewsh.framework.common.util.servlet.ServletUtils; -import com.viewsh.framework.web.config.WebProperties; -import org.springframework.web.context.request.RequestAttributes; -import org.springframework.web.context.request.RequestContextHolder; -import org.springframework.web.context.request.ServletRequestAttributes; - -import jakarta.servlet.ServletRequest; -import jakarta.servlet.http.HttpServletRequest; - -/** - * 专属于 web 包的工具类 - * - * @author 芋道源码 - */ -public class WebFrameworkUtils { - - private static final String REQUEST_ATTRIBUTE_LOGIN_USER_ID = "login_user_id"; - private static final String REQUEST_ATTRIBUTE_LOGIN_USER_TYPE = "login_user_type"; - - private static final String REQUEST_ATTRIBUTE_COMMON_RESULT = "common_result"; - - public static final String HEADER_TENANT_ID = "tenant-id"; - public static final String HEADER_VISIT_TENANT_ID = "visit-tenant-id"; - public static final String HEADER_PROJECT_ID = "project-id"; - - /** - * 终端的 Header - * - * @see com.viewsh.framework.common.enums.TerminalEnum - */ - public static final String HEADER_TERMINAL = "terminal"; - - private static WebProperties properties; - - public WebFrameworkUtils(WebProperties webProperties) { - WebFrameworkUtils.properties = webProperties; - } - - /** - * 获得租户编号,从 header 中 - * 考虑到其它 framework 组件也会使用到租户编号,所以不得不放在 WebFrameworkUtils 统一提供 - * - * @param request 请求 - * @return 租户编号 - */ - public static Long getTenantId(HttpServletRequest request) { - String tenantId = request.getHeader(HEADER_TENANT_ID); - return NumberUtil.isNumber(tenantId) ? Long.valueOf(tenantId) : null; - } - - /** - * 获得项目编号,从 header 中 - * 考虑到其它 framework 组件也会使用到项目编号,所以不得不放在 WebFrameworkUtils 统一提供 - * - * @param request 请求 - * @return 项目编号 - */ - public static Long getProjectId(HttpServletRequest request) { - String projectId = request.getHeader(HEADER_PROJECT_ID); - return NumberUtil.isNumber(projectId) ? Long.valueOf(projectId) : null; - } - - /** - * 获得访问的租户编号,从 header 中 - * 考虑到其它 framework 组件也会使用到租户编号,所以不得不放在 WebFrameworkUtils 统一提供 - * - * @param request 请求 - * @return 租户编号 - */ - public static Long getVisitTenantId(HttpServletRequest request) { - String tenantId = request.getHeader(HEADER_VISIT_TENANT_ID); - return NumberUtil.isNumber(tenantId)? Long.valueOf(tenantId) : null; - } - - public static void setLoginUserId(ServletRequest request, Long userId) { - request.setAttribute(REQUEST_ATTRIBUTE_LOGIN_USER_ID, userId); - } - - /** - * 设置用户类型 - * - * @param request 请求 - * @param userType 用户类型 - */ - public static void setLoginUserType(ServletRequest request, Integer userType) { - request.setAttribute(REQUEST_ATTRIBUTE_LOGIN_USER_TYPE, userType); - } - - /** - * 获得当前用户的编号,从请求中 - * 注意:该方法仅限于 framework 框架使用!!! - * - * @param request 请求 - * @return 用户编号 - */ - public static Long getLoginUserId(HttpServletRequest request) { - if (request == null) { - return null; - } - return (Long) request.getAttribute(REQUEST_ATTRIBUTE_LOGIN_USER_ID); - } - - /** - * 获得当前用户的类型 - * 注意:该方法仅限于 web 相关的 framework 组件使用!!! - * - * @param request 请求 - * @return 用户编号 - */ - public static Integer getLoginUserType(HttpServletRequest request) { - if (request == null) { - return null; - } - // 1. 优先,从 Attribute 中获取 - Integer userType = (Integer) request.getAttribute(REQUEST_ATTRIBUTE_LOGIN_USER_TYPE); - if (userType != null) { - return userType; - } - // 2. 其次,基于 URL 前缀的约定 - if (request.getServletPath().startsWith(properties.getAdminApi().getPrefix())) { - return UserTypeEnum.ADMIN.getValue(); - } - if (request.getServletPath().startsWith(properties.getAppApi().getPrefix())) { - return UserTypeEnum.MEMBER.getValue(); - } - return null; - } - - public static Integer getLoginUserType() { - HttpServletRequest request = getRequest(); - return getLoginUserType(request); - } - - public static Long getLoginUserId() { - HttpServletRequest request = getRequest(); - return getLoginUserId(request); - } - - public static Integer getTerminal() { - HttpServletRequest request = getRequest(); - if (request == null) { - return TerminalEnum.UNKNOWN.getTerminal(); - } - String terminalValue = request.getHeader(HEADER_TERMINAL); - return NumberUtil.parseInt(terminalValue, TerminalEnum.UNKNOWN.getTerminal()); - } - - public static void setCommonResult(ServletRequest request, CommonResult result) { - request.setAttribute(REQUEST_ATTRIBUTE_COMMON_RESULT, result); - } - - public static CommonResult getCommonResult(ServletRequest request) { - return (CommonResult) request.getAttribute(REQUEST_ATTRIBUTE_COMMON_RESULT); - } - - @SuppressWarnings("PatternVariableCanBeUsed") - public static HttpServletRequest getRequest() { - RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); - if (!(requestAttributes instanceof ServletRequestAttributes)) { - return null; - } - ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes) requestAttributes; - return servletRequestAttributes.getRequest(); - } - - /** - * 判断是否为 RPC 请求 - * - * @param request 请求 - * @return 是否为 RPC 请求 - */ - public static boolean isRpcRequest(HttpServletRequest request) { - return request.getRequestURI().startsWith(RpcConstants.RPC_API_PREFIX); - } - - /** - * 判断是否为 RPC 请求 - * - * 约定大于配置,只要以 Api 结尾,都认为是 RPC 接口 - * - * @param className 类名 - * @return 是否为 RPC 请求 - */ - public static boolean isRpcRequest(String className) { - return className.endsWith("Api"); - } - -} +package com.viewsh.framework.web.core.util; + +import cn.hutool.core.util.NumberUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.extra.servlet.ServletUtil; +import com.viewsh.framework.common.enums.RpcConstants; +import com.viewsh.framework.common.enums.TerminalEnum; +import com.viewsh.framework.common.enums.UserTypeEnum; +import com.viewsh.framework.common.pojo.CommonResult; +import com.viewsh.framework.common.util.servlet.ServletUtils; +import com.viewsh.framework.web.config.WebProperties; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import jakarta.servlet.ServletRequest; +import jakarta.servlet.http.HttpServletRequest; + +/** + * 专属于 web 包的工具类 + * + * @author 芋道源码 + */ +public class WebFrameworkUtils { + + private static final String REQUEST_ATTRIBUTE_LOGIN_USER_ID = "login_user_id"; + private static final String REQUEST_ATTRIBUTE_LOGIN_USER_TYPE = "login_user_type"; + + private static final String REQUEST_ATTRIBUTE_COMMON_RESULT = "common_result"; + + public static final String HEADER_TENANT_ID = "tenant-id"; + public static final String HEADER_VISIT_TENANT_ID = "visit-tenant-id"; + public static final String HEADER_PROJECT_ID = "project-id"; + /** + * OAuth2 客户端编号 Header + * + * 由前端(业务平台 / 物联运维平台)声明自己是哪个 OAuth2 client, + * 后端在密码登录、refresh-token 等"还没有 token 的入口"用它代替写死的 default。 + * 一旦 token 创建出来,后续请求就走 token 自带的 client_id,无需此 Header。 + */ + public static final String HEADER_CLIENT_ID = "X-Client-Id"; + + /** + * 终端的 Header + * + * @see com.viewsh.framework.common.enums.TerminalEnum + */ + public static final String HEADER_TERMINAL = "terminal"; + + private static WebProperties properties; + + public WebFrameworkUtils(WebProperties webProperties) { + WebFrameworkUtils.properties = webProperties; + } + + /** + * 获得租户编号,从 header 中 + * 考虑到其它 framework 组件也会使用到租户编号,所以不得不放在 WebFrameworkUtils 统一提供 + * + * @param request 请求 + * @return 租户编号 + */ + public static Long getTenantId(HttpServletRequest request) { + String tenantId = request.getHeader(HEADER_TENANT_ID); + return NumberUtil.isNumber(tenantId) ? Long.valueOf(tenantId) : null; + } + + /** + * 获得项目编号,从 header 中 + * 考虑到其它 framework 组件也会使用到项目编号,所以不得不放在 WebFrameworkUtils 统一提供 + * + * @param request 请求 + * @return 项目编号 + */ + public static Long getProjectId(HttpServletRequest request) { + String projectId = request.getHeader(HEADER_PROJECT_ID); + return NumberUtil.isNumber(projectId) ? Long.valueOf(projectId) : null; + } + + /** + * 获得 OAuth2 客户端编号,从 header 中。 + * 未携带时返回 null,由调用方决定回退默认值。 + * + * @param request 请求 + * @return 客户端编号 + */ + public static String getClientId(HttpServletRequest request) { + if (request == null) { + return null; + } + return StrUtil.trimToNull(request.getHeader(HEADER_CLIENT_ID)); + } + + /** + * 获得访问的租户编号,从 header 中 + * 考虑到其它 framework 组件也会使用到租户编号,所以不得不放在 WebFrameworkUtils 统一提供 + * + * @param request 请求 + * @return 租户编号 + */ + public static Long getVisitTenantId(HttpServletRequest request) { + String tenantId = request.getHeader(HEADER_VISIT_TENANT_ID); + return NumberUtil.isNumber(tenantId)? Long.valueOf(tenantId) : null; + } + + public static void setLoginUserId(ServletRequest request, Long userId) { + request.setAttribute(REQUEST_ATTRIBUTE_LOGIN_USER_ID, userId); + } + + /** + * 设置用户类型 + * + * @param request 请求 + * @param userType 用户类型 + */ + public static void setLoginUserType(ServletRequest request, Integer userType) { + request.setAttribute(REQUEST_ATTRIBUTE_LOGIN_USER_TYPE, userType); + } + + /** + * 获得当前用户的编号,从请求中 + * 注意:该方法仅限于 framework 框架使用!!! + * + * @param request 请求 + * @return 用户编号 + */ + public static Long getLoginUserId(HttpServletRequest request) { + if (request == null) { + return null; + } + return (Long) request.getAttribute(REQUEST_ATTRIBUTE_LOGIN_USER_ID); + } + + /** + * 获得当前用户的类型 + * 注意:该方法仅限于 web 相关的 framework 组件使用!!! + * + * @param request 请求 + * @return 用户编号 + */ + public static Integer getLoginUserType(HttpServletRequest request) { + if (request == null) { + return null; + } + // 1. 优先,从 Attribute 中获取 + Integer userType = (Integer) request.getAttribute(REQUEST_ATTRIBUTE_LOGIN_USER_TYPE); + if (userType != null) { + return userType; + } + // 2. 其次,基于 URL 前缀的约定 + if (request.getServletPath().startsWith(properties.getAdminApi().getPrefix())) { + return UserTypeEnum.ADMIN.getValue(); + } + if (request.getServletPath().startsWith(properties.getAppApi().getPrefix())) { + return UserTypeEnum.MEMBER.getValue(); + } + return null; + } + + public static Integer getLoginUserType() { + HttpServletRequest request = getRequest(); + return getLoginUserType(request); + } + + public static Long getLoginUserId() { + HttpServletRequest request = getRequest(); + return getLoginUserId(request); + } + + public static Integer getTerminal() { + HttpServletRequest request = getRequest(); + if (request == null) { + return TerminalEnum.UNKNOWN.getTerminal(); + } + String terminalValue = request.getHeader(HEADER_TERMINAL); + return NumberUtil.parseInt(terminalValue, TerminalEnum.UNKNOWN.getTerminal()); + } + + public static void setCommonResult(ServletRequest request, CommonResult result) { + request.setAttribute(REQUEST_ATTRIBUTE_COMMON_RESULT, result); + } + + public static CommonResult getCommonResult(ServletRequest request) { + return (CommonResult) request.getAttribute(REQUEST_ATTRIBUTE_COMMON_RESULT); + } + + @SuppressWarnings("PatternVariableCanBeUsed") + public static HttpServletRequest getRequest() { + RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); + if (!(requestAttributes instanceof ServletRequestAttributes)) { + return null; + } + ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes) requestAttributes; + return servletRequestAttributes.getRequest(); + } + + /** + * 判断是否为 RPC 请求 + * + * @param request 请求 + * @return 是否为 RPC 请求 + */ + public static boolean isRpcRequest(HttpServletRequest request) { + return request.getRequestURI().startsWith(RpcConstants.RPC_API_PREFIX); + } + + /** + * 判断是否为 RPC 请求 + * + * 约定大于配置,只要以 Api 结尾,都认为是 RPC 接口 + * + * @param className 类名 + * @return 是否为 RPC 请求 + */ + public static boolean isRpcRequest(String className) { + return className.endsWith("Api"); + } + +} diff --git a/viewsh-gateway/src/main/java/com/viewsh/gateway/filter/security/LoginUser.java b/viewsh-gateway/src/main/java/com/viewsh/gateway/filter/security/LoginUser.java index 030cd839..763ad629 100644 --- a/viewsh-gateway/src/main/java/com/viewsh/gateway/filter/security/LoginUser.java +++ b/viewsh-gateway/src/main/java/com/viewsh/gateway/filter/security/LoginUser.java @@ -36,6 +36,10 @@ public class LoginUser { * 授权范围 */ private List scopes; + /** + * OAuth2 客户端编号 + */ + private String clientId; /** * 过期时间 */ diff --git a/viewsh-gateway/src/main/java/com/viewsh/gateway/filter/security/TokenAuthenticationFilter.java b/viewsh-gateway/src/main/java/com/viewsh/gateway/filter/security/TokenAuthenticationFilter.java index 484b4786..4f8bca9c 100644 --- a/viewsh-gateway/src/main/java/com/viewsh/gateway/filter/security/TokenAuthenticationFilter.java +++ b/viewsh-gateway/src/main/java/com/viewsh/gateway/filter/security/TokenAuthenticationFilter.java @@ -157,7 +157,8 @@ public class TokenAuthenticationFilter implements GlobalFilter, Ordered { return new LoginUser().setId(tokenInfo.getUserId()).setUserType(tokenInfo.getUserType()) .setInfo(tokenInfo.getUserInfo()) // 额外的用户信息 .setTenantId(tokenInfo.getTenantId()).setScopes(tokenInfo.getScopes()) - .setExpiresTime(tokenInfo.getExpiresTime()); + .setExpiresTime(tokenInfo.getExpiresTime()) + .setClientId(tokenInfo.getClientId()); // OAuth2 客户端编号,下游按它做按前端来源过滤 } @Override diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/permission/vo/menu/MenuRespVO.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/permission/vo/menu/MenuRespVO.java index 4b0d2b70..cb5a7d13 100644 --- a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/permission/vo/menu/MenuRespVO.java +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/permission/vo/menu/MenuRespVO.java @@ -63,6 +63,9 @@ public class MenuRespVO { @Schema(description = "是否总是显示", example = "false") private Boolean alwaysShow; + @Schema(description = "平台标识:biz-业务平台,iot-物联运维平台,null-两个平台都展示", example = "biz") + private String platform; + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED, example = "时间戳格式") private LocalDateTime createTime; diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/permission/vo/menu/MenuSaveVO.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/permission/vo/menu/MenuSaveVO.java index 77ba467f..eee1a830 100644 --- a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/permission/vo/menu/MenuSaveVO.java +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/permission/vo/menu/MenuSaveVO.java @@ -61,4 +61,7 @@ public class MenuSaveVO { @Schema(description = "是否总是显示", example = "false") private Boolean alwaysShow; + @Schema(description = "平台标识:biz-业务平台,iot-物联运维平台,null-两个平台都展示", example = "biz") + private String platform; + } diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/dal/dataobject/oauth2/OAuth2ClientDO.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/dal/dataobject/oauth2/OAuth2ClientDO.java index 529666ff..284f7878 100644 --- a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/dal/dataobject/oauth2/OAuth2ClientDO.java +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/dal/dataobject/oauth2/OAuth2ClientDO.java @@ -105,5 +105,12 @@ public class OAuth2ClientDO extends BaseDO { * 附加信息,JSON 格式 */ private String additionalInformation; + /** + * 平台标识,决定该客户端登录后能看到的菜单子集 + * + * 例如:{@code biz} 业务平台 / {@code iot} 物联运维平台。NULL 表示不做按客户端的菜单过滤。 + * 与 {@link com.viewsh.module.system.dal.dataobject.permission.MenuDO#getPlatform()} 对齐。 + */ + private String platform; } diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/dal/dataobject/permission/MenuDO.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/dal/dataobject/permission/MenuDO.java index d9ba093f..5af778de 100644 --- a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/dal/dataobject/permission/MenuDO.java +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/dal/dataobject/permission/MenuDO.java @@ -105,5 +105,12 @@ public class MenuDO extends BaseDO { * 如果为 false 时,当该菜单只有一个子菜单时,不展示自己,直接展示子菜单 */ private Boolean alwaysShow; + /** + * 平台标识:biz-业务平台,iot-物联运维平台,NULL-两个平台都展示 + * + * 与 {@link com.viewsh.module.system.dal.dataobject.oauth2.OAuth2ClientDO#getPlatform()} 对齐: + * 用户登录的 OAuth2 客户端的 platform 等于本字段,或本字段为 NULL,则该菜单展示。 + */ + private String platform; } diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/auth/AdminAuthServiceImpl.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/auth/AdminAuthServiceImpl.java index 521ae30c..504b7d2e 100644 --- a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/auth/AdminAuthServiceImpl.java +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/auth/AdminAuthServiceImpl.java @@ -1,361 +1,379 @@ -package com.viewsh.module.system.service.auth; - -import cn.hutool.core.util.ObjectUtil; -import com.viewsh.framework.common.enums.CommonStatusEnum; -import com.viewsh.framework.common.enums.UserTypeEnum; -import com.viewsh.framework.common.util.monitor.TracerUtils; -import com.viewsh.framework.common.util.object.BeanUtils; -import com.viewsh.framework.common.util.servlet.ServletUtils; -import com.viewsh.framework.common.util.validation.ValidationUtils; -import com.viewsh.framework.datapermission.core.annotation.DataPermission; -import com.viewsh.module.system.api.logger.dto.LoginLogCreateReqDTO; -import com.viewsh.module.system.api.sms.SmsCodeApi; -import com.viewsh.module.system.api.sms.dto.code.SmsCodeUseReqDTO; -import com.viewsh.module.system.api.social.dto.SocialUserBindReqDTO; -import com.viewsh.module.system.api.social.dto.SocialUserRespDTO; -import com.viewsh.module.system.controller.admin.auth.vo.*; -import com.viewsh.module.system.convert.auth.AuthConvert; -import com.viewsh.module.system.dal.dataobject.oauth2.OAuth2AccessTokenDO; -import com.viewsh.module.system.dal.dataobject.user.AdminUserDO; -import com.viewsh.module.system.enums.logger.LoginLogTypeEnum; -import com.viewsh.module.system.enums.logger.LoginResultEnum; -import com.viewsh.module.system.enums.oauth2.OAuth2ClientConstants; -import com.viewsh.module.system.enums.sms.SmsSceneEnum; -import com.viewsh.module.system.service.logger.LoginLogService; -import com.viewsh.module.system.service.member.MemberService; -import com.viewsh.module.system.service.social.SocialClientService; -import com.viewsh.module.system.service.oauth2.OAuth2TokenService; -import com.viewsh.module.system.service.social.SocialUserService; -import com.viewsh.module.system.service.user.AdminUserService; -import com.anji.captcha.model.common.ResponseModel; -import com.anji.captcha.model.vo.CaptchaVO; -import com.anji.captcha.service.CaptchaService; -import com.google.common.annotations.VisibleForTesting; -import jakarta.annotation.Resource; -import jakarta.validation.Validator; -import lombok.Setter; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import cn.binarywang.wx.miniapp.bean.WxMaPhoneNumberInfo; -import cn.hutool.core.lang.Assert; -import com.viewsh.module.system.enums.social.SocialTypeEnum; - -import java.util.Objects; - -import static com.viewsh.framework.common.exception.util.ServiceExceptionUtil.exception; -import static com.viewsh.framework.common.util.servlet.ServletUtils.getClientIP; -import static com.viewsh.module.system.enums.ErrorCodeConstants.*; - -/** - * Auth Service 实现类 - * - * @author 芋道源码 - */ -@Service -@Slf4j -public class AdminAuthServiceImpl implements AdminAuthService { - - @Resource - private AdminUserService userService; - @Resource - private LoginLogService loginLogService; - @Resource - private OAuth2TokenService oauth2TokenService; - @Resource - private SocialUserService socialUserService; - @Resource - private MemberService memberService; - @Resource - private SocialClientService socialClientService; - @Resource - private Validator validator; - @Resource - private CaptchaService captchaService; - @Resource - private SmsCodeApi smsCodeApi; - - /** - * 验证码的开关,默认为 true - */ - @Value("${viewsh.captcha.enable:true}") - @Setter // 为了单测:开启或者关闭验证码 - private Boolean captchaEnable; - - @Override - public AdminUserDO authenticate(String username, String password) { - final LoginLogTypeEnum logTypeEnum = LoginLogTypeEnum.LOGIN_USERNAME; - // 校验账号是否存在 - AdminUserDO user = userService.getUserByUsername(username); - if (user == null) { - createLoginLog(null, username, logTypeEnum, LoginResultEnum.BAD_CREDENTIALS); - throw exception(AUTH_LOGIN_BAD_CREDENTIALS); - } - if (!userService.isPasswordMatch(password, user.getPassword())) { - createLoginLog(user.getId(), username, logTypeEnum, LoginResultEnum.BAD_CREDENTIALS); - throw exception(AUTH_LOGIN_BAD_CREDENTIALS); - } - // 校验是否禁用 - if (CommonStatusEnum.isDisable(user.getStatus())) { - createLoginLog(user.getId(), username, logTypeEnum, LoginResultEnum.USER_DISABLED); - throw exception(AUTH_LOGIN_USER_DISABLED); - } - return user; - } - - @Override - @DataPermission(enable = false) - public AuthLoginRespVO login(AuthLoginReqVO reqVO) { - // 校验验证码 - validateCaptcha(reqVO); - - // 使用账号密码,进行登录 - AdminUserDO user = authenticate(reqVO.getUsername(), reqVO.getPassword()); - - // 如果 socialType 非空,说明需要绑定社交用户 - if (reqVO.getSocialType() != null) { - socialUserService.bindSocialUser(new SocialUserBindReqDTO(user.getId(), getUserType().getValue(), - reqVO.getSocialType(), reqVO.getSocialCode(), reqVO.getSocialState())); - } - // 创建 Token 令牌,记录登录日志 - return createTokenAfterLoginSuccess(user.getId(), reqVO.getUsername(), LoginLogTypeEnum.LOGIN_USERNAME); - } - - @Override - public void sendSmsCode(AuthSmsSendReqVO reqVO) { - // 如果是重置密码场景,需要校验图形验证码是否正确 - if (Objects.equals(SmsSceneEnum.ADMIN_MEMBER_RESET_PASSWORD.getScene(), reqVO.getScene())) { - ResponseModel response = doValidateCaptcha(reqVO); - if (!response.isSuccess()) { - throw exception(AUTH_REGISTER_CAPTCHA_CODE_ERROR, response.getRepMsg()); - } - } - - // 登录场景,验证是否存在 - if (userService.getUserByMobile(reqVO.getMobile()) == null) { - throw exception(AUTH_MOBILE_NOT_EXISTS); - } - // 发送验证码 - smsCodeApi.sendSmsCode(AuthConvert.INSTANCE.convert(reqVO).setCreateIp(getClientIP())); - } - - @Override - public AuthLoginRespVO smsLogin(AuthSmsLoginReqVO reqVO) { - // 校验验证码 - smsCodeApi.useSmsCode(AuthConvert.INSTANCE.convert(reqVO, SmsSceneEnum.ADMIN_MEMBER_LOGIN.getScene(), getClientIP())).checkError(); - - // 获得用户信息 - AdminUserDO user = userService.getUserByMobile(reqVO.getMobile()); - if (user == null) { - throw exception(USER_NOT_EXISTS); - } - - // 创建 Token 令牌,记录登录日志 - return createTokenAfterLoginSuccess(user.getId(), reqVO.getMobile(), LoginLogTypeEnum.LOGIN_MOBILE); - } - - private void createLoginLog(Long userId, String username, - LoginLogTypeEnum logTypeEnum, LoginResultEnum loginResult) { - // 插入登录日志 - LoginLogCreateReqDTO reqDTO = new LoginLogCreateReqDTO(); - reqDTO.setLogType(logTypeEnum.getType()); - reqDTO.setTraceId(TracerUtils.getTraceId()); - reqDTO.setUserId(userId); - reqDTO.setUserType(getUserType().getValue()); - reqDTO.setUsername(username); - reqDTO.setUserAgent(ServletUtils.getUserAgent()); - reqDTO.setUserIp(ServletUtils.getClientIP()); - reqDTO.setResult(loginResult.getResult()); - loginLogService.createLoginLog(reqDTO); - // 更新最后登录时间 - if (userId != null && Objects.equals(LoginResultEnum.SUCCESS.getResult(), loginResult.getResult())) { - userService.updateUserLogin(userId, ServletUtils.getClientIP()); - } - } - - @Override - public AuthLoginRespVO socialLogin(AuthSocialLoginReqVO reqVO) { - // 使用 code 授权码,进行登录。然后,获得到绑定的用户编号 - SocialUserRespDTO socialUser = socialUserService.getSocialUserByCode(UserTypeEnum.ADMIN.getValue(), reqVO.getType(), - reqVO.getCode(), reqVO.getState()); - if (socialUser == null || socialUser.getUserId() == null) { - throw exception(AUTH_THIRD_LOGIN_NOT_BIND); - } - - // 获得用户 - AdminUserDO user = userService.getUser(socialUser.getUserId()); - if (user == null) { - throw exception(USER_NOT_EXISTS); - } - - // 创建 Token 令牌,记录登录日志 - return createTokenAfterLoginSuccess(user.getId(), user.getUsername(), LoginLogTypeEnum.LOGIN_SOCIAL); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public AuthLoginRespVO weixinMiniAppLogin(AuthWeixinMiniAppLoginReqVO reqVO) { - // 1. 通过 phoneCode 获取手机号 - WxMaPhoneNumberInfo phoneNumberInfo = socialClientService.getWxMaPhoneNumberInfo( - UserTypeEnum.ADMIN.getValue(), reqVO.getPhoneCode()); - Assert.notNull(phoneNumberInfo, "获得手机信息失败,结果为空"); - String mobile = phoneNumberInfo.getPurePhoneNumber(); - - // 2. 通过手机号查找管理员 - AdminUserDO user = userService.getUserByMobile(mobile); - if (user == null) { - throw exception(AUTH_WEIXIN_MINI_APP_PHONE_NOT_FOUND); - } - if (CommonStatusEnum.isDisable(user.getStatus())) { - throw exception(AUTH_LOGIN_USER_DISABLED); - } - - // 3. 通过 loginCode 获取社交用户(含 openid) - SocialUserRespDTO socialUser = socialUserService.getSocialUserByCode( - UserTypeEnum.ADMIN.getValue(), SocialTypeEnum.WECHAT_MINI_PROGRAM.getType(), - reqVO.getLoginCode(), reqVO.getState()); - Assert.notNull(socialUser, "社交用户不能为空"); - - // 4. 绑定冲突检测 - // 4a. 当前 openid 是否已绑定其他管理员 - if (socialUser.getUserId() != null && !socialUser.getUserId().equals(user.getId())) { - throw exception(AUTH_WEIXIN_MINI_APP_WECHAT_BINDTO_OTHER); - } - // 4b. 目标管理员是否已绑定其他微信 - SocialUserRespDTO existByUser = socialUserService.getSocialUserByUserId( - UserTypeEnum.ADMIN.getValue(), user.getId(), - SocialTypeEnum.WECHAT_MINI_PROGRAM.getType()); - if (existByUser != null && !existByUser.getOpenid().equals(socialUser.getOpenid())) { - throw exception(AUTH_WEIXIN_MINI_APP_BINDTO_OTHER_WECHAT); - } - - // 5. 绑定社交用户(无冲突且未绑定时) - if (existByUser == null) { - socialUserService.bindSocialUser(new SocialUserBindReqDTO(user.getId(), - getUserType().getValue(), SocialTypeEnum.WECHAT_MINI_PROGRAM.getType(), - reqVO.getLoginCode(), reqVO.getState())); - } - - // 6. 创建 Token 令牌,记录登录日志 - return createTokenAfterLoginSuccess(user.getId(), user.getUsername(), LoginLogTypeEnum.LOGIN_SOCIAL); - } - - @VisibleForTesting - void validateCaptcha(AuthLoginReqVO reqVO) { - ResponseModel response = doValidateCaptcha(reqVO); - // 校验验证码 - if (!response.isSuccess()) { - // 创建登录失败日志(验证码不正确) - createLoginLog(null, reqVO.getUsername(), LoginLogTypeEnum.LOGIN_USERNAME, LoginResultEnum.CAPTCHA_CODE_ERROR); - throw exception(AUTH_LOGIN_CAPTCHA_CODE_ERROR, response.getRepMsg()); - } - } - - private ResponseModel doValidateCaptcha(CaptchaVerificationReqVO reqVO) { - // 如果验证码关闭,则不进行校验 - if (!captchaEnable) { - return ResponseModel.success(); - } - ValidationUtils.validate(validator, reqVO, CaptchaVerificationReqVO.CodeEnableGroup.class); - CaptchaVO captchaVO = new CaptchaVO(); - captchaVO.setCaptchaVerification(reqVO.getCaptchaVerification()); - return captchaService.verification(captchaVO); - } - - private AuthLoginRespVO createTokenAfterLoginSuccess(Long userId, String username, LoginLogTypeEnum logType) { - // 插入登陆日志 - createLoginLog(userId, username, logType, LoginResultEnum.SUCCESS); - // 创建访问令牌 - OAuth2AccessTokenDO accessTokenDO = oauth2TokenService.createAccessToken(userId, getUserType().getValue(), - OAuth2ClientConstants.CLIENT_ID_DEFAULT, null); - // 构建返回结果 - return BeanUtils.toBean(accessTokenDO, AuthLoginRespVO.class); - } - - @Override - public AuthLoginRespVO refreshToken(String refreshToken) { - OAuth2AccessTokenDO accessTokenDO = oauth2TokenService.refreshAccessToken(refreshToken, OAuth2ClientConstants.CLIENT_ID_DEFAULT); - return BeanUtils.toBean(accessTokenDO, AuthLoginRespVO.class); - } - - @Override - public void logout(String token, Integer logType) { - // 删除访问令牌 - OAuth2AccessTokenDO accessTokenDO = oauth2TokenService.removeAccessToken(token); - if (accessTokenDO == null) { - return; - } - // 删除成功,则记录登出日志 - createLogoutLog(accessTokenDO.getUserId(), accessTokenDO.getUserType(), logType); - } - - private void createLogoutLog(Long userId, Integer userType, Integer logType) { - LoginLogCreateReqDTO reqDTO = new LoginLogCreateReqDTO(); - reqDTO.setLogType(logType); - reqDTO.setTraceId(TracerUtils.getTraceId()); - reqDTO.setUserId(userId); - reqDTO.setUserType(userType); - if (ObjectUtil.equal(getUserType().getValue(), userType)) { - reqDTO.setUsername(getUsername(userId)); - } else { - reqDTO.setUsername(memberService.getMemberUserMobile(userId)); - } - reqDTO.setUserAgent(ServletUtils.getUserAgent()); - reqDTO.setUserIp(ServletUtils.getClientIP()); - reqDTO.setResult(LoginResultEnum.SUCCESS.getResult()); - loginLogService.createLoginLog(reqDTO); - } - - private String getUsername(Long userId) { - if (userId == null) { - return null; - } - AdminUserDO user = userService.getUser(userId); - return user != null ? user.getUsername() : null; - } - - private UserTypeEnum getUserType() { - return UserTypeEnum.ADMIN; - } - - @Override - public AuthLoginRespVO register(AuthRegisterReqVO registerReqVO) { - // 1. 校验验证码 - validateCaptcha(registerReqVO); - - // 2. 校验用户名是否已存在 - Long userId = userService.registerUser(registerReqVO); - - // 3. 创建 Token 令牌,记录登录日志 - return createTokenAfterLoginSuccess(userId, registerReqVO.getUsername(), LoginLogTypeEnum.LOGIN_USERNAME); - } - - @VisibleForTesting - void validateCaptcha(AuthRegisterReqVO reqVO) { - ResponseModel response = doValidateCaptcha(reqVO); - // 验证不通过 - if (!response.isSuccess()) { - throw exception(AUTH_REGISTER_CAPTCHA_CODE_ERROR, response.getRepMsg()); - } - } - - @Override - @Transactional(rollbackFor = Exception.class) - public void resetPassword(AuthResetPasswordReqVO reqVO) { - AdminUserDO userByMobile = userService.getUserByMobile(reqVO.getMobile()); - if (userByMobile == null) { - throw exception(USER_MOBILE_NOT_EXISTS); - } - - smsCodeApi.useSmsCode(new SmsCodeUseReqDTO() - .setCode(reqVO.getCode()) - .setMobile(reqVO.getMobile()) - .setScene(SmsSceneEnum.ADMIN_MEMBER_RESET_PASSWORD.getScene()) - .setUsedIp(getClientIP()) - ).checkError(); - - userService.updateUserPassword(userByMobile.getId(), reqVO.getPassword()); - } -} +package com.viewsh.module.system.service.auth; + +import cn.hutool.core.util.ObjectUtil; +import com.viewsh.framework.common.enums.CommonStatusEnum; +import com.viewsh.framework.common.enums.UserTypeEnum; +import com.viewsh.framework.common.util.monitor.TracerUtils; +import com.viewsh.framework.common.util.object.BeanUtils; +import com.viewsh.framework.common.util.servlet.ServletUtils; +import com.viewsh.framework.common.util.validation.ValidationUtils; +import com.viewsh.framework.web.core.util.WebFrameworkUtils; +import cn.hutool.core.util.StrUtil; +import jakarta.servlet.http.HttpServletRequest; +import com.viewsh.framework.datapermission.core.annotation.DataPermission; +import com.viewsh.module.system.api.logger.dto.LoginLogCreateReqDTO; +import com.viewsh.module.system.api.sms.SmsCodeApi; +import com.viewsh.module.system.api.sms.dto.code.SmsCodeUseReqDTO; +import com.viewsh.module.system.api.social.dto.SocialUserBindReqDTO; +import com.viewsh.module.system.api.social.dto.SocialUserRespDTO; +import com.viewsh.module.system.controller.admin.auth.vo.*; +import com.viewsh.module.system.convert.auth.AuthConvert; +import com.viewsh.module.system.dal.dataobject.oauth2.OAuth2AccessTokenDO; +import com.viewsh.module.system.dal.dataobject.user.AdminUserDO; +import com.viewsh.module.system.enums.logger.LoginLogTypeEnum; +import com.viewsh.module.system.enums.logger.LoginResultEnum; +import com.viewsh.module.system.enums.oauth2.OAuth2ClientConstants; +import com.viewsh.module.system.enums.sms.SmsSceneEnum; +import com.viewsh.module.system.service.logger.LoginLogService; +import com.viewsh.module.system.service.member.MemberService; +import com.viewsh.module.system.service.social.SocialClientService; +import com.viewsh.module.system.service.oauth2.OAuth2TokenService; +import com.viewsh.module.system.service.social.SocialUserService; +import com.viewsh.module.system.service.user.AdminUserService; +import com.anji.captcha.model.common.ResponseModel; +import com.anji.captcha.model.vo.CaptchaVO; +import com.anji.captcha.service.CaptchaService; +import com.google.common.annotations.VisibleForTesting; +import jakarta.annotation.Resource; +import jakarta.validation.Validator; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import cn.binarywang.wx.miniapp.bean.WxMaPhoneNumberInfo; +import cn.hutool.core.lang.Assert; +import com.viewsh.module.system.enums.social.SocialTypeEnum; + +import java.util.Objects; + +import static com.viewsh.framework.common.exception.util.ServiceExceptionUtil.exception; +import static com.viewsh.framework.common.util.servlet.ServletUtils.getClientIP; +import static com.viewsh.module.system.enums.ErrorCodeConstants.*; + +/** + * Auth Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Slf4j +public class AdminAuthServiceImpl implements AdminAuthService { + + @Resource + private AdminUserService userService; + @Resource + private LoginLogService loginLogService; + @Resource + private OAuth2TokenService oauth2TokenService; + @Resource + private SocialUserService socialUserService; + @Resource + private MemberService memberService; + @Resource + private SocialClientService socialClientService; + @Resource + private Validator validator; + @Resource + private CaptchaService captchaService; + @Resource + private SmsCodeApi smsCodeApi; + + /** + * 验证码的开关,默认为 true + */ + @Value("${viewsh.captcha.enable:true}") + @Setter // 为了单测:开启或者关闭验证码 + private Boolean captchaEnable; + + @Override + public AdminUserDO authenticate(String username, String password) { + final LoginLogTypeEnum logTypeEnum = LoginLogTypeEnum.LOGIN_USERNAME; + // 校验账号是否存在 + AdminUserDO user = userService.getUserByUsername(username); + if (user == null) { + createLoginLog(null, username, logTypeEnum, LoginResultEnum.BAD_CREDENTIALS); + throw exception(AUTH_LOGIN_BAD_CREDENTIALS); + } + if (!userService.isPasswordMatch(password, user.getPassword())) { + createLoginLog(user.getId(), username, logTypeEnum, LoginResultEnum.BAD_CREDENTIALS); + throw exception(AUTH_LOGIN_BAD_CREDENTIALS); + } + // 校验是否禁用 + if (CommonStatusEnum.isDisable(user.getStatus())) { + createLoginLog(user.getId(), username, logTypeEnum, LoginResultEnum.USER_DISABLED); + throw exception(AUTH_LOGIN_USER_DISABLED); + } + return user; + } + + @Override + @DataPermission(enable = false) + public AuthLoginRespVO login(AuthLoginReqVO reqVO) { + // 校验验证码 + validateCaptcha(reqVO); + + // 使用账号密码,进行登录 + AdminUserDO user = authenticate(reqVO.getUsername(), reqVO.getPassword()); + + // 如果 socialType 非空,说明需要绑定社交用户 + if (reqVO.getSocialType() != null) { + socialUserService.bindSocialUser(new SocialUserBindReqDTO(user.getId(), getUserType().getValue(), + reqVO.getSocialType(), reqVO.getSocialCode(), reqVO.getSocialState())); + } + // 创建 Token 令牌,记录登录日志 + return createTokenAfterLoginSuccess(user.getId(), reqVO.getUsername(), LoginLogTypeEnum.LOGIN_USERNAME); + } + + @Override + public void sendSmsCode(AuthSmsSendReqVO reqVO) { + // 如果是重置密码场景,需要校验图形验证码是否正确 + if (Objects.equals(SmsSceneEnum.ADMIN_MEMBER_RESET_PASSWORD.getScene(), reqVO.getScene())) { + ResponseModel response = doValidateCaptcha(reqVO); + if (!response.isSuccess()) { + throw exception(AUTH_REGISTER_CAPTCHA_CODE_ERROR, response.getRepMsg()); + } + } + + // 登录场景,验证是否存在 + if (userService.getUserByMobile(reqVO.getMobile()) == null) { + throw exception(AUTH_MOBILE_NOT_EXISTS); + } + // 发送验证码 + smsCodeApi.sendSmsCode(AuthConvert.INSTANCE.convert(reqVO).setCreateIp(getClientIP())); + } + + @Override + public AuthLoginRespVO smsLogin(AuthSmsLoginReqVO reqVO) { + // 校验验证码 + smsCodeApi.useSmsCode(AuthConvert.INSTANCE.convert(reqVO, SmsSceneEnum.ADMIN_MEMBER_LOGIN.getScene(), getClientIP())).checkError(); + + // 获得用户信息 + AdminUserDO user = userService.getUserByMobile(reqVO.getMobile()); + if (user == null) { + throw exception(USER_NOT_EXISTS); + } + + // 创建 Token 令牌,记录登录日志 + return createTokenAfterLoginSuccess(user.getId(), reqVO.getMobile(), LoginLogTypeEnum.LOGIN_MOBILE); + } + + private void createLoginLog(Long userId, String username, + LoginLogTypeEnum logTypeEnum, LoginResultEnum loginResult) { + // 插入登录日志 + LoginLogCreateReqDTO reqDTO = new LoginLogCreateReqDTO(); + reqDTO.setLogType(logTypeEnum.getType()); + reqDTO.setTraceId(TracerUtils.getTraceId()); + reqDTO.setUserId(userId); + reqDTO.setUserType(getUserType().getValue()); + reqDTO.setUsername(username); + reqDTO.setUserAgent(ServletUtils.getUserAgent()); + reqDTO.setUserIp(ServletUtils.getClientIP()); + reqDTO.setResult(loginResult.getResult()); + loginLogService.createLoginLog(reqDTO); + // 更新最后登录时间 + if (userId != null && Objects.equals(LoginResultEnum.SUCCESS.getResult(), loginResult.getResult())) { + userService.updateUserLogin(userId, ServletUtils.getClientIP()); + } + } + + @Override + public AuthLoginRespVO socialLogin(AuthSocialLoginReqVO reqVO) { + // 使用 code 授权码,进行登录。然后,获得到绑定的用户编号 + SocialUserRespDTO socialUser = socialUserService.getSocialUserByCode(UserTypeEnum.ADMIN.getValue(), reqVO.getType(), + reqVO.getCode(), reqVO.getState()); + if (socialUser == null || socialUser.getUserId() == null) { + throw exception(AUTH_THIRD_LOGIN_NOT_BIND); + } + + // 获得用户 + AdminUserDO user = userService.getUser(socialUser.getUserId()); + if (user == null) { + throw exception(USER_NOT_EXISTS); + } + + // 创建 Token 令牌,记录登录日志 + return createTokenAfterLoginSuccess(user.getId(), user.getUsername(), LoginLogTypeEnum.LOGIN_SOCIAL); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public AuthLoginRespVO weixinMiniAppLogin(AuthWeixinMiniAppLoginReqVO reqVO) { + // 1. 通过 phoneCode 获取手机号 + WxMaPhoneNumberInfo phoneNumberInfo = socialClientService.getWxMaPhoneNumberInfo( + UserTypeEnum.ADMIN.getValue(), reqVO.getPhoneCode()); + Assert.notNull(phoneNumberInfo, "获得手机信息失败,结果为空"); + String mobile = phoneNumberInfo.getPurePhoneNumber(); + + // 2. 通过手机号查找管理员 + AdminUserDO user = userService.getUserByMobile(mobile); + if (user == null) { + throw exception(AUTH_WEIXIN_MINI_APP_PHONE_NOT_FOUND); + } + if (CommonStatusEnum.isDisable(user.getStatus())) { + throw exception(AUTH_LOGIN_USER_DISABLED); + } + + // 3. 通过 loginCode 获取社交用户(含 openid) + SocialUserRespDTO socialUser = socialUserService.getSocialUserByCode( + UserTypeEnum.ADMIN.getValue(), SocialTypeEnum.WECHAT_MINI_PROGRAM.getType(), + reqVO.getLoginCode(), reqVO.getState()); + Assert.notNull(socialUser, "社交用户不能为空"); + + // 4. 绑定冲突检测 + // 4a. 当前 openid 是否已绑定其他管理员 + if (socialUser.getUserId() != null && !socialUser.getUserId().equals(user.getId())) { + throw exception(AUTH_WEIXIN_MINI_APP_WECHAT_BINDTO_OTHER); + } + // 4b. 目标管理员是否已绑定其他微信 + SocialUserRespDTO existByUser = socialUserService.getSocialUserByUserId( + UserTypeEnum.ADMIN.getValue(), user.getId(), + SocialTypeEnum.WECHAT_MINI_PROGRAM.getType()); + if (existByUser != null && !existByUser.getOpenid().equals(socialUser.getOpenid())) { + throw exception(AUTH_WEIXIN_MINI_APP_BINDTO_OTHER_WECHAT); + } + + // 5. 绑定社交用户(无冲突且未绑定时) + if (existByUser == null) { + socialUserService.bindSocialUser(new SocialUserBindReqDTO(user.getId(), + getUserType().getValue(), SocialTypeEnum.WECHAT_MINI_PROGRAM.getType(), + reqVO.getLoginCode(), reqVO.getState())); + } + + // 6. 创建 Token 令牌,记录登录日志 + return createTokenAfterLoginSuccess(user.getId(), user.getUsername(), LoginLogTypeEnum.LOGIN_SOCIAL); + } + + @VisibleForTesting + void validateCaptcha(AuthLoginReqVO reqVO) { + ResponseModel response = doValidateCaptcha(reqVO); + // 校验验证码 + if (!response.isSuccess()) { + // 创建登录失败日志(验证码不正确) + createLoginLog(null, reqVO.getUsername(), LoginLogTypeEnum.LOGIN_USERNAME, LoginResultEnum.CAPTCHA_CODE_ERROR); + throw exception(AUTH_LOGIN_CAPTCHA_CODE_ERROR, response.getRepMsg()); + } + } + + private ResponseModel doValidateCaptcha(CaptchaVerificationReqVO reqVO) { + // 如果验证码关闭,则不进行校验 + if (!captchaEnable) { + return ResponseModel.success(); + } + ValidationUtils.validate(validator, reqVO, CaptchaVerificationReqVO.CodeEnableGroup.class); + CaptchaVO captchaVO = new CaptchaVO(); + captchaVO.setCaptchaVerification(reqVO.getCaptchaVerification()); + return captchaService.verification(captchaVO); + } + + private AuthLoginRespVO createTokenAfterLoginSuccess(Long userId, String username, LoginLogTypeEnum logType) { + // 插入登陆日志 + createLoginLog(userId, username, logType, LoginResultEnum.SUCCESS); + // 创建访问令牌(client_id 优先取请求头 X-Client-Id,缺省回退 default) + OAuth2AccessTokenDO accessTokenDO = oauth2TokenService.createAccessToken(userId, getUserType().getValue(), + resolveClientId(), null); + // 构建返回结果 + return BeanUtils.toBean(accessTokenDO, AuthLoginRespVO.class); + } + + @Override + public AuthLoginRespVO refreshToken(String refreshToken) { + OAuth2AccessTokenDO accessTokenDO = oauth2TokenService.refreshAccessToken(refreshToken, resolveClientId()); + return BeanUtils.toBean(accessTokenDO, AuthLoginRespVO.class); + } + + /** + * 解析当前请求声明的 OAuth2 客户端编号。 + * + * 用于密码登录、refresh-token 等"还没有 token 的入口"——前端通过请求头 X-Client-Id 声明自己身份, + * 决定 token 绑定到哪个客户端,进而影响 {@code AuthController#getPermissionInfo} 按 platform 过滤的菜单。 + * + * 缺省回退到 {@link OAuth2ClientConstants#CLIENT_ID_DEFAULT}("default"),即原生业务平台客户端; + * 在 DB 层通过 system_oauth2_client.platform='biz' 标识。 + */ + private String resolveClientId() { + HttpServletRequest request = WebFrameworkUtils.getRequest(); + String clientId = WebFrameworkUtils.getClientId(request); + return StrUtil.isNotBlank(clientId) ? clientId : OAuth2ClientConstants.CLIENT_ID_DEFAULT; + } + + @Override + public void logout(String token, Integer logType) { + // 删除访问令牌 + OAuth2AccessTokenDO accessTokenDO = oauth2TokenService.removeAccessToken(token); + if (accessTokenDO == null) { + return; + } + // 删除成功,则记录登出日志 + createLogoutLog(accessTokenDO.getUserId(), accessTokenDO.getUserType(), logType); + } + + private void createLogoutLog(Long userId, Integer userType, Integer logType) { + LoginLogCreateReqDTO reqDTO = new LoginLogCreateReqDTO(); + reqDTO.setLogType(logType); + reqDTO.setTraceId(TracerUtils.getTraceId()); + reqDTO.setUserId(userId); + reqDTO.setUserType(userType); + if (ObjectUtil.equal(getUserType().getValue(), userType)) { + reqDTO.setUsername(getUsername(userId)); + } else { + reqDTO.setUsername(memberService.getMemberUserMobile(userId)); + } + reqDTO.setUserAgent(ServletUtils.getUserAgent()); + reqDTO.setUserIp(ServletUtils.getClientIP()); + reqDTO.setResult(LoginResultEnum.SUCCESS.getResult()); + loginLogService.createLoginLog(reqDTO); + } + + private String getUsername(Long userId) { + if (userId == null) { + return null; + } + AdminUserDO user = userService.getUser(userId); + return user != null ? user.getUsername() : null; + } + + private UserTypeEnum getUserType() { + return UserTypeEnum.ADMIN; + } + + @Override + public AuthLoginRespVO register(AuthRegisterReqVO registerReqVO) { + // 1. 校验验证码 + validateCaptcha(registerReqVO); + + // 2. 校验用户名是否已存在 + Long userId = userService.registerUser(registerReqVO); + + // 3. 创建 Token 令牌,记录登录日志 + return createTokenAfterLoginSuccess(userId, registerReqVO.getUsername(), LoginLogTypeEnum.LOGIN_USERNAME); + } + + @VisibleForTesting + void validateCaptcha(AuthRegisterReqVO reqVO) { + ResponseModel response = doValidateCaptcha(reqVO); + // 验证不通过 + if (!response.isSuccess()) { + throw exception(AUTH_REGISTER_CAPTCHA_CODE_ERROR, response.getRepMsg()); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void resetPassword(AuthResetPasswordReqVO reqVO) { + AdminUserDO userByMobile = userService.getUserByMobile(reqVO.getMobile()); + if (userByMobile == null) { + throw exception(USER_MOBILE_NOT_EXISTS); + } + + smsCodeApi.useSmsCode(new SmsCodeUseReqDTO() + .setCode(reqVO.getCode()) + .setMobile(reqVO.getMobile()) + .setScene(SmsSceneEnum.ADMIN_MEMBER_RESET_PASSWORD.getScene()) + .setUsedIp(getClientIP()) + ).checkError(); + + userService.updateUserPassword(userByMobile.getId(), reqVO.getPassword()); + } +} diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/permission/MenuService.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/permission/MenuService.java index 96b45ab4..40d20287 100644 --- a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/permission/MenuService.java +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/permission/MenuService.java @@ -67,6 +67,15 @@ public interface MenuService { */ List filterDisableMenus(List list); + /** + * 按平台标识过滤菜单。保留 platform 为空(全平台共用)或与入参相同的菜单 + * + * @param list 菜单列表 + * @param platform 平台标识(来自 OAuth2 客户端的 platform 字段)。为空时不过滤,原样返回 + * @return 过滤后的菜单列表 + */ + List filterMenusByPlatform(List list, String platform); + /** * 筛选菜单列表 * diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/permission/MenuServiceImpl.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/permission/MenuServiceImpl.java index b9376222..a03c0432 100644 --- a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/permission/MenuServiceImpl.java +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/permission/MenuServiceImpl.java @@ -136,6 +136,20 @@ public class MenuServiceImpl implements MenuService { return menus; } + @Override + public List filterMenusByPlatform(List menuList, String platform) { + if (CollUtil.isEmpty(menuList) || StrUtil.isBlank(platform)) { + return menuList; + } + List result = new ArrayList<>(menuList.size()); + for (MenuDO menu : menuList) { + if (StrUtil.isBlank(menu.getPlatform()) || platform.equals(menu.getPlatform())) { + result.add(menu); + } + } + return result; + } + @Override public List filterDisableMenus(List menuList) { if (CollUtil.isEmpty(menuList)){