UrbanOps 系统鉴权方式说明文档
项目: 蓟城山水集团全域智能运营管理平台 (UrbanOps)
版本: 基于 2026-06-11 代码分析
适用对象: 前端开发、第三方对接、运维部署
目录
- 1. 鉴权体系概览
- 2. Token 令牌鉴权(核心机制)
- 3. 用户名密码登录鉴权
- 4. 短信验证码登录鉴权
- 5. OAuth2 授权服务器
- 6. API 签名鉴权(第三方对接)
- 7. 社交登录鉴权(JustAuth)
- 8. UAA SSO 单点登录
- 9. RBAC 权限鉴权
- 10. 租户隔离鉴权
- 11. 数据权限鉴权(行级安全)
- 12. API 加密
- 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 配置项
# 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
@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
@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<String> 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 调用示例
# 请求管理后台接口(需要在请求头带上 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
@Tag(name = "管理后台 - 认证")
@RestController
@RequestMapping("/system/auth")
@Validated
public class AuthController {
@Resource
private AdminAuthService authService;
@PostMapping("/login")
@PermitAll
@Operation(summary = "使用账号密码登录")
public CommonResult<AuthLoginRespVO> login(@RequestBody @Valid AuthLoginReqVO reqVO) {
return success(authService.login(reqVO));
}
}
Service 实现层 — 身份验证: AdminAuthServiceImpl.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 调用示例
# 用户名密码登录
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
@PostMapping("/send-sms-code")
@PermitAll
@Operation(summary = "发送手机验证码")
public CommonResult<Boolean> 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<AuthLoginRespVO> smsLogin(@RequestBody @Valid AuthSmsLoginReqVO reqVO) {
return success(authService.smsLogin(reqVO));
}
Service 实现层: AdminAuthServiceImpl.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 调用示例
# 步骤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
@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<OAuth2OpenAccessTokenRespVO> 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<String> 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
@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<String> 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<String> 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 调用示例
# 密码模式:客户端凭证 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 注解定义:
@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
@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<String, String[]> paramMap = request.getParameterMap();
if (CollUtil.isNotEmpty(paramMap)) {
TreeMap<String, String> 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<String, String> 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:
@RestController
@RequestMapping("/open-api/partner")
public class PartnerOpenController {
@PostMapping("/data-sync")
@ApiSignature(timeout = 120, message = "签名验证失败,请检查签名参数")
public CommonResult<String> syncData(@RequestBody PartnerDataDTO data) {
// 签名已在 AOP 层面自动验证,此处可以直接处理业务
partnerService.syncData(data);
return success("同步成功");
}
}
客户端调用示例(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 调用示例:
# 计算签名(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
@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<String, String> justAuthRedisCacheTemplate,
JustAuthProperties justAuthProperties) {
return new RedisStateCache(justAuthRedisCacheTemplate,
justAuthProperties.getCache());
}
}
社交登录请求工厂: AuthRequestFactory.java
public class AuthRequestFactory {
private final JustAuthProperties properties;
private final AuthStateCache authStateCache;
private final ConcurrentHashMap<String, AuthRequest> 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 调用示例
# 步骤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 配置
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
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<String, String> 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 实现:
/**
* 安全框架服务实现
* 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
@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<Long> createTenant(@Valid @RequestBody TenantSaveReqVO createReqVO) {
return success(tenantService.createTenant(createReqVO));
}
@PutMapping("/update")
@Operation(summary = "更新租户")
@PreAuthorize("@ss.hasPermission('system:tenant:update')")
public CommonResult<Boolean> updateTenant(@Valid @RequestBody TenantSaveReqVO updateReqVO) {
tenantService.updateTenant(updateReqVO);
return success(true);
}
@DeleteMapping("/delete")
@Operation(summary = "删除租户")
@PreAuthorize("@ss.hasPermission('system:tenant:delete')")
public CommonResult<Boolean> deleteTenant(@RequestParam("id") Long id) {
tenantService.deleteTenant(id);
return success(true);
}
@GetMapping("/page")
@Operation(summary = "获得租户分页")
@PreAuthorize("@ss.hasPermission('system:tenant:query')")
public CommonResult<PageResult<TenantRespVO>> getTenantPage(@Valid TenantPageReqVO pageReqVO) {
return success(tenantService.getTenantPage(pageReqVO));
}
}
9.6 前端权限控制
<template>
<!-- 按钮级别权限控制(v-hasPermi 指令) -->
<el-button v-hasPermi="['system:tenant:create']" type="primary">
新增租户
</el-button>
<!-- 角色级别权限控制(v-hasRole 指令) -->
<div v-hasRole="['admin']">
管理员专属内容
</div>
</template>
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
@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 配置示例
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 注解
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DataPermission {
/** 是否启用数据权限,默认 true */
boolean enable() default true;
/** 指定启用的数据权限规则 */
Class<? extends DataPermissionRule>[] includeRules() default {};
/** 指定排除的数据权限规则 */
Class<? extends DataPermissionRule>[] 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
@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<Long> 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
@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<AdminUserRespVO> getUserPage(AdminUserPageReqVO reqVO) {
// MyBatis 查询时会自动注入 dept_id IN (...) OR creator = ? 条件
Page<AdminUserDO> page = userMapper.selectPage(reqVO,
new LambdaQueryWrapperX<AdminUserDO>()
.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 注解
@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
@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 使用示例
@RestController
@RequestMapping("/admin-api/system/user")
public class UserController {
/**
* 创建用户:请求体加密传输,响应结果也加密返回
*/
@PostMapping("/create")
@ApiEncrypt(request = true, response = true)
@PreAuthorize("@ss.hasPermission('system:user:create')")
public CommonResult<Long> createUser(@RequestBody UserSaveReqVO reqVO) {
return success(userService.createUser(reqVO));
}
}
# 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 项目代码分析生成,如项目安全机制有变更,请同步更新本文档。