系统鉴权方式说明文档.md 71.1 KB

UrbanOps 系统鉴权方式说明文档

项目: 蓟城山水集团全域智能运营管理平台 (UrbanOps)
版本: 基于 2026-06-11 代码分析
适用对象: 前端开发、第三方对接、运维部署


目录


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 提供 hasPermissionhasRolehasScope 三个维度的权限判断。

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 项目代码分析生成,如项目安全机制有变更,请同步更新本文档。