# UrbanOps 系统鉴权方式说明文档 > **项目**: 蓟城山水集团全域智能运营管理平台 (UrbanOps) > **版本**: 基于 2026-06-11 代码分析 > **适用对象**: 前端开发、第三方对接、运维部署 --- ## 目录 - [1. 鉴权体系概览](#1-鉴权体系概览) - [2. Token 令牌鉴权(核心机制)](#2-token-令牌鉴权核心机制) - [3. 用户名密码登录鉴权](#3-用户名密码登录鉴权) - [4. 短信验证码登录鉴权](#4-短信验证码登录鉴权) - [5. OAuth2 授权服务器](#5-oauth2-授权服务器) - [6. API 签名鉴权(第三方对接)](#6-api-签名鉴权第三方对接) - [7. 社交登录鉴权(JustAuth)](#7-社交登录鉴权justauth) - [8. UAA SSO 单点登录](#8-uaa-sso-单点登录) - [9. RBAC 权限鉴权](#9-rbac-权限鉴权) - [10. 租户隔离鉴权](#10-租户隔离鉴权) - [11. 数据权限鉴权(行级安全)](#11-数据权限鉴权行级安全) - [12. API 加密](#12-api-加密) - [13. 接口前缀与鉴权方式对照表](#13-接口前缀与鉴权方式对照表) --- ## 1. 鉴权体系概览 UrbanOps 采用 **多层鉴权架构**,从请求进入系统到数据返回,依次经过以下安全层级: ``` 请求到达 │ ├── 第0层: API 加密过滤 (ApiEncryptFilter) ← 可选,按 @ApiEncrypt 注解启用 │ ├── 第1层: 租户隔离 (TenantSecurityWebFilter) ← /admin-api/, /app-api/ 强制 │ ├── 第2层: Token 令牌校验 (TokenAuthenticationFilter) ← /admin-api/, /app-api/ 强制 │ ├── 第3层: 方法级权限 (Spring Security @PreAuthorize) ← /admin-api/ 方法级 │ └── 第4层: 数据权限 (DeptDataPermissionRule) ← /admin-api/ MyBatis SQL 注入 ``` --- ## 2. Token 令牌鉴权(核心机制) ### 2.1 概述 Token 令牌鉴权是系统的核心认证方式,覆盖所有 `/admin-api/` 和 `/app-api/` 接口。系统通过自定义 `TokenAuthenticationFilter` 拦截每个请求,提取并验证 Token。 ### 2.2 核心类 | 类名 | 路径 | |------|------| | `TokenAuthenticationFilter` | `urbanops-framework/urbanops-spring-boot-starter-security/src/main/java/com/zteits/urbanops/framework/security/core/filter/TokenAuthenticationFilter.java` | | `SecurityProperties` | `urbanops-framework/urbanops-spring-boot-starter-security/src/main/java/com/zteits/urbanops/framework/security/config/SecurityProperties.java` | | `OAuth2TokenServiceImpl` | `urbanops-module-system/src/main/java/com/zteits/urbanops/module/system/service/oauth2/OAuth2TokenServiceImpl.java` | | `SecurityFrameworkUtils` | `urbanops-framework/urbanops-spring-boot-starter-security/src/main/java/com/zteits/urbanops/framework/security/core/util/SecurityFrameworkUtils.java` | ### 2.3 配置项 ```yaml # application.yaml urbanops: security: token-header: Authorization # 请求头名称 token-parameter: token # Query参数名称(WebSocket场景用) mock-enable: false # 开发环境Mock模式开关 mock-secret: test # Mock模式密钥前缀 password-encoder-length: 4 # BCrypt加密强度 ``` ### 2.4 工作流程 ``` 客户端请求(带 Authorization: Bearer {token}) │ ▼ TokenAuthenticationFilter.doFilterInternal() │ ├── 1. 提取 Token │ 方式A: 从请求头 Authorization 提取 │ 方式B: 从查询参数 ?token=xxx 提取(WebSocket 场景) │ ├── 2. 推断 userType │ /admin-api/ → ADMIN(2) │ /app-api/ → MEMBER(1) │ ├── 3. 调用 oauth2TokenApi.checkAccessToken(token) 校验 │ 先查 Redis(key: oauth2_access_token:{token}) │ 缓存未命中则查 MySQL(system_oauth2_access_token 表) │ ├── 4. 校验 userType 匹配 │ 防止 admin token 访问 app 接口(反之亦然) │ ├── 5. 构建 LoginUser 对象 │ 包含 userId, userType, tenantId, scopes, userInfo │ └── 6. 设置到 SecurityContextHolder 后续 Controller 可通过 SecurityFrameworkUtils.getLoginUser() 获取 ``` ### 2.5 实现代码示例 **Token 提取与验证核心代码:** `TokenAuthenticationFilter.java` ```java @RequiredArgsConstructor public class TokenAuthenticationFilter extends OncePerRequestFilter { private final SecurityProperties securityProperties; private final GlobalExceptionHandler globalExceptionHandler; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { // 从请求头或查询参数中提取 Token String token = SecurityFrameworkUtils.obtainAuthorization( request, securityProperties.getTokenHeader(), securityProperties.getTokenParameter()); if (StrUtil.isNotEmpty(token)) { Integer userType = WebFrameworkUtils.getLoginUserType(request); try { // 1. 基于 token 构建登录用户 LoginUser loginUser = buildLoginUserByToken(token, userType); // 2. 模拟登录(开发环境 fallback) if (loginUser == null) { loginUser = mockLoginUser(request, token, userType); } // 3. 设置当前用户到 SecurityContext if (loginUser != null) { SecurityFrameworkUtils.setLoginUser(loginUser, request); } } catch (Throwable ex) { // Token 非法或过期,直接返回错误 JSON,中断过滤器链 CommonResult result = globalExceptionHandler.allExceptionHandler(request, ex); ServletUtils.writeJSON(response, result); return; } } // 4. 继续执行后续过滤器 chain.doFilter(request, response); } private LoginUser buildLoginUserByToken(String token, Integer userType) { // 调用内部 OAuth2 服务校验 token OAuth2AccessTokenCheckRespDTO accessToken = oauth2TokenApi.checkAccessToken(token); if (accessToken == null) { return null; } // 校验用户类型匹配 if (ObjectUtil.notEqual(accessToken.getUserType(), userType)) { throw new AccessDeniedException("错误的用户类型"); } // 构建 LoginUser LoginUser loginUser = new LoginUser(); loginUser.setId(accessToken.getUserId()); loginUser.setUserType(accessToken.getUserType()); loginUser.setTenantId(accessToken.getTenantId()); loginUser.setScopes(accessToken.getScopes()); loginUser.setContext(accessToken.getUserInfo()); return loginUser; } } ``` **Token 生成核心代码:** `OAuth2TokenServiceImpl.java` ```java @Service public class OAuth2TokenServiceImpl implements OAuth2TokenService { @Resource private OAuth2AccessTokenMapper oauth2AccessTokenMapper; @Resource private OAuth2AccessTokenRedisDAO oauth2AccessTokenRedisDAO; @Resource private OAuth2RefreshTokenMapper oauth2RefreshTokenMapper; @Override public OAuth2AccessTokenDO createAccessToken(Long userId, Integer userType, String clientId, List scopes) { OAuth2ClientDO client = oauth2ClientService.validOAuthClientFromCache(clientId); // 生成 Token(UUID 无连字符) OAuth2AccessTokenDO accessToken = new OAuth2AccessTokenDO(); accessToken.setAccessToken(IdUtil.fastSimpleUUID()); // 32位十六进制 accessToken.setRefreshToken(IdUtil.fastSimpleUUID()); accessToken.setUserId(userId); accessToken.setUserType(userType); accessToken.setClientId(clientId); accessToken.setScopes(scopes); accessToken.setExpiresTime(LocalDateTime.now() .plusSeconds(client.getAccessTokenValiditySeconds())); // 双重存储:MySQL + Redis oauth2AccessTokenMapper.insert(accessToken); oauth2AccessTokenRedisDAO.set(accessToken); return accessToken; } @Override public OAuth2AccessTokenCheckRespDTO checkAccessToken(String accessToken) { // 先从 Redis 缓存获取(高性能) OAuth2AccessTokenDO accessTokenDO = oauth2AccessTokenRedisDAO.get(accessToken); if (accessTokenDO == null) { // 缓存未命中,查 MySQL 兜底 accessTokenDO = oauth2AccessTokenMapper.selectByAccessToken(accessToken); if (accessTokenDO != null) { // 回写 Redis 缓存 oauth2AccessTokenRedisDAO.set(accessTokenDO); } } if (accessTokenDO == null || isExpired(accessTokenDO)) { throw exception(OAUTH2_ACCESS_TOKEN_NOT_FOUND_OR_EXPIRED); } return convertToCheckDTO(accessTokenDO); } } ``` ### 2.6 调用示例 ```bash # 请求管理后台接口(需要在请求头带上 Token) curl -X GET "https://test.jichengshanshui.com.cn:28302/admin-api/system/dept/list" \ -H "Authorization: Bearer a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6" # 返回成功示例 { "code": 0, "msg": "成功", "data": { ... } } # Token 无效返回示例 { "code": 401, "msg": "访问令牌不存在或已过期" } ``` --- ## 3. 用户名密码登录鉴权 ### 3.1 概述 系统提供标准的用户名+密码登录方式,通过 BCrypt 密码比对验证用户身份,登录成功后返回 accessToken 和 refreshToken。 ### 3.2 核心类 | 类名 | 路径 | |------|------| | `AuthController` | `urbanops-module-system/src/main/java/com/zteits/urbanops/module/system/controller/admin/auth/AuthController.java` | | `AdminAuthServiceImpl` | `urbanops-module-system/src/main/java/com/zteits/urbanops/module/system/service/auth/AdminAuthServiceImpl.java` | | `BCryptPasswordEncoder` | `urbanops-framework/urbanops-spring-boot-starter-security/src/main/java/com/zteits/urbanops/framework/security/config/UrbanopsSecurityAutoConfiguration.java` | ### 3.3 工作流程 ``` POST /system/auth/login { username, password } │ ▼ AuthController.login() │ ▼ AdminAuthService.login() │ ├── 1. authenticate(username, password) │ ├── 根据 username 查询 AdminUserDO │ ├── 如果未找到,尝试按手机号查询 │ ├── BCrypt 比对密码(强度4) │ ├── 检查用户状态(是否禁用) │ └── 记录登录日志 │ ├── 2. 处理社交账号绑定(可选) │ └── 3. createTokenAfterLoginSuccess() ├── 创建 OAuth2AccessTokenDO ├── 存储到 MySQL + Redis └── 返回 { accessToken, refreshToken, expiresTime } ``` ### 3.4 实现代码示例 **Controller 层:** `AuthController.java` ```java @Tag(name = "管理后台 - 认证") @RestController @RequestMapping("/system/auth") @Validated public class AuthController { @Resource private AdminAuthService authService; @PostMapping("/login") @PermitAll @Operation(summary = "使用账号密码登录") public CommonResult login(@RequestBody @Valid AuthLoginReqVO reqVO) { return success(authService.login(reqVO)); } } ``` **Service 实现层 — 身份验证:** `AdminAuthServiceImpl.java` ```java @Service public class AdminAuthServiceImpl implements AdminAuthService { @Resource private AdminUserService userService; /** * 账号密码认证 */ public AdminUserDO authenticate(String username, String password) { final LoginLogTypeEnum logTypeEnum = LoginLogTypeEnum.LOGIN_USERNAME; // 1. 校验账号是否存在(先按用户名,再按手机号) AdminUserDO user = userService.getUserByUsername(username); if (user == null) { user = userService.getUserByMobile(username); if (user == null) { createLoginLog(null, username, logTypeEnum, LoginResultEnum.BAD_CREDENTIALS); throw exception(AUTH_LOGIN_BAD_CREDENTIALS); } } // 2. BCrypt 密码比对 if (!userService.isPasswordMatch(password, user.getPassword())) { createLoginLog(user.getId(), username, logTypeEnum, LoginResultEnum.BAD_CREDENTIALS); throw exception(AUTH_LOGIN_BAD_CREDENTIALS); } // 3. 校验用户状态 if (CommonStatusEnum.isDisable(user.getStatus())) { createLoginLog(user.getId(), username, logTypeEnum, LoginResultEnum.USER_DISABLED); throw exception(AUTH_LOGIN_USER_DISABLED); } return user; } /** * 登录:认证 → 创建 Token → 返回 */ @Override @DataPermission(enable = false) // 关闭数据权限(登录时无需数据过滤) public AuthLoginRespVO login(AuthLoginReqVO reqVO) { AdminUserDO user = authenticate(reqVO.getUsername(), reqVO.getPassword()); return createTokenAfterLoginSuccess(user.getId(), reqVO.getUsername(), LoginLogTypeEnum.LOGIN_USERNAME); } private AuthLoginRespVO createTokenAfterLoginSuccess(Long userId, String username, LoginLogTypeEnum logType) { // 创建 Token OAuth2AccessTokenDO accessToken = oauth2TokenService.createAccessToken( userId, UserTypeEnum.ADMIN.getValue(), OAuth2ClientConstants.CLIENT_ID_DEFAULT, null); // 记录登录成功日志 createLoginLog(userId, username, logType, LoginResultEnum.SUCCESS); // 返回结果 return AuthConvert.INSTANCE.convert(accessToken); } } ``` ### 3.5 调用示例 ```bash # 用户名密码登录 curl -X POST "https://test.jichengshanshui.com.cn:28302/admin-api/system/auth/login" \ -H "Content-Type: application/json" \ -d '{ "username": "admin", "password": "admin123" }' # 返回示例 { "code": 0, "msg": "成功", "data": { "accessToken": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6", "refreshToken": "p6o5n4m3l2k1j0i9h8g7f6e5d4c3b2a1", "expiresTime": "2026-06-12T10:30:00", "userId": 1, "userType": 2 } } ``` --- ## 4. 短信验证码登录鉴权 ### 4.1 概述 系统支持通过短信验证码方式进行免密登录,先发送验证码到用户手机,再通过验证码完成身份认证。 ### 4.2 核心类 | 类名 | 路径 | |------|------| | `AuthController` | `urbanops-module-system/.../controller/admin/auth/AuthController.java` | | `AdminAuthServiceImpl` | `urbanops-module-system/.../service/auth/AdminAuthServiceImpl.java` | | `SmsCodeApi` | `urbanops-framework/urbanops-common/.../biz/system/sms/SmsCodeApi.java` | ### 4.3 工作流程 ``` ① 发送验证码 POST /system/auth/send-sms-code { mobile } │ ▼ AuthController.sendSmsCode() └── smsCodeApi.sendSmsCode(mobile, scene) └── 发送短信 + 存储验证码到 Redis(带过期时间) ② 验证码登录 POST /system/auth/sms-login { mobile, code } │ ▼ AuthController.smsLogin() │ ▼ AdminAuthService.smsLogin() ├── 1. smsCodeApi.useSmsCode(mobile, code) ← 校验验证码 ├── 2. userService.getUserByMobile(mobile) ← 查找用户 └── 3. createTokenAfterLoginSuccess() ← 创建 Token ``` ### 4.4 实现代码示例 **Controller 层:** `AuthController.java` ```java @PostMapping("/send-sms-code") @PermitAll @Operation(summary = "发送手机验证码") public CommonResult sendSmsCode(@RequestBody @Valid AuthSendSmsReqVO reqVO) { smsCodeApi.sendSmsCode(reqVO.getMobile(), SmsSceneEnum.ADMIN_MEMBER_LOGIN.getScene(), WebFrameworkUtils.getClientIP()); return success(true); } @PostMapping("/sms-login") @PermitAll @Operation(summary = "使用短信验证码登录") public CommonResult smsLogin(@RequestBody @Valid AuthSmsLoginReqVO reqVO) { return success(authService.smsLogin(reqVO)); } ``` **Service 实现层:** `AdminAuthServiceImpl.java` ```java @Override public AuthLoginRespVO smsLogin(AuthSmsLoginReqVO reqVO) { // 1. 校验验证码(一次性消费,用过即删) smsCodeApi.useSmsCode( AuthConvert.INSTANCE.convert( reqVO, SmsSceneEnum.ADMIN_MEMBER_LOGIN.getScene(), getClientIP() ) ); // 2. 根据手机号获取用户 AdminUserDO user = userService.getUserByMobile(reqVO.getMobile()); if (user == null) { throw exception(USER_NOT_EXISTS); } // 3. 创建 Token return createTokenAfterLoginSuccess( user.getId(), reqVO.getMobile(), LoginLogTypeEnum.LOGIN_MOBILE); } ``` ### 4.5 调用示例 ```bash # 步骤1: 发送短信验证码 curl -X POST "https://test.jichengshanshui.com.cn:28302/admin-api/system/auth/send-sms-code" \ -H "Content-Type: application/json" \ -d '{"mobile": "13800138000"}' # 返回 { "code": 0, "msg": "成功", "data": true } # 步骤2: 验证码登录 curl -X POST "https://test.jichengshanshui.com.cn:28302/admin-api/system/auth/sms-login" \ -H "Content-Type: application/json" \ -d '{ "mobile": "13800138000", "code": "123456" }' # 返回(同密码登录) { "code": 0, "msg": "成功", "data": { "accessToken": "x1y2z3...", "refreshToken": "z3y2x1...", "expiresTime": "2026-06-12T10:30:00" } } ``` --- ## 5. OAuth2 授权服务器 ### 5.1 概述 系统内置完整的 OAuth2 授权服务器实现,支持 5 种标准授权模式,可同时作为授权服务和资源服务对外提供标准 OAuth2 接口。 ### 5.2 核心类 | 类名 | 路径 | |------|------| | `OAuth2OpenController` | `urbanops-module-system/.../controller/admin/oauth2/OAuth2OpenController.java` | | `OAuth2GrantServiceImpl` | `urbanops-module-system/.../service/oauth2/OAuth2GrantServiceImpl.java` | | `OAuth2TokenServiceImpl` | `urbanops-module-system/.../service/oauth2/OAuth2TokenServiceImpl.java` | | `OAuth2ClientServiceImpl` | `urbanops-module-system/.../service/oauth2/OAuth2ClientServiceImpl.java` | | `OAuth2GrantTypeEnum` | `urbanops-module-system/.../enums/oauth2/OAuth2GrantTypeEnum.java` | ### 5.3 支持的授权模式 | 授权模式 | grant_type 值 | 说明 | |----------|--------------|------| | 密码模式 | `password` | 直接使用用户名+密码换取 Token | | 授权码模式 | `authorization_code` | 先获取授权码 code,再换取 Token | | 客户端模式 | `client_credentials` | 客户端以自己的名义访问资源 | | 刷新令牌 | `refresh_token` | 使用 refreshToken 刷新 accessToken | | 隐式模式 | `implicit` | 通过 `/authorize` 端点直接返回 Token | ### 5.4 OAuth2 端点 | 端点 | 方法 | 鉴权方式 | 说明 | |------|------|----------|------| | `/system/oauth2/token` | POST | `@PermitAll` (client_id + client_secret Basic Auth) | 签发 Token | | `/system/oauth2/check-token` | POST | `@PermitAll` (client_id + client_secret Basic Auth) | 校验 Token | | `/system/oauth2/revoke-token` | DELETE | `@PermitAll` (client_id + client_secret Basic Auth) | 撤销 Token | | `/system/oauth2/authorize` | GET | `@PermitAll` (需已登录用户) | 授权页面(SSO 入口) | | `/system/oauth2/authorize` | POST | `@PermitAll` (需已登录用户) | 用户确认授权 | ### 5.5 实现代码示例 **`/token` 端点核心代码:** `OAuth2OpenController.java` ```java @Tag(name = "管理后台 - OAuth2.0") @RestController @RequestMapping("/system/oauth2") @Validated public class OAuth2OpenController { @Resource private OAuth2GrantService oauth2GrantService; @Resource private OAuth2ClientService oauth2ClientService; @PostMapping("/token") @PermitAll @Operation(summary = "获得访问令牌", description = "支持 authorization_code / password / client_credentials / refresh_token 四种模式") public CommonResult postAccessToken( HttpServletRequest request, @RequestParam("grant_type") String grantType, @RequestParam(value = "code", required = false) String code, @RequestParam(value = "redirect_uri", required = false) String redirectUri, @RequestParam(value = "state", required = false) String state, @RequestParam(value = "username", required = false) String username, @RequestParam(value = "password", required = false) String password, @RequestParam(value = "scope", required = false) String scope, @RequestParam(value = "refresh_token", required = false) String refreshToken) { List scopes = OAuth2Utils.buildScopes(scope); OAuth2GrantTypeEnum grantTypeEnum = OAuth2GrantTypeEnum.getByGrantType(grantType); // 1. 解析 Basic Auth,获取 client_id 和 client_secret String[] clientIdAndSecret = obtainBasicAuthorization(request); // 2. 校验客户端合法性 OAuth2ClientDO client = oauth2ClientService.validOAuthClientFromCache( clientIdAndSecret[0], clientIdAndSecret[1], grantType, scopes, redirectUri); // 3. 根据授权模式分发处理 OAuth2AccessTokenDO accessTokenDO; switch (grantTypeEnum) { case AUTHORIZATION_CODE: accessTokenDO = oauth2GrantService.grantAuthorizationCodeForAccessToken( client.getClientId(), code, redirectUri, state); break; case PASSWORD: accessTokenDO = oauth2GrantService.grantPassword( username, password, client.getClientId(), scopes); break; case CLIENT_CREDENTIALS: accessTokenDO = oauth2GrantService.grantClientCredentials( client.getClientId(), scopes); break; case REFRESH_TOKEN: accessTokenDO = oauth2GrantService.grantRefreshToken( refreshToken, client.getClientId()); break; default: throw new IllegalArgumentException("未知授权类型:" + grantType); } Assert.notNull(accessTokenDO, "访问令牌不能为空"); return success(OAuth2OpenConvert.INSTANCE.convert(accessTokenDO)); } /** * 从请求头解析 Basic Auth 获取 client_id:client_secret */ private String[] obtainBasicAuthorization(HttpServletRequest request) { String header = request.getHeader("Authorization"); if (StrUtil.isEmpty(header) || !header.startsWith("Basic ")) { throw exception(ErrorCodeConstants.UNKNOWN); } String base64Credentials = header.substring(6); String credentials = new String(Base64.getDecoder().decode(base64Credentials)); return credentials.split(":", 2); } } ``` **各授权模式的实现:** `OAuth2GrantServiceImpl.java` ```java @Service public class OAuth2GrantServiceImpl implements OAuth2GrantService { @Resource private OAuth2TokenService oauth2TokenService; @Resource private AdminAuthService adminAuthService; /** * 密码模式:直接用用户名密码换 Token */ @Override public OAuth2AccessTokenDO grantPassword(String username, String password, String clientId, List scopes) { // BCrypt 验证用户名密码 AdminUserDO user = adminAuthService.authenticate(username, password); Assert.notNull(user, "用户不能为空!"); return oauth2TokenService.createAccessToken( user.getId(), UserTypeEnum.ADMIN.getValue(), clientId, scopes); } /** * 授权码模式:用 code 换取 Token */ @Override public OAuth2AccessTokenDO grantAuthorizationCodeForAccessToken( String clientId, String code, String redirectUri, String state) { OAuth2CodeDO codeDO = oauth2CodeService.validCode(code); Assert.notNull(codeDO, "授权码不存在"); // 校验 clientId、redirectUri、state 是否匹配 oauth2CodeService.validateCode(codeDO, clientId, redirectUri, state); // 删除已使用的授权码 oauth2CodeService.deleteCode(code); return oauth2TokenService.createAccessToken( codeDO.getUserId(), codeDO.getUserType(), clientId, codeDO.getScopes()); } /** * 客户端模式:系统用户 Token(userId=0) */ @Override public OAuth2AccessTokenDO grantClientCredentials(String clientId, List scopes) { return oauth2TokenService.createAccessToken( 0L, UserTypeEnum.ADMIN.getValue(), clientId, scopes); } /** * 刷新令牌:用 refreshToken 刷新 accessToken */ @Override public OAuth2AccessTokenDO grantRefreshToken(String refreshToken, String clientId) { return oauth2TokenService.refreshAccessToken(refreshToken, clientId); } } ``` ### 5.6 调用示例 ```bash # 密码模式:客户端凭证 Basic Auth + 用户密码换取 Token curl -X POST "https://test.jichengshanshui.com.cn:28302/admin-api/system/oauth2/token" \ -H "Authorization: Basic ZGVmYXVsdDphZG1pbjEyMw==" \ -d "grant_type=password&username=admin&password=admin123" # 客户端模式:仅用客户端凭证换取 Token curl -X POST "https://test.jichengshanshui.com.cn:28302/admin-api/system/oauth2/token" \ -H "Authorization: Basic ZGVmYXVsdDphZG1pbjEyMw==" \ -d "grant_type=client_credentials" # 刷新令牌 curl -X POST "https://test.jichengshanshui.com.cn:28302/admin-api/system/oauth2/token" \ -H "Authorization: Basic ZGVmYXVsdDphZG1pbjEyMw==" \ -d "grant_type=refresh_token&refresh_token=abc123..." # 返回格式 { "code": 0, "msg": "成功", "data": { "access_token": "a1b2c3d4...", "refresh_token": "p6o5n4m3...", "token_type": "bearer", "expires_in": 7200, "scope": "read write" } } ``` --- ## 6. API 签名鉴权(第三方对接) ### 6.1 概述 面向 `/open-api/` 前缀的第三方系统对接接口,通过 HMAC-SHA256 请求签名机制实现无状态的接口鉴权,防止请求被篡改、重放。 ### 6.2 核心类 | 类名 | 路径 | |------|------| | `@ApiSignature` 注解 | `urbanops-framework/urbanops-spring-boot-starter-protection/src/main/java/com/zteits/urbanops/framework/signature/core/annotation/ApiSignature.java` | | `ApiSignatureAspect` 切面 | `urbanops-framework/urbanops-spring-boot-starter-protection/src/main/java/com/zteits/urbanops/framework/signature/core/aop/ApiSignatureAspect.java` | ### 6.3 签名参数(请求头) | 请求头 | 类型 | 说明 | |--------|------|------| | `appId` | string | 应用唯一标识(由平台分配) | | `timestamp` | long | 请求时间戳(毫秒) | | `nonce` | string | 随机字符串(>= 10 位,防重放) | | `sign` | string | 签名字符串(SHA256 结果) | ### 6.4 签名规则 ``` 签名字符串 = sorted(queryParams, by key) + requestBody + sorted({appId, timestamp, nonce} headers, by key) + appSecret 最终签名 = SHA256(签名字符串) ``` ### 6.5 工作流程 ``` 客户端请求(带 appId, timestamp, nonce, sign 请求头) │ ▼ @ApiSignature 注解的方法 │ ▼ ApiSignatureAspect.beforePointCut() ← @Before AOP 拦截 │ ├── 1. verifyHeaders() 校验请求头完整性 │ ├── appId 非空 │ ├── timestamp 在时间窗口内(默认 60s) │ ├── nonce 长度 >= 10 │ └── sign 非空 │ ├── 2. 从 Redis 根据 appId 获取 appSecret │ ├── 3. 服务端按相同规则计算签名字符串 │ serverSignStr = sorted(queryParams) + body + sorted(headers) + appSecret │ ├── 4. 比对签名:SHA256(serverSignStr) == clientSign │ └── 5. nonce 防重放:存入 Redis(有效期 = timeout * 2) ``` ### 6.6 实现代码示例 **@ApiSignature 注解定义:** ```java @Inherited @Documented @Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) public @interface ApiSignature { /** 签名超时时间,默认 60 秒 */ int timeout() default 60; /** 超时时间单位,默认秒 */ TimeUnit timeUnit() default TimeUnit.SECONDS; /** 签名校验失败提示信息 */ String message() default "签名不正确"; /** 应用ID 请求头字段名 */ String appId() default "appId"; /** 时间戳 请求头字段名 */ String timestamp() default "timestamp"; /** 随机数 请求头字段名(长度 >= 10) */ String nonce() default "nonce"; /** 签名 请求头字段名 */ String sign() default "sign"; } ``` **签名校验切面实现:** `ApiSignatureAspect.java` ```java @Aspect @RequiredArgsConstructor public class ApiSignatureAspect { private final ApiSignatureRedisDAO signatureRedisDAO; /** * @Before 拦截所有标注 @ApiSignature 的方法 */ @Before("@annotation(signature)") public void beforePointCut(JoinPoint joinPoint, ApiSignature signature) { HttpServletRequest request = ServletUtils.getRequest(); if (!verifySignature(signature, request)) { throw new ServiceException(GlobalErrorCodeConstants.BAD_REQUEST.getCode(), signature.message()); } } /** * 完整的签名验证流程 */ public boolean verifySignature(ApiSignature signature, HttpServletRequest request) { // 1. 校验请求头完整性 if (!verifyHeaders(signature, request)) { return false; } // 2. 根据 appId 获取 appSecret String appId = request.getHeader(signature.appId()); String appSecret = signatureRedisDAO.getAppSecret(appId); Assert.notNull(appSecret, "[appId({})] 找不到对应的 appSecret", appId); // 3. 服务端按相同规则构造签名字符串 String serverSignatureString = buildSignatureString(signature, request, appSecret); String serverSignature = DigestUtil.sha256Hex(serverSignatureString); // 4. 比对客户端签名 String clientSignature = request.getHeader(signature.sign()); if (ObjUtil.notEqual(clientSignature, serverSignature)) { return false; } // 5. nonce 防重放(存入 Redis,过期时间 = timeout * 2) String nonce = request.getHeader(signature.nonce()); if (BooleanUtil.isFalse( signatureRedisDAO.setNonce(appId, nonce, signature.timeout() * 2, signature.timeUnit()))) { throw new ServiceException(GlobalErrorCodeConstants.REPEATED_REQUESTS.getCode(), "存在重复请求"); } return true; } /** * 校验请求头参数 */ private boolean verifyHeaders(ApiSignature signature, HttpServletRequest request) { String appId = request.getHeader(signature.appId()); String timestamp = request.getHeader(signature.timestamp()); String nonce = request.getHeader(signature.nonce()); String sign = request.getHeader(signature.sign()); // appId 不能为空 if (StrUtil.isBlank(appId)) return false; // timestamp 必须在有效时间窗口内 if (StrUtil.isBlank(timestamp)) return false; long ts = Long.parseLong(timestamp); long now = System.currentTimeMillis(); long timeoutMs = signature.timeUnit().toMillis(signature.timeout()); if (Math.abs(now - ts) > timeoutMs) return false; // nonce 长度必须 >= 10 if (StrUtil.length(nonce) < 10) return false; // sign 不能为空 if (StrUtil.isBlank(sign)) return false; return true; } /** * 构造签名字符串: * sorted(GET参数) + 请求体 + sorted(Header加签参数) + appSecret */ private String buildSignatureString(ApiSignature signature, HttpServletRequest request, String appSecret) { StringBuilder sb = new StringBuilder(); // 1. 排序后的查询参数 Map paramMap = request.getParameterMap(); if (CollUtil.isNotEmpty(paramMap)) { TreeMap sorted = new TreeMap<>(); paramMap.forEach((key, values) -> sorted.put(key, values[0])); sorted.forEach((key, value) -> sb.append(value)); } // 2. 请求体(POST/PUT 场景) String body = ServletUtils.getBody(request); if (StrUtil.isNotBlank(body)) { sb.append(body); } // 3. 排序后的加签头参数 TreeMap headerMap = new TreeMap<>(); headerMap.put(signature.appId(), request.getHeader(signature.appId())); headerMap.put(signature.timestamp(), request.getHeader(signature.timestamp())); headerMap.put(signature.nonce(), request.getHeader(signature.nonce())); headerMap.values().forEach(sb::append); // 4. 追加 appSecret sb.append(appSecret); return sb.toString(); } } ``` ### 6.7 使用示例 **服务端 Controller:** ```java @RestController @RequestMapping("/open-api/partner") public class PartnerOpenController { @PostMapping("/data-sync") @ApiSignature(timeout = 120, message = "签名验证失败,请检查签名参数") public CommonResult syncData(@RequestBody PartnerDataDTO data) { // 签名已在 AOP 层面自动验证,此处可以直接处理业务 partnerService.syncData(data); return success("同步成功"); } } ``` **客户端调用示例(Java):** ```java public class ApiSignatureClient { private static final String APP_ID = "your_app_id"; private static final String APP_SECRET = "your_app_secret"; public static String callOpenApi(String url, String requestBody) { // 1. 生成参数 String timestamp = String.valueOf(System.currentTimeMillis()); String nonce = RandomUtil.randomString(16); // 2. 构造签名字符串 String signStr = requestBody + APP_ID + timestamp + nonce + APP_SECRET; String sign = DigestUtil.sha256Hex(signStr); // 3. 发起请求 HttpResponse response = HttpRequest.post(url) .header("appId", APP_ID) .header("timestamp", timestamp) .header("nonce", nonce) .header("sign", sign) .header("Content-Type", "application/json") .body(requestBody) .execute(); return response.body(); } } ``` **cURL 调用示例:** ```bash # 计算签名(shell 示例) APP_ID="my_app_001" APP_SECRET="my_secret_key" TIMESTAMP=$(date +%s%3N) NONCE=$(openssl rand -hex 16) BODY='{"name":"test","value":123}' SIGN_STR="${BODY}${APP_ID}${TIMESTAMP}${NONCE}${APP_SECRET}" SIGN=$(echo -n "$SIGN_STR" | openssl dgst -sha256 -hex | awk '{print $2}') # 发起请求 curl -X POST "https://test.jichengshanshui.com.cn:28302/open-api/partner/data-sync" \ -H "Content-Type: application/json" \ -H "appId: ${APP_ID}" \ -H "timestamp: ${TIMESTAMP}" \ -H "nonce: ${NONCE}" \ -H "sign: ${SIGN}" \ -d "${BODY}" # 签名失败返回 { "code": 400, "msg": "签名验证失败,请检查签名参数" } ``` --- ## 7. 社交登录鉴权(JustAuth) ### 7.1 概述 系统集成 JustAuth 1.16.7,支持 30+ 第三方社交平台登录。用户可通过微信、QQ、钉钉、GitHub 等平台授权后登录系统。 ### 7.2 核心类 | 类名 | 路径 | |------|------| | `AuthRequestFactory` | `urbanops-module-system/.../framework/justauth/core/AuthRequestFactory.java` | | `UrbanopsJustAuthConfiguration` | `urbanops-module-system/.../framework/justauth/config/UrbanopsJustAuthConfiguration.java` | | `RedisStateCache` | JustAuth 内置(State 存储到 Redis) | ### 7.3 支持的平台 GitHub · 微信开放平台 · 微信公众号 · 微信小程序 · 企业微信 · 微信网站应用 · QQ · 微博 · 钉钉 · 支付宝 · Google · Facebook · Apple · 飞书 · 以及 30+ 其他平台 ### 7.4 工作流程 ``` 用户点击"社交登录" │ ▼ GET /system/auth/social-auth-redirect?type={platform}&redirectUri={url} │ ├── AuthRequestFactory.get(type) 获取对应平台的 AuthRequest ├── 生成授权 URL(含 state 参数,state 存入 Redis) └── 前端 302 跳转到第三方授权页面 │ ▼ 用户授权后,第三方回调 redirectUri(带 code + state 参数) │ ▼ 前端提取 code + state,调用后端 │ ▼ POST /system/auth/social-login { type, code, state } │ ├── 1. AuthRequestFactory.get(type).login(callback) │ ├── getAccessToken(AuthCallback) → 用 code 换 accessToken │ └── getUserInfo(AuthToken) → 获取用户信息 │ ├── 2. 根据 socialUserId 查找绑定关系 ├── 3. 自动注册 / 绑定已有用户 └── 4. createTokenAfterLoginSuccess() ``` ### 7.5 实现代码示例 **JustAuth 配置类:** `UrbanopsJustAuthConfiguration.java` ```java @Configuration(proxyBeanMethods = false) @EnableConfigurationProperties({JustAuthProperties.class}) public class UrbanopsJustAuthConfiguration { /** * 创建 AuthRequest 工厂(条件 Bean:justauth.enabled=true 时激活) */ @Bean @ConditionalOnProperty(prefix = "justauth", value = {"enabled"}, havingValue = "true", matchIfMissing = true) public AuthRequestFactory authRequestFactory(JustAuthProperties properties, AuthStateCache authStateCache) { return new AuthRequestFactory(properties, authStateCache); } /** * OAuth2 State 参数缓存(Redis 存储,防 CSRF) */ @Bean public AuthStateCache authStateCache( RedisTemplate justAuthRedisCacheTemplate, JustAuthProperties justAuthProperties) { return new RedisStateCache(justAuthRedisCacheTemplate, justAuthProperties.getCache()); } } ``` **社交登录请求工厂:** `AuthRequestFactory.java` ```java public class AuthRequestFactory { private final JustAuthProperties properties; private final AuthStateCache authStateCache; private final ConcurrentHashMap requestCache = new ConcurrentHashMap<>(); /** * 获取指定平台的授权请求对象 */ public AuthRequest get(String source) { return requestCache.computeIfAbsent(source, this::getDefaultRequest); } /** * 根据平台名称创建对应的 JustAuth 请求类 */ private AuthRequest getDefaultRequest(String source) { AuthConfig config = properties.getType().get(source); Assert.notNull(config, "平台 [{}] 的配置不存在", source); // 注入自定义 state 缓存(Redis) config.setAuthStateCache(authStateCache); switch (source.toUpperCase()) { case "GITHUB": return new AuthGithubRequest(config, authStateCache); case "WECHAT_OPEN": return new AuthWeChatOpenRequest(config, authStateCache); case "WECHAT_MP": return new AuthWeChatMpRequest(config, authStateCache); case "QQ": return new AuthQqRequest(config, authStateCache); case "DINGTALK": return new AuthDingTalkRequest(config, authStateCache); case "FEISHU": return new AuthFeishuRequest(config, authStateCache); case "ALIPAY": return new AuthAlipayRequest(config, authStateCache); case "GOOGLE": return new AuthGoogleRequest(config, authStateCache); case "FACEBOOK": return new AuthFacebookRequest(config, authStateCache); case "APPLE": return new AuthAppleRequest(config, authStateCache); case "UAA": return new AuthUaaRequest(config, authStateCache); // 自定义 UAA SSO // ... 30+ 其他平台 ... default: return null; } } } ``` ### 7.6 调用示例 ```bash # 步骤1: 获取社交登录授权 URL curl -X GET "https://test.jichengshanshui.com.cn:28302/admin-api/system/auth/social-auth-redirect?type=GITHUB&redirectUri=https://example.com/callback" # 返回 { "code": 0, "data": { "url": "https://github.com/login/oauth/authorize?client_id=xxx&redirect_uri=xxx&state=xxx" } } # 步骤2: 用户跳转到 GitHub 授权,回调到 redirectUri # 浏览器地址栏: https://example.com/callback?code=abc123&state=xxx # 步骤3: 前端提取 code + state,调用后端完成登录 curl -X POST "https://test.jichengshanshui.com.cn:28302/admin-api/system/auth/social-login" \ -H "Content-Type: application/json" \ -d '{ "type": "GITHUB", "code": "abc123", "state": "xxx" }' # 返回(同密码登录) { "code": 0, "msg": "成功", "data": { "accessToken": "token...", "refreshToken": "refresh..." } } ``` --- ## 8. UAA SSO 单点登录 ### 8.1 概述 系统对接集团统一认证平台 UAA(Unified Authentication Application),通过自定义 `AuthUaaRequest` 实现企业级单点登录。用户登录 UAA 后即可无缝访问 UrbanOps。 ### 8.2 核心类 | 类名 | 路径 | |------|------| | `AuthUaaRequest` | `urbanops-module-system/.../framework/justauth/core/AuthUaaRequest.java` | | `SsoService` | `urbanops-module-system/.../service/oauth2/SsoService.java` | | `CustomerAuthSource.UAA` | 自定义 AuthSource 枚举值 | ### 8.3 UAA 配置 ```yaml sso: client: switch-state: true client-id: urbanops client-secret: ${SSO_CLIENT_SECRET} base-url: https://uaa.fangshanparking.com:28201 redirect-uri: ${urbanops.base-url}/admin-api/system/auth/social-login jwt-public-key: MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A... ``` ### 8.4 工作流程 ``` 用户访问 UrbanOps → 未登录 → 重定向到 UAA 登录页 │ ▼ UAA 登录成功后回调 redirectUri(带 authorization_code) │ ▼ POST /system/auth/social-login { type: "UAA", code, state } │ ▼ AuthUaaRequest ├── getAccessToken(callback) │ └── POST https://uaa.fangshanparking.com:28201/oauth2/token │ code=xxx&grant_type=authorization_code&redirect_uri=xxx │ └── getUserInfo(authToken) └── 使用 pub.cer 公钥解密 JWT token(RSA256) ├── 提取 account(工号) ├── 提取公司 ID ├── 提取业务线 └── 构建 AuthUser 对象 ``` ### 8.5 实现代码示例 **自定义 UAA JustAuth 请求:** `AuthUaaRequest.java` ```java public class AuthUaaRequest extends AuthDefaultRequest { public AuthUaaRequest(AuthConfig config, AuthStateCache authStateCache) { super(config, CustomerAuthSource.UAA, authStateCache); } /** * 步骤1:用 authorization_code 向 UAA 服务器换取 access_token */ @Override public AuthToken getAccessToken(AuthCallback authCallback) { String tokenUrl = source.accessToken(); // 构造请求体 Map params = new HashMap<>(); params.put("code", authCallback.getCode()); params.put("grant_type", "authorization_code"); params.put("redirect_uri", authCallback.getRedirectUri()); // POST /oauth2/token String response = new HttpUtils(config.getHttpConfig()) .post(tokenUrl, params, this.config.isIgnoreRedirect()); JSONObject json = JSONUtil.parseObj(response); // 校验响应 checkResponse(json); return AuthToken.builder() .accessToken(json.getStr("access_token")) // ...其他字段... .build(); } /** * 步骤2:从 JWT access_token 中解析用户身份信息 */ @Override public AuthUser getUserInfo(AuthToken authToken) { try { // 从 classpath 读取 UAA 公钥证书 CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509"); ClassPathResource resource = new ClassPathResource("pub.cer"); Certificate certificate = certificateFactory.generateCertificate(resource.getInputStream()); RSAPublicKey publicKey = (RSAPublicKey) certificate.getPublicKey(); // 创建 JWT 验证器(RSA256 算法) Algorithm algorithm = Algorithm.RSA256(publicKey, null); JWTVerifier verifier = JWT.require(algorithm) .acceptLeeway(60) // 允许 60 秒时钟偏差 .build(); // 验证并解析 JWT DecodedJWT decodedJWT = verifier.verify(authToken.getAccessToken()); DecodedJWT jwt = JWT.decode(authToken.getAccessToken()); // 提取 JWT payload 中的用户信息 String staffNo = jwt.getClaim("account").asString(); // 工号 String companyId = jwt.getClaim("company_id").asString(); // 公司ID String busiLine = jwt.getClaim("busi_line").asString(); // 业务线 return AuthUser.builder() .uuid(staffNo) .username(staffNo) .nickname(staffNo) .gender(AuthUserGender.UNKNOWN) .token(authToken) .source(this.source.toString()) .build(); } catch (Exception e) { throw new BusinessException("UAA SSO 登录失败:" + e.getMessage(), e); } } /** * 校验 UAA 返回的 Token 响应 */ private void checkResponse(JSONObject json) { if (json.containsKey("error")) { throw new AuthException(json.getStr("error_description")); } } } ``` --- ## 9. RBAC 权限鉴权 ### 9.1 概述 系统采用基于角色的访问控制(RBAC)模型,通过 Spring Security 的 `@PreAuthorize` 注解在方法级别进行权限控制。权限表达式中的 `@ss` Bean 提供 `hasPermission`、`hasRole`、`hasScope` 三个维度的权限判断。 ### 9.2 核心类 | 类名 | 路径 | |------|------| | `SecurityFrameworkServiceImpl` (Bean name: `ss`) | `urbanops-framework/urbanops-spring-boot-starter-security/src/main/java/com/zteits/urbanops/framework/security/core/service/SecurityFrameworkServiceImpl.java` | | `PermissionCommonApi` | `urbanops-framework/urbanops-common/src/main/java/com/zteits/urbanops/framework/common/biz/system/permission/PermissionCommonApi.java` | ### 9.3 权限格式规范 ``` {模块}:{实体}:{操作} 示例: system:dept:create — 系统管理 · 部门 · 创建 system:dept:update — 系统管理 · 部门 · 更新 system:user:query — 系统管理 · 用户 · 查询 workorder:event-info:create — 工单调度 · 事件 · 创建 workorder:event-info:end — 工单调度 · 事件 · 结单 bpm:garden-inspection:create— 工作流 · 园林巡检 · 创建 ``` ### 9.4 权限方法一览 | SpEL 表达式 | 说明 | |-------------|------| | `@ss.hasPermission('perm')` | 检查单一权限 | | `@ss.hasAnyPermissions('a','b')` | 检查任一权限(满足一个即可) | | `@ss.hasRole('admin')` | 检查单一角色 | | `@ss.hasAnyRoles('admin','manager')` | 检查任一角色 | | `@ss.hasScope('read')` | 检查 OAuth2 scope | ### 9.5 实现代码示例 **SecurityFrameworkServiceImpl — `@ss` Bean 实现:** ```java /** * 安全框架服务实现 * Bean 名称:ss * 用于 @PreAuthorize("@ss.hasPermission(...)") 等表达式 */ @Service("ss") public class SecurityFrameworkServiceImpl implements SecurityFrameworkService { @Resource private PermissionCommonApi permissionApi; @Override public boolean hasPermission(String permission) { return hasAnyPermissions(permission); } @Override public boolean hasAnyPermissions(String... permissions) { // 特殊场景:跨租户访问时,跳过权限校验 if (skipPermissionCheck()) { return true; } // 标准 RBAC 权限校验 Long userId = getLoginUserId(); if (userId == null) { return false; } // 调用权限公共 API(查询用户角色 → 角色菜单 → 菜单权限标识) return permissionApi.hasAnyPermissions(userId, permissions); } @Override public boolean hasAnyRoles(String... roles) { if (skipPermissionCheck()) { return true; } Long userId = getLoginUserId(); if (userId == null) { return false; } return permissionApi.hasAnyRoles(userId, roles); } @Override public boolean hasAnyScopes(String... scope) { if (skipPermissionCheck()) { return true; } LoginUser user = SecurityFrameworkUtils.getLoginUser(); if (user == null) { return false; } // 检查 LoginUser 的 scopes 列表中是否包含目标 scope return CollUtil.containsAny(user.getScopes(), Arrays.asList(scope)); } /** * 跨租户访问时自动跳过权限检查 * 逻辑:如果当前请求租户ID != 用户所属租户ID,说明是跨租户访问,自动放行 */ private boolean skipPermissionCheck() { LoginUser user = SecurityFrameworkUtils.getLoginUser(); if (user == null) return false; Long visitTenantId = TenantContextHolder.getTenantId(); return visitTenantId != null && !Objects.equals(user.getTenantId(), visitTenantId); } } ``` **Controller 中的 @PreAuthorize 使用示例:** `TenantController.java` ```java @Tag(name = "管理后台 - 租户") @RestController @RequestMapping("/system/tenant") @Validated public class TenantController { @Resource private TenantService tenantService; @PostMapping("/create") @Operation(summary = "创建租户") @PreAuthorize("@ss.hasPermission('system:tenant:create')") public CommonResult createTenant(@Valid @RequestBody TenantSaveReqVO createReqVO) { return success(tenantService.createTenant(createReqVO)); } @PutMapping("/update") @Operation(summary = "更新租户") @PreAuthorize("@ss.hasPermission('system:tenant:update')") public CommonResult updateTenant(@Valid @RequestBody TenantSaveReqVO updateReqVO) { tenantService.updateTenant(updateReqVO); return success(true); } @DeleteMapping("/delete") @Operation(summary = "删除租户") @PreAuthorize("@ss.hasPermission('system:tenant:delete')") public CommonResult deleteTenant(@RequestParam("id") Long id) { tenantService.deleteTenant(id); return success(true); } @GetMapping("/page") @Operation(summary = "获得租户分页") @PreAuthorize("@ss.hasPermission('system:tenant:query')") public CommonResult> getTenantPage(@Valid TenantPageReqVO pageReqVO) { return success(tenantService.getTenantPage(pageReqVO)); } } ``` ### 9.6 前端权限控制 ```vue ``` --- ## 10. 租户隔离鉴权 ### 10.1 概述 系统为多租户 SaaS 架构,通过 `TenantSecurityWebFilter` 在请求级别强制租户隔离,确保用户只能访问自己所属租户的数据,防止租户间数据越权。 ### 10.2 核心类 | 类名 | 路径 | |------|------| | `TenantSecurityWebFilter` | `urbanops-framework/urbanops-spring-boot-starter-biz-tenant/src/main/java/com/zteits/urbanops/framework/tenant/core/security/TenantSecurityWebFilter.java` | | `TenantContextHolder` | 租户上下文持有者(ThreadLocal) | ### 10.3 工作流程 ``` 请求到达 │ ▼ TenantSecurityWebFilter.doFilterInternal() │ ├── 1. 如果请求头/参数中没有 tenantId │ └── 自动从 LoginUser.tenantId 填充 │ ├── 2. 如果请求中的 tenantId != LoginUser.tenantId │ └── 判定为跨租户越权访问 → 直接返回 403 │ ├── 3. 如果 tenantId 为空 且 不在白名单中 │ └── 返回 400 "请求的租户标识未传递" │ ├── 4. 校验租户合法性 │ └── tenantFrameworkService.validTenant(tenantId) │ ├── 租户存在 │ ├── 租户未被禁用 │ └── 租户未过期 │ └── 5. 放行 ``` ### 10.4 实现代码示例 **租户隔离过滤器:** `TenantSecurityWebFilter.java` ```java @RequiredArgsConstructor public class TenantSecurityWebFilter extends ApiRequestFilter { private final TenantProperties tenantProperties; private final TenantFrameworkService tenantFrameworkService; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { Long tenantId = TenantContextHolder.getTenantId(); LoginUser user = SecurityFrameworkUtils.getLoginUser(); // ========== 1. 已登录用户的租户校验 ========== if (user != null) { if (tenantId == null) { // 请求未带租户ID → 自动使用用户所属租户 tenantId = user.getTenantId(); TenantContextHolder.setTenantId(tenantId); } else if (!Objects.equals(user.getTenantId(), tenantId)) { // 跨租户访问 → 拒绝请求 log.error("[doFilterInternal][租户({}) User({}/{}) 越权访问租户({}) URL({}/{})]", user.getTenantId(), user.getId(), user.getUserType(), tenantId, request.getRequestURI(), request.getMethod()); ServletUtils.writeJSON(response, CommonResult.error(GlobalErrorCodeConstants.FORBIDDEN.getCode(), "您无权访问该租户的数据")); return; // ← 直接中断请求 } } // ========== 2. 租户合法性校验 ========== if (!isIgnoreUrl(request)) { // 非白名单 URL:必须有租户ID if (tenantId == null) { ServletUtils.writeJSON(response, CommonResult.error(GlobalErrorCodeConstants.BAD_REQUEST.getCode(), "请求的租户标识未传递,请进行排查")); return; } // 校验租户是否有效(未被禁用、未过期等) tenantFrameworkService.validTenant(tenantId); } else { // 白名单 URL(如登录接口):允许无租户ID if (tenantId == null) { TenantContextHolder.setIgnore(true); } } chain.doFilter(request, response); } /** * 判断当前 URL 是否在租户忽略白名单中 */ private boolean isIgnoreUrl(HttpServletRequest request) { return tenantProperties.getIgnoreUrls().stream() .anyMatch(url -> WebFrameworkUtils.match(url, request)); } } ``` ### 10.5 配置示例 ```yaml urbanops: tenant: ignore-urls: - /system/auth/login - /system/auth/sms-login - /system/oauth2/** - /swagger-ui/** - /v3/api-docs/** ``` --- ## 11. 数据权限鉴权(行级安全) ### 11.1 概述 数据权限是 RBAC 权限模型的补充,在 SQL 层面注入 `WHERE` 条件,实现行级数据过滤。用户根据其数据权限范围,只能看到: - **全部数据**(ALL) - **本部门及下级部门数据**(DEPT_SCOPE) - **仅本人数据**(SELF) ### 11.2 核心类 | 类名 | 路径 | |------|------| | `@DataPermission` 注解 | `urbanops-framework/urbanops-spring-boot-starter-biz-data-permission/src/main/java/com/zteits/urbanops/framework/datapermission/core/annotation/DataPermission.java` | | `DeptDataPermissionRule` | `urbanops-framework/urbanops-spring-boot-starter-biz-data-permission/src/main/java/com/zteits/urbanops/framework/datapermission/core/rule/dept/DeptDataPermissionRule.java` | ### 11.3 @DataPermission 注解 ```java @Target({ElementType.TYPE, ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface DataPermission { /** 是否启用数据权限,默认 true */ boolean enable() default true; /** 指定启用的数据权限规则 */ Class[] includeRules() default {}; /** 指定排除的数据权限规则 */ Class[] excludeRules() default {}; } ``` ### 11.4 SQL 注入逻辑 | 数据权限范围 | 生成的 SQL WHERE 条件 | |-------------|--------------------| | ALL(全部)| 不注入条件(查全部)| | DEPT(部门)| `WHERE dept_id IN (1, 2, 3)` | | SELF(仅本人)| `WHERE creator = 5`(或 `user_id = 5`)| | DEPT + SELF | `WHERE (dept_id IN (1, 2, 3) OR creator = 5)` | | NONE(无权限)| `WHERE null = null` | ### 11.5 实现代码示例 **部门数据权限规则:** `DeptDataPermissionRule.java` ```java @Component public class DeptDataPermissionRule implements DataPermissionRule { private static final String CONTEXT_KEY = DeptDataPermissionRule.class.getSimpleName(); @Resource private PermissionCommonApi permissionApi; /** * 根据当前用户的数据权限范围,构造 SQL 过滤表达式 */ @Override public Expression getExpression(String tableName, Alias tableAlias) { LoginUser loginUser = SecurityFrameworkUtils.getLoginUser(); if (loginUser == null) { return null; } // 仅对 ADMIN 类型用户生效 if (ObjectUtil.notEqual(loginUser.getUserType(), UserTypeEnum.ADMIN.getValue())) { return null; } // 从缓存或远程 API 获取用户的数据权限配置 DeptDataPermissionRespDTO deptDataPermission = loginUser.getContext( CONTEXT_KEY, DeptDataPermissionRespDTO.class); if (deptDataPermission == null) { deptDataPermission = permissionApi.getDeptDataPermission(loginUser.getId()); loginUser.setContext(CONTEXT_KEY, deptDataPermission); } // 情况1: 全部数据权限 → 不注入任何条件 if (deptDataPermission.getAll()) { return null; } // 情况2: 既无部门权限,也无本人权限 → 查不到任何数据 if (CollUtil.isEmpty(deptDataPermission.getDeptIds()) && Boolean.FALSE.equals(deptDataPermission.getSelf())) { return new EqualsTo(null, null); // WHERE null = null } // 情况3: 拼接部门和本人的 OR 条件 Expression deptExpression = buildDeptExpression( tableName, tableAlias, deptDataPermission.getDeptIds()); Expression userExpression = buildUserExpression( tableName, tableAlias, deptDataPermission.getSelf(), loginUser.getId()); if (deptExpression == null) return userExpression; if (userExpression == null) return deptExpression; // 组合: (dept_id IN (1,2,3) OR creator = 5) return new ParenthesizedExpressionList( new OrExpression(deptExpression, userExpression)); } /** * 构造部门条件:dept_id IN (1, 2, 3) */ private Expression buildDeptExpression(String tableName, Alias tableAlias, Set deptIds) { if (CollUtil.isEmpty(deptIds)) { return null; } return new InExpression(new Column(tableAlias, "dept_id"), new ExpressionList(deptIds)); } /** * 构造本人条件:creator = 5(或 user_id = 5) */ private Expression buildUserExpression(String tableName, Alias tableAlias, Boolean self, Long userId) { if (BooleanUtil.isFalse(self)) { return null; } return new EqualsTo(new Column(tableAlias, "creator"), new LongValue(userId)); } } ``` **Service 层使用示例:** `AdminAuthServiceImpl.java` ```java @Service public class AdminAuthServiceImpl implements AdminAuthService { /** * 登录方法:不需要数据权限过滤 * 通过 @DataPermission(enable = false) 禁用行级过滤 */ @Override @DataPermission(enable = false) public AuthLoginRespVO login(AuthLoginReqVO reqVO) { AdminUserDO user = authenticate(reqVO.getUsername(), reqVO.getPassword()); return createTokenAfterLoginSuccess(user.getId(), reqVO.getUsername(), LoginLogTypeEnum.LOGIN_USERNAME); } /** * 列表查询方法:默认启用数据权限 * 用户只能看到自己部门或自己的数据 */ @Override public PageResult getUserPage(AdminUserPageReqVO reqVO) { // MyBatis 查询时会自动注入 dept_id IN (...) OR creator = ? 条件 Page page = userMapper.selectPage(reqVO, new LambdaQueryWrapperX() .likeIfPresent(AdminUserDO::getUsername, reqVO.getUsername())); return AdminUserConvert.INSTANCE.convertPage(page); } } ``` --- ## 12. API 加密 ### 12.1 概述 系统支持对请求体和响应体进行 AES 或 RSA 加解密,通过 `@ApiEncrypt` 注解在方法级别控制,防止敏感数据在传输过程中被窃取。 ### 12.2 核心类 | 类名 | 路径 | |------|------| | `@ApiEncrypt` 注解 | `urbanops-framework/urbanops-spring-boot-starter-web/src/main/java/com/zteits/urbanops/framework/encrypt/core/annotation/ApiEncrypt.java` | | `ApiEncryptFilter` | `urbanops-framework/urbanops-spring-boot-starter-web/src/main/java/com/zteits/urbanops/framework/encrypt/core/filter/ApiEncryptFilter.java` | ### 12.3 @ApiEncrypt 注解 ```java @Documented @Target({ElementType.TYPE, ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) public @interface ApiEncrypt { /** 是否对请求参数进行解密(默认 true) */ boolean request() default true; /** 是否对响应结果进行加密(默认 true) */ boolean response() default true; } ``` ### 12.4 实现代码示例 **API 加密过滤器:** `ApiEncryptFilter.java` ```java @RequiredArgsConstructor public class ApiEncryptFilter extends ApiRequestFilter { private final ApiEncryptProperties apiEncryptProperties; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { // 1. 获取 Controller 方法上的 @ApiEncrypt 注解 ApiEncrypt apiEncrypt = getApiEncrypt(request); boolean requestEnable = apiEncrypt != null && apiEncrypt.request(); boolean responseEnable = apiEncrypt != null && apiEncrypt.response(); String encryptHeader = request.getHeader(apiEncryptProperties.getHeader()); // 不需要加解密 → 直接放行 if (!requestEnable && !responseEnable && StrUtil.isBlank(encryptHeader)) { chain.doFilter(request, response); return; } // 2. 解密请求体(POST/PUT/DELETE 请求) if (ObjectUtils.equalsAny(HttpMethod.valueOf(request.getMethod()), HttpMethod.POST, HttpMethod.PUT, HttpMethod.DELETE)) { if (StrUtil.isNotBlank(encryptHeader)) { // 使用加密请求包装器(AES 或 RSA 解密) request = new ApiDecryptRequestWrapper(request, requestSymmetricDecryptor, // AES 解密器 requestAsymmetricDecryptor); // RSA 解密器 } else if (requestEnable) { throw invalidParamException("请求未包含加密标头,请检查是否正确配置了加密标头"); } } // 3. 包装响应对象(用于后续加密输出) if (responseEnable) { response = new ApiEncryptResponseWrapper(response); } // 4. 执行后续过滤器链 chain.doFilter(request, response); // 5. 加密响应体 if (responseEnable) { ((ApiEncryptResponseWrapper) response).encrypt( apiEncryptProperties, responseSymmetricEncryptor, // AES 加密器 responseAsymmetricEncryptor); // RSA 加密器 } } } ``` ### 12.5 使用示例 ```java @RestController @RequestMapping("/admin-api/system/user") public class UserController { /** * 创建用户:请求体加密传输,响应结果也加密返回 */ @PostMapping("/create") @ApiEncrypt(request = true, response = true) @PreAuthorize("@ss.hasPermission('system:user:create')") public CommonResult createUser(@RequestBody UserSaveReqVO reqVO) { return success(userService.createUser(reqVO)); } } ``` ```yaml # application.yaml urbanops: api-encrypt: enable: true header: X-Encrypt # 加密请求头标识 algorithm: AES # AES / RSA request-aes-key: ${API_ENCRYPT_REQ_KEY} response-aes-key: ${API_ENCRYPT_RESP_KEY} ``` --- ## 13. 接口前缀与鉴权方式对照表 | 接口前缀 | 鉴权方式 | 认证类型 | 权限控制 | 租户隔离 | 适用场景 | |----------|----------|----------|----------|----------|----------| | `/admin-api/` | Bearer Token + RBAC | Token(登录后获取) | `@PreAuthorize("@ss.hasPermission(...)")` | ✅ 强制 | 管理后台 Web | | `/app-api/` | Bearer Token | Token(移动端登录获取) | 宽松(大多 @PermitAll) | ✅ 强制 | 移动端 App / 小程序 | | `/open-api/` | HMAC-SHA256 签名 | appId + appSecret 签名 | 无(由签名保证) | ❌ | 第三方系统对接 | | `/pub-api/` | 无 | 无 | 无 | ❌ | 支付回调等公开回调 | | `/system/auth/*` | 无(`@PermitAll`) | BCrypt 密码 / 短信验证码 / 社交登录 | 无 | ❌ | 登录入口 | | `/system/oauth2/*` | client_id + client_secret (Basic Auth) | OAuth2 标准协议 | OAuth2 Scope | ❌ | 授权服务器端点 | | `/swagger-ui/**` | 无 | 无 | 无 | ❌ | API 文档(开发环境) | | `/actuator/**` | 无(可配置) | 无 | 无 | ❌ | 健康检查 / 监控 | | WebSocket | `?token=xxx` 查询参数 | Token | 无 | ❌ | WebSocket 连接 | --- ## 附录:完整鉴权流程图 ``` ┌─────────────────────────────┐ │ 客户端请求到达 │ └──────────┬──────────────────┘ │ ┌──────────▼──────────────────┐ │ 1. ApiEncryptFilter │ │ @ApiEncrypt 注解的方法 │ │ 解密请求体(AES/RSA) │ └──────────┬──────────────────┘ │ ┌──────────▼──────────────────┐ │ 2. TenantSecurityWebFilter │ │ Tenant 租户隔离校验 │ │ 防止跨租户越权 │ └──────────┬──────────────────┘ │ ┌──────────▼──────────────────┐ │ 3. TokenAuthenticationFilter │ │ Bearer Token 提取 & 校验 │ │ userType 匹配检查 │ └──────────┬──────────────────┘ │ ┌──────────▼──────────────────┐ │ 4. Spring Security │ │ @PreAuthorize │ │ "@ss.hasPermission(...)" │ │ "@ss.hasRole(...)" │ │ "@ss.hasScope(...)" │ └──────────┬──────────────────┘ │ ┌──────────▼──────────────────┐ │ 5. @ApiSignature AOP │ │ (仅 /open-api/ 接口) │ │ HMAC-SHA256 签名验证 │ └──────────┬──────────────────┘ │ ┌──────────▼──────────────────┐ │ 6. Controller 方法执行 │ └──────────┬──────────────────┘ │ ┌──────────▼──────────────────┐ │ 7. DeptDataPermissionRule │ │ MyBatis SQL 行级过滤 │ │ dept_id IN (...) OR │ │ creator = ? │ └──────────┬──────────────────┘ │ ┌──────────▼──────────────────┐ │ 8. ApiEncryptResponseWrapper │ │ 加密响应体(AES/RSA) │ └──────────┬──────────────────┘ │ ┌──────────▼──────────────────┐ │ 返回客户端 │ └─────────────────────────────┘ ``` --- > 📌 **本文档基于 2026-06-11 项目代码分析生成,如项目安全机制有变更,请同步更新本文档。**