feat(system): 按 OAuth2 客户端 platform 过滤菜单,支持业务/物联双前端
问题:业务平台(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) <noreply@anthropic.com>
This commit is contained in:
@@ -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)。
|
||||
18
sql/mysql/migrations/2026-04-20_02_bulk_mark_biz_menus.sql
Normal file
18
sql/mysql/migrations/2026-04-20_02_bulk_mark_biz_menus.sql
Normal file
@@ -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
|
||||
@@ -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<String, String> userInfo;
|
||||
|
||||
|
||||
@@ -43,6 +43,14 @@ public class LoginUser {
|
||||
* 授权范围
|
||||
*/
|
||||
private List<String> scopes;
|
||||
/**
|
||||
* OAuth2 客户端编号
|
||||
*
|
||||
* 用于区分用户从哪个前端登录进来,做按客户端的菜单/权限过滤。客户端 → platform 映射:
|
||||
* - {@code default}(业务平台) → platform=biz
|
||||
* - {@code iot-client}(物联运维平台) → platform=iot
|
||||
*/
|
||||
private String clientId;
|
||||
/**
|
||||
* 过期时间
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -36,6 +36,10 @@ public class LoginUser {
|
||||
* 授权范围
|
||||
*/
|
||||
private List<String> scopes;
|
||||
/**
|
||||
* OAuth2 客户端编号
|
||||
*/
|
||||
private String clientId;
|
||||
/**
|
||||
* 过期时间
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -61,4 +61,7 @@ public class MenuSaveVO {
|
||||
@Schema(description = "是否总是显示", example = "false")
|
||||
private Boolean alwaysShow;
|
||||
|
||||
@Schema(description = "平台标识:biz-业务平台,iot-物联运维平台,null-两个平台都展示", example = "biz")
|
||||
private String platform;
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,15 @@ public interface MenuService {
|
||||
*/
|
||||
List<MenuDO> filterDisableMenus(List<MenuDO> list);
|
||||
|
||||
/**
|
||||
* 按平台标识过滤菜单。保留 platform 为空(全平台共用)或与入参相同的菜单
|
||||
*
|
||||
* @param list 菜单列表
|
||||
* @param platform 平台标识(来自 OAuth2 客户端的 platform 字段)。为空时不过滤,原样返回
|
||||
* @return 过滤后的菜单列表
|
||||
*/
|
||||
List<MenuDO> filterMenusByPlatform(List<MenuDO> list, String platform);
|
||||
|
||||
/**
|
||||
* 筛选菜单列表
|
||||
*
|
||||
|
||||
@@ -136,6 +136,20 @@ public class MenuServiceImpl implements MenuService {
|
||||
return menus;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<MenuDO> filterMenusByPlatform(List<MenuDO> menuList, String platform) {
|
||||
if (CollUtil.isEmpty(menuList) || StrUtil.isBlank(platform)) {
|
||||
return menuList;
|
||||
}
|
||||
List<MenuDO> 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<MenuDO> filterDisableMenus(List<MenuDO> menuList) {
|
||||
if (CollUtil.isEmpty(menuList)){
|
||||
|
||||
Reference in New Issue
Block a user