等保 2.0 合规架构:分布式系统全链路落地实战指南

0 阅读16分钟

随着数字化转型的深入,分布式微服务架构已成为企业系统建设的主流选择,而《网络安全等级保护条例》的落地,让等保2.0成为所有非涉密信息系统的强制合规要求。分布式系统节点分散、链路复杂、边界动态变化的特性,与等保2.0“一个中心、三重防护”的纵深防御体系存在天然的适配难点,很多企业在合规落地中出现“表面过审、实际裸奔”的问题,甚至因为合规不达标面临行政处罚。

一、等保2.0核心框架与分布式系统合规痛点

1.1 等保2.0核心技术体系

等保2.0的核心技术体系是“一个中心、三重防护”的纵深防御模型,所有合规要求均围绕该模型展开:

  • 安全管理中心:对全系统的安全策略、审计日志、安全事件、风险状况进行统一管控,是整个安全体系的大脑
  • 安全通信网络:保障数据在传输过程中的保密性、完整性,防止传输链路中的窃听、篡改、重放攻击
  • 安全区域边界:明确系统安全边界,对跨边界的访问进行精准管控,阻断非法入侵与越权访问
  • 安全计算环境:保障服务器、应用、数据等计算资源的安全,是等保合规的核心控制点,覆盖70%以上的测评项

1.2 分布式系统的合规核心痛点

相比单体系统,分布式架构在等保合规落地中面临四大核心挑战:

  • 安全边界模糊化:微服务多节点、跨集群、跨云部署,传统基于物理边界的防护模式完全失效,横向渗透风险大幅提升
  • 身份链路碎片化:用户请求从终端到网关、再到多个微服务、最终到数据库,经过多跳节点,身份传递过程易出现伪造、冒用问题
  • 审计日志分散化:每个微服务、中间件、数据库都有独立的日志体系,无法实现全链路操作的可追溯、不可篡改,无法满足审计要求
  • 防护规则静态化:容器化、云原生环境下,服务实例动态扩缩容,传统静态的IP、端口防护规则无法适配动态的架构变化

二、分布式等保2.0合规架构核心设计原则

基于等保2.0的标准要求,结合分布式架构的特性,合规架构设计需遵循五大核心原则:

  1. 纵深防御原则:构建从网络边界到应用、再到数据的多层防护体系,单点防护失效不会导致整个系统被突破
  2. 最小权限原则:对每个服务账号、用户账号、运维账号,仅授予完成业务所需的最小权限,严格禁止超权限分配
  3. 全链路可审计原则:所有用户操作、服务调用、系统事件都必须有完整的审计记录,记录可追溯、不可篡改,保存时间不少于6个月
  4. 内生安全原则:将安全能力嵌入分布式架构的设计阶段,而非业务上线后的补丁式防护,实现安全与业务的深度融合
  5. 零信任动态防护原则:默认不信任任何内部或外部的访问请求,所有访问都必须经过身份认证、权限校验、加密传输,适配分布式架构的动态变化

三、分布式系统等保2.0合规架构分层落地

3.1 安全通信网络层:全链路传输安全防护

安全通信网络层是等保2.0的第一道防线,核心目标是保障数据在整个传输链路中的保密性与完整性,防止传输过程中的安全风险。

3.1.1 合规核心要求

GB/T 22239-2019对通信网络的核心合规要求包括:

  • 通信双方必须经过双向身份鉴别,防止身份伪造
  • 传输过程中的重要数据必须采用密码技术保证保密性与完整性
  • 必须具备通信会话的抗重放能力,防止重放攻击
  • 必须能够检测到通信过程中的异常行为,并进行告警与阻断

3.1.2 架构设计

全链路加密传输架构如下:

3.1.3 落地实现方案

  1. 外网接入层强制TLS 1.3加密 外网接入必须禁用SSL、TLS 1.0/1.1等不安全协议,强制使用TLS 1.3,配置强加密套件,示例Nginx配置如下:
server {
    listen 443 ssl http2;
    server_name demo.jam.com;
    ssl_protocols TLSv1.3;
    ssl_ciphers TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256;
    ssl_prefer_server_ciphers on;
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 1d;
    ssl_stapling on;
    ssl_stapling_verify on;
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
}

2. 数据库通信强制加密 MySQL 8.0开启强制SSL连接,配置如下:

SET GLOBAL require_secure_transport = ON;
CREATE USER 'app_user'@'%' IDENTIFIED BY 'Your_Strong_Password_123!' REQUIRE SSL;
GRANT SELECT,INSERT,UPDATE,DELETE ON jam_demo.* TO 'app_user'@'%';
FLUSH PRIVILEGES;

Spring Boot项目中MySQL连接配置如下:

spring:
  datasource:
    url: jdbc:mysql://127.0.0.1:3306/jam_demo?useSSL=true&requireSSL=true&verifyServerCertificate=true
    username: app_user
    password: Your_Strong_Password_123!
    driver-class-name: com.mysql.cj.jdbc.Driver

3. 服务间通信mTLS双向认证 微服务间通信采用mTLS双向认证,防止服务间的身份伪造,Spring Boot服务TLS配置示例如下:

server:
  ssl:
    enabled: true
    key-store: classpath:server.p12
    key-store-password: ${KEY_STORE_PASSWORD}
    key-store-type: PKCS12
    key-alias: server
    trust-store: classpath:truststore.p12
    trust-store-password: ${TRUST_STORE_PASSWORD}
    trust-store-type: PKCS12
    client-auth: need

4. 通信会话抗重放保护 基于请求签名、时间戳、nonce随机数实现抗重放攻击,请求签名工具类实现如下:

package com.jam.demo.common.utils;

import com.alibaba.fastjson2.JSON;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.util.encoders.Hex;
import org.springframework.util.StringUtils;
import com.jam.demo.common.exception.BusinessException;

import java.nio.charset.StandardCharsets;
import java.security.Security;
import java.util.SortedMap;
import java.util.TreeMap;

/**
 * 请求签名工具类
 * @author ken
 */
public class SignUtils {

    static {
        if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
            Security.addProvider(new BouncyCastleProvider());
        }
    }

    private static final long VALID_TIME_WINDOW = 5 * 60 * 1000L;

    /**
     * 生成请求签名
     * @param params 请求参数
     * @param timestamp 时间戳
     * @param nonce 随机数
     * @param appSecret 应用密钥
     * @return 签名值
     */
    public static String generateSign(SortedMap<StringObject> params, long timestamp, String nonce, String appSecret) {
        if (!StringUtils.hasText(nonce)) {
            throw new BusinessException("nonce不能为空");
        }
        if (!StringUtils.hasText(appSecret)) {
            throw new BusinessException("appSecret不能为空");
        }
        SortedMap<StringObject> sortedMap = new TreeMap<>(params);
        sortedMap.put("timestamp", timestamp);
        sortedMap.put("nonce", nonce);
        StringBuilder signContent = new StringBuilder();
        for (SortedMap.Entry<StringObject> entry : sortedMap.entrySet()) {
            if (!StringUtils.hasText(entry.getKey()) || entry.getValue() == null) {
                continue;
            }
            signContent.append(entry.getKey()).append("=");
            if (entry.getValue() instanceof String) {
                signContent.append(entry.getValue());
            } else {
                signContent.append(JSON.toJSONString(entry.getValue()));
            }
            signContent.append("&");
        }
        signContent.append("appSecret=").append(appSecret);
        return sm3Hash(signContent.toString());
    }

    /**
     * 校验请求签名
     * @param params 请求参数
     * @param timestamp 时间戳
     * @param nonce 随机数
     * @param appSecret 应用密钥
     * @param sign 待校验的签名
     * @return 校验结果
     */
    public static boolean verifySign(SortedMap<StringObject> params, long timestamp, String nonce, String appSecret, String sign) {
        long currentTime = System.currentTimeMillis();
        if (Math.abs(currentTime - timestamp) > VALID_TIME_WINDOW) {
            return false;
        }
        String generateSign = generateSign(params, timestamp, nonce, appSecret);
        return generateSign.equalsIgnoreCase(sign);
    }

    /**
     * 国密SM3哈希算法
     * @param content 待哈希内容
     * @return 哈希结果
     */
    private static String sm3Hash(String content) {
        try {
            org.bouncycastle.crypto.digests.SM3Digest digest = new org.bouncycastle.crypto.digests.SM3Digest();
            byte[] bytes = content.getBytes(StandardCharsets.UTF_8);
            digest.update(bytes, 0, bytes.length);
            byte[] hashResult = new byte[digest.getDigestSize()];
            digest.doFinal(hashResult, 0);
            return Hex.toHexString(hashResult);
        } catch (Exception e) {
            throw new BusinessException("SM3哈希计算失败", e);
        }
    }
}

3.2 安全区域边界层:精准访问控制与入侵防护

安全区域边界层是分布式系统的第二道防线,核心目标是明确系统的安全边界,对所有跨边界的访问进行严格管控,阻断非法入侵与越权访问。

3.2.1 合规核心要求

GB/T 22239-2019对区域边界的核心合规要求包括:

  • 必须明确划分安全区域,对跨越边界的数据流进行访问控制
  • 必须对进出边界的网络流量进行攻击检测,阻断常见的网络攻击行为
  • 必须对无线网络、远程接入、第三方接口接入进行专项安全管控
  • 必须对边界的安全事件进行完整审计,实现可追溯

3.2.2 架构设计

分布式系统安全区域划分与边界防护架构如下:

3.2.3 落地实现方案

  1. 安全区域划分与最小权限ACL规则 按照业务功能、数据敏感级别划分安全区域,每个区域之间采用默认拒绝的ACL规则,仅开放业务必需的端口与通信链路:
  • DMZ区域:仅对外开放443端口,仅允许HTTPS流量进入
  • API网关区域:仅允许DMZ区域的443流量进入,仅开放网关服务端口
  • 业务微服务区域:仅允许API网关区域的流量进入,禁止外网直接访问
  • 数据存储区域:仅允许业务微服务区域的对应端口访问,禁止其他区域直接访问
  • 运维管理区域:仅通过堡垒机访问其他区域,禁止直接跨区域访问
  1. API网关边界防护 API网关是分布式系统的核心入口,必须实现全量请求的身份认证、权限校验、攻击检测、流量控制,Spring Cloud Gateway自定义XSS攻击过滤过滤器实现如下:
package com.jam.demo.gateway.filter;

import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

import java.nio.charset.StandardCharsets;
import java.util.regex.Pattern;

/**
 * XSS攻击检测全局过滤器
 * @author ken
 */
@Slf4j
@Component
public class XssAttackFilter implements GlobalFilter, Ordered {

    private static final Pattern XSS_PATTERN = Pattern.compile(
            "<script.*?>.*?</script>|javascript:|onerror=|onclick=|onload=|alert\(|confirm\(|prompt\(",
            Pattern.CASE_INSENSITIVE
    );

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        ServerHttpRequest request = exchange.getRequest();
        String requestPath = request.getPath().value();
        if (isWhiteList(requestPath)) {
            return chain.filter(exchange);
        }
        for (String paramName : request.getQueryParams().keySet()) {
            String paramValue = request.getQueryParams().getFirst(paramName);
            if (hasXssRisk(paramValue)) {
                return blockRequest(exchange, "请求参数包含XSS攻击内容");
            }
        }
        if (request.getHeaders().getContentType() != null && request.getHeaders().getContentType().includes(org.springframework.http.MediaType.APPLICATION_JSON)) {
            return DataBufferUtils.join(request.getBody())
                    .flatMap(dataBuffer -> {
                        byte[] content = new byte[dataBuffer.readableByteCount()];
                        dataBuffer.read(content);
                        DataBufferUtils.release(dataBuffer);
                        String bodyContent = new String(content, StandardCharsets.UTF_8);
                        if (hasXssRisk(bodyContent)) {
                            return blockRequest(exchange, "请求体包含XSS攻击内容");
                        }
                        DataBuffer newBuffer = exchange.getResponse().bufferFactory().wrap(content);
                        ServerHttpRequest newRequest = request.mutate().body(Flux.just(newBuffer)).build();
                        return chain.filter(exchange.mutate().request(newRequest).build());
                    });
        }
        return chain.filter(exchange);
    }

    @Override
    public int getOrder() {
        return Ordered.HIGHEST_PRECEDENCE + 1;
    }

    private boolean isWhiteList(String path) {
        return path.startsWith("/health") || path.startsWith("/actuator");
    }

    private boolean hasXssRisk(String content) {
        if (!StringUtils.hasText(content)) {
            return false;
        }
        return XSS_PATTERN.matcher(content).find();
    }

    private Mono<Void> blockRequest(ServerWebExchange exchange, String message) {
        ServerHttpResponse response = exchange.getResponse();
        response.setStatusCode(HttpStatus.BAD_REQUEST);
        response.getHeaders().setContentType(org.springframework.http.MediaType.APPLICATION_JSON);
        String responseBody = "{"code":400,"message":"" + message + ""}";
        DataBuffer buffer = response.bufferFactory().wrap(responseBody.getBytes(StandardCharsets.UTF_8));
        return response.writeWith(Mono.just(buffer));
    }
}

3. 微服务间零信任访问控制 摒弃“内网即可信”的传统理念,微服务间调用必须进行身份认证与权限校验,基于Spring Security实现服务间调用的Token校验拦截器如下:

package com.jam.demo.common.interceptor;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.StringUtils;
import org.springframework.web.servlet.HandlerInterceptor;
import com.jam.demo.common.utils.JwtUtils;
import com.jam.demo.common.exception.BusinessException;

/**
 * 服务间调用身份认证拦截器
 * @author ken
 */
@Slf4j
public class ServiceAuthInterceptor implements HandlerInterceptor {

    private final JwtUtils jwtUtils;

    public ServiceAuthInterceptor(JwtUtils jwtUtils) {
        this.jwtUtils = jwtUtils;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        String token = request.getHeader("X-Service-Token");
        if (!StringUtils.hasText(token)) {
            throw new BusinessException("服务调用令牌不能为空");
        }
        if (!jwtUtils.validateToken(token)) {
            throw new BusinessException("服务调用令牌无效");
        }
        String serviceId = jwtUtils.getServiceIdFromToken(token);
        request.setAttribute("callerServiceId", serviceId);
        return true;
    }
}

3.3 安全计算环境层:核心合规控制点落地

安全计算环境层是等保2.0合规的核心,覆盖70%以上的测评项,核心目标是保障服务器、应用、数据等计算资源的全生命周期安全。

3.3.1 合规核心要求

GB/T 22239-2019对安全计算环境的核心控制点包括:身份鉴别、访问控制、安全审计、入侵防范、数据完整性、数据保密性、数据备份恢复、剩余信息保护、资源控制。

3.3.2 身份鉴别落地实现

身份鉴别是安全计算环境的第一道关卡,等保2.0要求必须实现用户身份的唯一性标识、强密码策略、登录失败处理、多因素认证。

  1. 数据库表设计
CREATE TABLE sys_user (
    id BIGINT NOT NULL AUTO_INCREMENT COMMENT '用户ID',
    username VARCHAR(64NOT NULL COMMENT '用户名',
    password VARCHAR(128NOT NULL COMMENT '密码哈希',
    full_name VARCHAR(64NOT NULL COMMENT '用户姓名',
    phone VARCHAR(32) COMMENT '手机号',
    email VARCHAR(64) COMMENT '邮箱',
    status TINYINT NOT NULL DEFAULT 1 COMMENT '状态 0-禁用 1-正常',
    login_fail_count INT NOT NULL DEFAULT 0 COMMENT '登录失败次数',
    lock_time DATETIME COMMENT '锁定时间',
    last_login_time DATETIME COMMENT '最后登录时间',
    last_login_ip VARCHAR(64) COMMENT '最后登录IP',
    pwd_update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '密码更新时间',
    create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    PRIMARY KEY (id),
    UNIQUE INDEX uk_username (username)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='系统用户表';

2. 用户登录服务实现

package com.jam.demo.system.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.jam.demo.system.entity.SysUser;
import com.jam.demo.system.mapper.SysUserMapper;
import com.jam.demo.system.service.SysUserService;
import com.jam.demo.common.utils.PasswordUtils;
import com.jam.demo.common.utils.JwtUtils;
import com.jam.demo.common.exception.BusinessException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.TransactionCallback;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;

import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;

/**
 * 系统用户服务实现类
 * @author ken
 */
@Slf4j
@Service
public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> implements SysUserService {

    private static final int MAX_LOGIN_FAIL_COUNT = 5;
    private static final int LOCK_DURATION_MINUTES = 30;
    private static final int PWD_VALID_DAYS = 90;

    private final TransactionTemplate transactionTemplate;
    private final PasswordUtils passwordUtils;
    private final JwtUtils jwtUtils;

    public SysUserServiceImpl(TransactionTemplate transactionTemplate, PasswordUtils passwordUtils, JwtUtils jwtUtils) {
        this.transactionTemplate = transactionTemplate;
        this.passwordUtils = passwordUtils;
        this.jwtUtils = jwtUtils;
    }

    /**
     * 用户登录
     * @param username 用户名
     * @param password 密码
     * @param ipAddress 登录IP地址
     * @return 登录结果,包含token与用户信息
     */
    @Override
    public Map<String, Object> login(String username, String password, String ipAddress) {
        if (!StringUtils.hasText(username)) {
            throw new BusinessException("用户名不能为空");
        }
        if (!StringUtils.hasText(password)) {
            throw new BusinessException("密码不能为空");
        }
        SysUser user = this.getOne(new LambdaQueryWrapper<SysUser>().eq(SysUser::getUsername, username));
        if (ObjectUtils.isEmpty(user)) {
            throw new BusinessException("用户名或密码错误");
        }
        if (user.getStatus() == 0) {
            throw new BusinessException("账号已被禁用");
        }
        if (user.getLockTime() != null && user.getLockTime().isAfter(LocalDateTime.now())) {
            throw new BusinessException("账号已被锁定,请" + LOCK_DURATION_MINUTES + "分钟后重试");
        }
        if (!passwordUtils.matches(password, user.getPassword())) {
            handleLoginFail(user);
            throw new BusinessException("用户名或密码错误,连续错误" + MAX_LOGIN_FAIL_COUNT + "次将锁定账号");
        }
        if (user.getPwdUpdateTime().plusDays(PWD_VALID_DAYS).isBefore(LocalDateTime.now())) {
            throw new BusinessException("密码已过期,请修改密码后重新登录");
        }
        resetLoginFailCount(user);
        updateLoginInfo(user, ipAddress);
        String token = jwtUtils.generateToken(user.getId(), user.getUsername());
        Map<String, Object> result = new HashMap<>();
        result.put("token", token);
        result.put("userId", user.getId());
        result.put("username", user.getUsername());
        result.put("fullName", user.getFullName());
        return result;
    }

    /**
     * 处理登录失败
     * @param user 用户实体
     */
    private void handleLoginFail(SysUser user) {
        transactionTemplate.execute(new TransactionCallback<Void>() {
            @Override
            public Void doInTransaction(TransactionStatus status) {
                int newFailCount = user.getLoginFailCount() + 1;
                user.setLoginFailCount(newFailCount);
                if (newFailCount >= MAX_LOGIN_FAIL_COUNT) {
                    user.setLockTime(LocalDateTime.now().plusMinutes(LOCK_DURATION_MINUTES));
                }
                updateById(user);
                return null;
            }
        });
    }

    /**
     * 重置登录失败次数
     * @param user 用户实体
     */
    private void resetLoginFailCount(SysUser user) {
        if (user.getLoginFailCount() > 0) {
            transactionTemplate.execute(new TransactionCallback<Void>() {
                @Override
                public Void doInTransaction(TransactionStatus status) {
                    user.setLoginFailCount(0);
                    user.setLockTime(null);
                    updateById(user);
                    return null;
                }
            });
        }
    }

    /**
     * 更新登录信息
     * @param user 用户实体
     * @param ipAddress 登录IP地址
     */
    private void updateLoginInfo(SysUser user, String ipAddress) {
        transactionTemplate.execute(new TransactionCallback<Void>() {
            @Override
            public Void doInTransaction(TransactionStatus status) {
                user.setLastLoginTime(LocalDateTime.now());
                user.setLastLoginIp(ipAddress);
                updateById(user);
                return null;
            }
        });
    }
}

3.3.3 访问控制落地实现

等保2.0要求必须实现最小权限的访问控制,采用RBAC+ABAC的访问控制模型,实现功能权限与数据权限的精准管控。

  1. RBAC模型数据库表设计
CREATE TABLE sys_role (
    id BIGINT NOT NULL AUTO_INCREMENT COMMENT '角色ID',
    role_name VARCHAR(64NOT NULL COMMENT '角色名称',
    role_code VARCHAR(64NOT NULL COMMENT '角色编码',
    status TINYINT NOT NULL DEFAULT 1 COMMENT '状态 0-禁用 1-正常',
    create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    PRIMARY KEY (id),
    UNIQUE INDEX uk_role_code (role_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='系统角色表';

CREATE TABLE sys_permission (
    id BIGINT NOT NULL AUTO_INCREMENT COMMENT '权限ID',
    parent_id BIGINT NOT NULL DEFAULT 0 COMMENT '父权限ID',
    permission_name VARCHAR(64NOT NULL COMMENT '权限名称',
    permission_code VARCHAR(128NOT NULL COMMENT '权限编码',
    permission_type TINYINT NOT NULL COMMENT '权限类型 1-菜单 2-按钮 3-接口',
    url VARCHAR(256) COMMENT '接口URL',
    method VARCHAR(16) COMMENT '请求方法',
    sort INT NOT NULL DEFAULT 0 COMMENT '排序',
    create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    PRIMARY KEY (id),
    UNIQUE INDEX uk_permission_code (permission_code),
    INDEX idx_parent_id (parent_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='系统权限表';

CREATE TABLE sys_user_role (
    id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
    user_id BIGINT NOT NULL COMMENT '用户ID',
    role_id BIGINT NOT NULL COMMENT '角色ID',
    create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    PRIMARY KEY (id),
    UNIQUE INDEX uk_user_role (user_id, role_id),
    INDEX idx_user_id (user_id),
    INDEX idx_role_id (role_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='用户角色关联表';

CREATE TABLE sys_role_permission (
    id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
    role_id BIGINT NOT NULL COMMENT '角色ID',
    permission_id BIGINT NOT NULL COMMENT '权限ID',
    create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    PRIMARY KEY (id),
    UNIQUE INDEX uk_role_permission (role_id, permission_id),
    INDEX idx_role_id (role_id),
    INDEX idx_permission_id (permission_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='角色权限关联表';

2. 权限校验拦截器实现

package com.jam.demo.common.interceptor;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.StringUtils;
import org.springframework.web.servlet.HandlerInterceptor;
import com.jam.demo.common.utils.JwtUtils;
import com.jam.demo.system.service.SysPermissionService;
import com.jam.demo.common.exception.BusinessException;

/**
 * 权限校验拦截器
 * @author ken
 */
@Slf4j
public class PermissionInterceptor implements HandlerInterceptor {

    private final JwtUtils jwtUtils;
    private final SysPermissionService sysPermissionService;

    public PermissionInterceptor(JwtUtils jwtUtils, SysPermissionService sysPermissionService) {
        this.jwtUtils = jwtUtils;
        this.sysPermissionService = sysPermissionService;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        String token = request.getHeader("Authorization");
        if (!StringUtils.hasText(token) || !token.startsWith("Bearer ")) {
            throw new BusinessException("用户未登录");
        }
        token = token.substring(7);
        if (!jwtUtils.validateToken(token)) {
            throw new BusinessException("登录令牌无效");
        }
        Long userId = jwtUtils.getUserIdFromToken(token);
        request.setAttribute("userId", userId);
        String requestUrl = request.getRequestURI();
        String requestMethod = request.getMethod();
        boolean hasPermission = sysPermissionService.hasPermission(userId, requestUrl, requestMethod);
        if (!hasPermission) {
            throw new BusinessException("无操作权限");
        }
        return true;
    }
}

3.3.4 安全审计落地实现

等保2.0要求必须实现全量操作的安全审计,审计记录必须可追溯、不可篡改,保存时间不少于6个月。

  1. 审计日志表设计
CREATE TABLE sys_audit_log (
    id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
    trace_id VARCHAR(64NOT NULL COMMENT '全链路追踪ID',
    operator_id BIGINT COMMENT '操作人ID',
    operator_name VARCHAR(64) COMMENT '操作人姓名',
    operation_type VARCHAR(32NOT NULL COMMENT '操作类型',
    operation_desc VARCHAR(256NOT NULL COMMENT '操作描述',
    request_url VARCHAR(512NOT NULL COMMENT '请求URL',
    request_method VARCHAR(16NOT NULL COMMENT '请求方法',
    request_params TEXT COMMENT '请求参数',
    response_result TEXT COMMENT '响应结果',
    ip_address VARCHAR(64NOT NULL COMMENT '操作IP地址',
    operation_status TINYINT NOT NULL COMMENT '操作状态 0-失败 1-成功',
    error_message TEXT COMMENT '错误信息',
    operation_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '操作时间',
    PRIMARY KEY (id),
    INDEX idx_trace_id (trace_id),
    INDEX idx_operator_id (operator_id),
    INDEX idx_operation_time (operation_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='系统审计日志表';

CREATE USER 'audit_user'@'%' IDENTIFIED BY 'Audit_Strong_Password_123!' REQUIRE SSL;
GRANT INSERT ON jam_demo.sys_audit_log TO 'audit_user'@'%';
FLUSH PRIVILEGES;

2. 审计日志AOP切面实现

package com.jam.demo.common.aspect;

import com.alibaba.fastjson2.JSON;
import com.jam.demo.common.annotation.AuditLog;
import com.jam.demo.common.utils.TraceIdUtils;
import com.jam.demo.system.entity.SysAuditLog;
import com.jam.demo.system.service.SysAuditLogService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;

import java.util.Arrays;

/**
 * 审计日志切面
 * @author ken
 */
@Slf4j
@Aspect
@Component
public class AuditLogAspect {

    private final SysAuditLogService sysAuditLogService;

    public AuditLogAspect(SysAuditLogService sysAuditLogService) {
        this.sysAuditLogService = sysAuditLogService;
    }

    @Around("@annotation(auditLog)")
    public Object around(ProceedingJoinPoint joinPoint, AuditLog auditLog) throws Throwable {
        SysAuditLog auditLogEntity = new SysAuditLog();
        auditLogEntity.setTraceId(TraceIdUtils.getTraceId());
        auditLogEntity.setOperationType(auditLog.operationType());
        auditLogEntity.setOperationDesc(auditLog.operationDesc());
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        if (!ObjectUtils.isEmpty(attributes)) {
            HttpServletRequest request = attributes.getRequest();
            auditLogEntity.setRequestUrl(request.getRequestURI());
            auditLogEntity.setRequestMethod(request.getMethod());
            auditLogEntity.setIpAddress(getIpAddress(request));
            Long userId = (Long) request.getAttribute("userId");
            auditLogEntity.setOperatorId(userId);
            String operatorName = (String) request.getAttribute("username");
            auditLogEntity.setOperatorName(operatorName);
        }
        auditLogEntity.setRequestParams(Arrays.toString(joinPoint.getArgs()));
        long startTime = System.currentTimeMillis();
        Object result = null;
        try {
            result = joinPoint.proceed();
            auditLogEntity.setOperationStatus(1);
            if (!ObjectUtils.isEmpty(result)) {
                auditLogEntity.setResponseResult(JSON.toJSONString(result));
            }
        } catch (Throwable e) {
            auditLogEntity.setOperationStatus(0);
            auditLogEntity.setErrorMessage(e.getMessage());
            throw e;
        } finally {
            long endTime = System.currentTimeMillis();
            log.info("操作:{}, 耗时:{}ms", auditLog.operationDesc(), (endTime - startTime));
            sysAuditLogService.saveAuditLog(auditLogEntity);
        }
        return result;
    }

    private String getIpAddress(HttpServletRequest request) {
        String ip = request.getHeader("X-Forwarded-For");
        if (StringUtils.hasText(ip) && !"unknown".equalsIgnoreCase(ip)) {
            int index = ip.indexOf(',');
            if (index != -1) {
                return ip.substring(0, index).trim();
            }
            return ip.trim();
        }
        ip = request.getHeader("X-Real-IP");
        if (StringUtils.hasText(ip) && !"unknown".equalsIgnoreCase(ip)) {
            return ip.trim();
        }
        return request.getRemoteAddr();
    }
}

3.3.5 数据保密性落地实现

等保2.0要求必须对敏感数据进行存储加密,采用国密SM4算法实现敏感字段的自动加解密,MyBatisPlus类型处理器实现如下:

  1. 国密SM4加密工具类
package com.jam.demo.common.utils;

import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.util.encoders.Hex;
import org.springframework.util.StringUtils;
import com.jam.demo.common.exception.BusinessException;

import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.Security;

/**
 * 国密SM4加密工具类
 * @author ken
 */
public class Sm4Utils {

    static {
        if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
            Security.addProvider(new BouncyCastleProvider());
        }
    }

    private static final String ALGORITHM_NAME = "SM4";
    private static final String ALGORITHM_NAME_CBC_PADDING = "SM4/CBC/PKCS7Padding";
    private static final int KEY_SIZE = 128;
    private static final byte[] DEFAULT_IV = new byte[16];

    private static final String SM4_KEY = "${SM4_ENCRYPT_KEY}";

    /**
     * SM4 CBC加密
     * @param plainText 明文
     * @return 加密后的十六进制字符串
     */
    public static String encrypt(String plainText) {
        if (!StringUtils.hasText(plainText)) {
            return plainText;
        }
        try {
            Cipher cipher = Cipher.getInstance(ALGORITHM_NAME_CBC_PADDING, BouncyCastleProvider.PROVIDER_NAME);
            SecretKeySpec secretKeySpec = new SecretKeySpec(Hex.decode(SM4_KEY), ALGORITHM_NAME);
            IvParameterSpec ivParameterSpec = new IvParameterSpec(DEFAULT_IV);
            cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, ivParameterSpec);
            byte[] encrypted = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));
            return Hex.toHexString(encrypted);
        } catch (Exception e) {
            throw new BusinessException("SM4加密失败", e);
        }
    }

    /**
     * SM4 CBC解密
     * @param cipherText 加密后的十六进制字符串
     * @return 解密后的明文
     */
    public static String decrypt(String cipherText) {
        if (!StringUtils.hasText(cipherText)) {
            return cipherText;
        }
        try {
            Cipher cipher = Cipher.getInstance(ALGORITHM_NAME_CBC_PADDING, BouncyCastleProvider.PROVIDER_NAME);
            SecretKeySpec secretKeySpec = new SecretKeySpec(Hex.decode(SM4_KEY), ALGORITHM_NAME);
            IvParameterSpec ivParameterSpec = new IvParameterSpec(DEFAULT_IV);
            cipher.init(Cipher.DECRYPT_MODE, secretKeySpec, ivParameterSpec);
            byte[] decrypted = cipher.doFinal(Hex.decode(cipherText));
            return new String(decrypted, StandardCharsets.UTF_8);
        } catch (Exception e) {
            throw new BusinessException("SM4解密失败", e);
        }
    }

    /**
     * 生成SM4密钥
     * @return 十六进制格式的密钥
     */
    public static String generateKey() {
        try {
            KeyGenerator keyGenerator = KeyGenerator.getInstance(ALGORITHM_NAME, BouncyCastleProvider.PROVIDER_NAME);
            keyGenerator.init(KEY_SIZE);
            SecretKey secretKey = keyGenerator.generateKey();
            return Hex.toHexString(secretKey.getEncoded());
        } catch (Exception e) {
            throw new BusinessException("SM4密钥生成失败", e);
        }
    }
}

2. MyBatisPlus敏感字段类型处理器

package com.jam.demo.common.handler;

import com.jam.demo.common.utils.Sm4Utils;
import org.apache.ibatis.type.BaseTypeHandler;
import org.apache.ibatis.type.JdbcType;
import org.apache.ibatis.type.MappedJdbcTypes;
import org.apache.ibatis.type.MappedTypes;
import org.springframework.util.StringUtils;

import java.sql.CallableStatement;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;

/**
 * 敏感字段SM4加解密类型处理器
 * @author ken
 */
@MappedTypes(String.class)
@MappedJdbcTypes(JdbcType.VARCHAR)
public class SensitiveFieldTypeHandler extends BaseTypeHandler<String> {

    @Override
    public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) throws SQLException {
        String encryptedValue = Sm4Utils.encrypt(parameter);
        ps.setString(i, encryptedValue);
    }

    @Override
    public String getNullableResult(ResultSet rs, String columnName) throws SQLException {
        String cipherText = rs.getString(columnName);
        return Sm4Utils.decrypt(cipherText);
    }

    @Override
    public String getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
        String cipherText = rs.getString(columnIndex);
        return Sm4Utils.decrypt(cipherText);
    }

    @Override
    public String getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
        String cipherText = cs.getString(columnIndex);
        return Sm4Utils.decrypt(cipherText);
    }
}

3.4 安全管理中心:统一安全管控体系

安全管理中心是整个等保2.0合规架构的大脑,核心目标是实现对全系统安全策略、安全事件、审计日志、风险状况的统一管控。

3.4.1 合规核心要求

GB/T 22239-2019对安全管理中心的核心要求包括:

  • 必须实现对全系统安全设备与安全策略的统一管理
  • 必须实现对全系统安全事件的统一收集、分析与告警
  • 必须实现对全系统审计数据的集中管理与关联分析
  • 必须实现对全系统安全风险的统一评估与处置

3.4.2 架构设计

安全管理中心架构如下:

3.4.3 落地实现方案

  1. 安全策略统一管理:建立统一的策略管理平台,实现对防火墙规则、WAF规则、访问控制策略、加密策略的统一配置、下发与生命周期管理,避免策略分散导致的合规漏洞。
  2. 安全事件统一监控:采用SIEM系统实现全系统安全日志的统一收集、关联分析,对异常登录、越权访问、SQL注入、暴力破解等安全事件进行实时告警,告警流程如下:

  1. 统一审计分析:建立统一的审计日志中心,实现全链路审计日志的集中存储、查询、分析,审计日志必须采用不可篡改的存储方式,保存时间不少于6个月。
  2. 风险评估与管控:建立定期的风险评估机制,每季度对系统进行全面的安全风险评估,对发现的高风险漏洞必须在72小时内完成整改,中风险漏洞在15个工作日内完成整改。

四、等保2.0合规验证与常见问题整改

4.1 合规自查核心清单

基于GB/T 22239-2019标准,分布式系统等保2.0合规自查核心清单如下:

  1. 安全通信网络:是否实现全链路传输加密、双向身份认证、抗重放保护
  2. 安全区域边界:是否明确划分安全区域、实现最小权限ACL规则、API网关攻击防护、服务间访问控制
  3. 安全计算环境:是否实现强身份鉴别、最小权限访问控制、全量操作审计、敏感数据加密、数据备份恢复
  4. 安全管理中心:是否实现安全策略统一管理、安全事件统一监控、审计日志集中管理、风险统一管控

4.2 常见测评不符合项整改方案

  1. 不符合项:用户密码复杂度不符合要求,未定期更换 整改方案:强制密码复杂度要求(长度不少于8位,包含大小写字母、数字、特殊字符),密码有效期90天,禁止重复使用最近5次的密码
  2. 不符合项:审计日志保存时间不足6个月 整改方案:建立审计日志集中存储平台,采用不可篡改的存储方式,日志保存时间设置为180天以上
  3. 不符合项:敏感数据明文存储 整改方案:采用国密SM4算法对身份证号、手机号、银行卡号等敏感数据进行存储加密,实现敏感字段的自动加解密
  4. 不符合项:未实现通信过程中的双向身份认证 整改方案:外网接入采用TLS 1.3双向认证,服务间通信采用mTLS双向认证,数据库连接强制SSL加密
  5. 不符合项:访问控制粒度不足,未实现最小权限 整改方案:采用RBAC模型实现功能权限的精准管控,数据权限实现行级管控,数据库账号仅授予业务必需的最小权限

五、项目核心依赖配置

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.4</version>
        <relativePath/>
    </parent>
    <groupId>com.jam</groupId>
    <artifactId>demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>jam-demo</name>
    <description>等保2.0合规分布式系统Demo</description>
    <properties>
        <java.version>17</java.version>
        <mybatis-plus.version>3.5.7</mybatis-plus.version>
        <fastjson2.version>2.0.52</fastjson2.version>
        <bouncycastle.version>1.78.1</bouncycastle.version>
        <springdoc.version>2.6.0</springdoc.version>
        <guava.version>33.1.0-jre</guava.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jdbc</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-gateway</artifactId>
            <version>4.1.3</version>
        </dependency>
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>${mybatis-plus.version}</version>
        </dependency>
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <version>8.0.40</version>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>com.alibaba.fastjson2</groupId>
            <artifactId>fastjson2</artifactId>
            <version>${fastjson2.version}</version>
        </dependency>
        <dependency>
            <groupId>org.bouncycastle</groupId>
            <artifactId>bcprov-jdk18on</artifactId>
            <version>${bouncycastle.version}</version>
        </dependency>
        <dependency>
            <groupId>org.bouncycastle</groupId>
            <artifactId>bcpkix-jdk18on</artifactId>
            <version>${bouncycastle.version}</version>
        </dependency>
        <dependency>
            <groupId>org.springdoc</groupId>
            <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
            <version>${springdoc.version}</version>
        </dependency>
        <dependency>
            <groupId>com.google.guava</groupId>
            <artifactId>guava</artifactId>
            <version>${guava.version}</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.30</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

结尾

等保2.0合规不是一次性的测评工作,而是持续的安全建设过程。分布式系统的等保合规落地,必须摒弃“为了过审而合规”的错误理念,基于“一个中心、三重防护”的纵深防御体系,将安全能力嵌入到分布式架构的全生命周期中,构建既符合国家合规要求、又具备实际防护能力的安全架构,真正实现业务与安全的协同发展。