MayurValte/security-incident-response-platform
GitHub: MayurValte/security-incident-response-platform
基于 Java 17 和 Spring Boot 3.5 构建的生产级安全事件响应平台,采用 8 个微服务架构实现事件全生命周期管理与 SLA 自动升级。
Stars: 1 | Forks: 0
# SIRP — 安全事件响应平台
一个用于管理组织内部安全事件的后端平台 —— 可以把它看作一个**专为安全团队量身定制的轻量级 PagerDuty / Jira Service Management 克隆**。事件在被创建、分配后,会经历完整的生命周期跟踪,在违反 SLA 时自动升级,并且每一个步骤都会被审计、发送邮件,并汇入实时的指标仪表板。
本项目构建为 **8 个可独立部署的 Spring Boot 微服务**,外加一个配置服务器和 API 网关 —— 它们具备真实分布式系统所需的弹性、可观测性和安全模式,而不仅仅是为了跑通理想路径。







## 本项目为何存在
大多数作品集中的 CRUD 应用在“能跑通”时就止步了。这个项目旨在回答*下一个*问题 —— 当一个依赖挂掉时、当 Kafka 消息被重新投递时,或者当一个 JVM 环境变量中继悄悄截断了一个 2048 位密钥时,会发生什么?下面每一个不显而易见的决策都是有意为之并伴随着权衡的,并且在端到端组装系统的过程中发现的几个真实 Bug 也被记录在案,而不是被掩盖起来。
## 目录
- [架构](#architecture)
- [技术栈](#tech-stack)
- [服务清单](#service-inventory)
- [核心架构决策](#key-architectural-decisions)
- [快速开始](#quick-start)
- [API 参考](#api-reference)
- [安全模型](#security-model)
- [弹性与可观测性](#resilience--observability)
- [测试](#testing)
- [部署目标](#deployment-targets)
- [已知局限性](#known-limitations-left-in-on-purpose)
- [项目结构](#project-layout)
## 架构
```
flowchart TB
Client([Client])
subgraph Edge["Edge"]
GW["api-gateway :8080\nWebFlux · JWT validation\nRedis rate limiting · CORS"]
end
subgraph Domain["Domain services"]
AUTH["auth-service :8082\nlogin · refresh · lockout"]
USER["user-service :8081\nusers & teams"]
INC["incident-service :8083\nlifecycle · SLA · attachments"]
WF["workflow-service :8086\nassignment · escalation"]
AUD["audit-service :8084\nimmutable audit trail"]
NOTIF["notification-service :8085\nemail · retry sweep"]
AN["analytics-service :8087\ndashboard metrics"]
end
CFG["config-server :8888"]
KAFKA[("Kafka\n14 topics")]
PG[("PostgreSQL\n1 DB per service")]
REDIS[("Redis\ncache · lockout · rate limit")]
OBS["Prometheus · Grafana · Loki · Zipkin"]
Client -->|"Bearer JWT"| GW
GW --> AUTH & USER & INC & WF & AUD & NOTIF & AN
AUTH -.->|Feign| USER
WF -.->|Feign| INC
INC -->|produces| KAFKA
WF -->|produces| KAFKA
AUTH -->|produces| KAFKA
USER -->|produces| KAFKA
KAFKA -->|consumes| AUD
KAFKA -->|consumes| NOTIF
KAFKA -->|consumes| AN
Domain --> PG
AN -.->|cache| REDIS
AUTH -.->|lockout counters| REDIS
GW -.->|rate limit| REDIS
CFG -.->|config at boot| Domain
Domain -.->|traces/logs/metrics| OBS
```
**端到端的请求流程:** 网关验证 JWT 并注入 `X-User-*` 便利请求头,应用基于 Redis 的速率限制,然后通过静态路由表代理到某个领域服务。该服务**独立重新验证同一个 JWT** —— 网关的请求头在下永远不会被信任,仅作为便利手段使用 —— 接着持久化更改,并发布一个 Kafka 事件。该事件最多会分发给三个独立的消费者(审计、通知、分析),它们都不会阻塞原始的 HTTP 响应。
## 技术栈
| 层级 | 选型 |
|---|---|
| 语言 / 运行时 | Java 17, Spring Boot 3.5.16 |
| API 网关 | Spring Cloud Gateway (WebFlux/响应式) |
| 服务框架 | Spring Web (servlet),全部 8 个领域服务 |
| 持久化 | PostgreSQL 17,每个服务一个 schema,Flyway 数据库迁移,Spring Data JPA |
| 异步消息传递 | Apache Kafka 4.1 (Spring Kafka),14 个带版本控制的 topic |
| 缓存 / 限流 / 锁定 | Redis 8 |
| 服务发现 | Consul (到处注册,但不用于路由 —— 见下文) |
| 配置中心 | Spring Cloud Config Server (原生/文件系统后端) |
| 认证授权 | JWT,RS256 非对称签名,Spring Security |
| 弹性容错 | Resilience4j 断路器 |
| 链路追踪 | Micrometer Tracing + Brave → Zipkin |
| 日志记录 | Logback → Loki (直接 HTTP 推送) → Grafana |
| 指标监控 | Micrometer → Prometheus → Grafana |
| API 文档 | springdoc-openapi (Swagger UI),每个服务独立配备 |
| 映射 | MapStruct |
| 构建 | Maven —— **无父级/reactor POM**,每个模块独立构建 |
| 容器化 | Docker / docker-compose,以及原生 Kubernetes 清单 |
| 测试 | JUnit 5, Mockito, AssertJ, 独立 MockMvc —— **258 个测试** |
## 服务清单
| 服务 | 拥有 | 职责 |
|---|---|---|
| `config-server` | — | 启动时提供各服务的 YAML 配置;没有它,每个服务都会快速失败 |
| `api-gateway` | — | 唯一入口;JWT 验证、速率限制、CORS、静态路由 |
| `auth-service` | `sirp_auth_db` | 登录、refresh-token 轮换、JWT 生成(私钥的**唯一**持有者)、暴力破解锁定 |
| `user-service` | `sirp_user_db` | 用户与团队,记录系统,仅限管理员写入 |
| `incident-service` | `sirp_incident_db` | 事件 CRUD、生命周期状态机、SLA 计算、评论、文件附件 |
| `workflow-service` | `workflow_db` | 在事件之上的分配/升级编排 |
| `audit-service` | `sirp_audit_db` | 不可变的审计追踪,完全由 Kafka 事件构建(只读) |
| `notification-service` | `sirp_notification_db` | 由 Kafka 驱动的多渠道通知,带有发送失败重试清理任务 |
| `analytics-service` | `sirp_analytics_db` | 基于 Redis 缓存的仪表板读取模型,由 Kafka 构建 |
| `common-library` | — | 共享的枚举、Kafka 事件契约、topic 常量 —— 不是 Spring Boot 应用 |
| `sirp-security` | — | 作为 Spring Boot **自动配置**模块的共享 JWT 过滤器/验证器,被每个 servlet 服务*以及*响应式网关所使用 |
## 核心架构决策
这是系统设计面试中真正重要的部分 —— 不仅是构建了什么,还有为什么这样构建,以及代价是什么:
- **没有父级/reactor Maven POM。** 每个模块都是完全独立的 `spring-boot-starter-parent` 项目 —— 这更接近真实的 polyrepo(多仓库)微服务进行版本控制和部署的方式,代价是无法进行统一的依赖版本强制管理。
- **JWT 使用 RS256 而非 HS256。** 只有 `auth-service` 会持有私钥;包括网关在内的所有其他服务,永远只拥有公钥,可以验证但永远无法伪造。这在代码中得到了强制执行:如果在未配置私钥的地方调用 `JwtKeyProvider.getPrivateKey()` 将会抛出异常。
- **网关请求头只是一种便利,绝不是信任边界。** `X-User-Id`/`X-User-Role`/等头部是为了下游的便利而注入的,但每个领域服务都会独立重新验证已签名的 JWT —— 这是针对配置错误的网络边界的深度防御,而不仅仅是听信网关的一面之词。
- **Kafka 用于状态传播,仅在需要*立刻*得到答复时才使用同步的 Feign。** 事件生命周期事件会分发给 3 个互不耦合的独立消费者;同步调用仅保留用于诸如“该分配对象是否存在且处于激活状态”这类情况,并在事件被分配之前予以回答。
- **断路器包装的是 `Resilient*Client` 组件,绝不是原始的 Feign 接口** —— `@CircuitBreaker` 是一个 AOP 代理注解,在*同一个类内部*从一个方法对另一个方法的自我调用会悄无声息地绕过代理。每个调用的服务始终注入的都是包装类。
- **每个服务使用一个独立的 Postgres 数据库,没有跨服务的 JOIN。** 经典的微服务所有权规则 —— 这才是让独立部署成为现实的关键,代价是需要通过事件或 API 来替代跨服务读取时的 `JOIN`。
- **关联 ID *就是* Micrometer 的 trace ID**,而不是第二个生成的标识符 —— 由于分布式追踪已经完全打通,一个 ID 既作为 Zipkin 的 trace 键,也作为你在每个服务的日志中去 grep 的字符串。
- **DLT(死信队列)的命名是按消费者的,而不是按 topic 的** (`incident.created.v1.analytics-service.DLT`) —— 4 个事件 topic 每个都被 3 个服务消费;共享的 DLT 会把所有 3 个服务的失败信息交织在一起,导致无法分辨到底是哪个服务的处理失败了。
- **`sirp-security` 是一个 Spring Boot `@AutoConfiguration` 模块**,而不是通过组件扫描加载的 —— 这是在早期的组件扫描方式导致服务间发生 Bean 冲突重启后,经过深思熟虑的修复。每个 Bean 都是 `@ConditionalOnMissingBean` 的,因此任何消费者依然可以覆盖其中某一部分。
## 快速开始
```
# 1. 复制 env 模板并填写真实 secrets
cp .env.example .env
# 2. 生成 JWT 密钥对(Java 17 单文件启动,无需编译步骤)
java scripts/GenerateJwtKeys.java
# 将打印出的 JWT_PUBLIC_KEY / JWT_PRIVATE_KEY 粘贴到 .env 中
# 3. 启动整个平台 — infra + 全部 9 个服务,容器化
docker-compose up -d --build
```
大功告成 —— Postgres 会在首次启动时自动创建每个服务所需的数据库 (`postgres-init/init-databases.sql`),并且每个服务在启动前都会等待 Config Server 的健康检查通过。
接下来引导创建第一个管理员用户(特意不提供种子数据 —— 因为创建用户本身就需要一个 ADMIN 的 JWT):
```
INSERT INTO teams (id, team_name) VALUES (gen_random_uuid(), 'Bootstrap Team');
INSERT INTO users (id, username, email, password, enabled, role, team_id)
VALUES (gen_random_uuid(), 'admin', 'admin@sirp.local', '', true, 'ADMIN',
(SELECT id FROM teams WHERE team_name = 'Bootstrap Team'));
```
| 端点 | URL |
|---|---|
| API (所有服务,通过网关) | http://localhost:8080 |
| Swagger UI (各服务独立) | http://localhost:8080/swagger-ui.html |
| Consul | http://localhost:8500 |
| Kafka UI | http://localhost:8090 |
| Grafana (匿名管理员) | http://localhost:3000 |
| Prometheus | http://localhost:9090 |
| Zipkin | http://localhost:9411 |
一套可随时导入的 **Postman 集合 + 环境变量**位于 [`postman/`](postman/) 目录下。此外还提供了一套用于集群部署的 **Kubernetes** 清单集 (`k8s/`) —— 请参阅 [部署目标](#deployment-targets) 章节。
更倾向于在宿主机上单独运行服务?每个模块都有自己的 Maven wrapper —— 先构建两个共享库 (`common-library`, `sirp-security`),然后进入各服务目录执行 `./mvnw spring-boot:run`。详见 [`CLAUDE.md`](CLAUDE.md)。
## API 参考
完整的端到端参考(请求/响应结构、分页特性、错误格式)位于 [`CLAUDE.md`](CLAUDE.md#building-a-separate-frontend-against-this-api) 中 —— 其编写目的是让你无需阅读源码即可基于此 API 构建前端。重点概览:
- **认证**: `POST /api/v1/auth/login` → access + refresh token (RS256 JWT,15 分钟 / 7 天有效期)。refresh token 在每次使用时都会轮换。15 分钟内失败 5 次后返回 `423 Locked`。
- **事件**: 严格的线性生命周期 —— `OPEN → ACKNOWLEDGED → IN_PROGRESS → RESOLVED → CLOSED`,乱序调用将返回 `409`。评论和文件附件(最大 20MB)作为子资源存在。
- **工作流**: 构建于事件之上的分配/升级编排,带有每 5 分钟执行一次的定时 SLA 升级清理任务。
- **审计 / 分析 / 通知**: 完全只读的 HTTP API —— 全部由 Kafka 消费者写入,而非直接写入。
## 安全模型
- **登录** → `AuthenticationManager` → 通过 Feign 进行 `user-service` 查找 → 生成包含 `sub`/`userId`/`role`/`iss`/`aud`/`jti` 声明的 RS256 JWT,外加一个不透明的、存储在服务端的、使用时轮换的 refresh token。
- **暴力破解防护**: 基于 Redis,以**邮箱**为键(因为网关隐藏了真实的客户端 IP),5 次失败尝试 / 15 分钟 → 锁定 15 分钟并返回 `423`。锁定检查甚至在 Spring Security 的 `AuthenticationManager` 被调用之前就会运行。
- **速率限制**: 网关处的 Redis 令牌桶算法,持续 20 req/s / 突发 40,基于客户端 IP,所有路由保持一致。
- **授权在刻意设计上是不一致的** —— 这是一个真实且已记录的缺陷:只有 `user-service` 的管理员写入和每个服务的 `/actuator/**` 设置了角色门禁。`incident-service`/`workflow-service` 完全没有 `@PreAuthorize`;任何已认证用户无论角色如何,都可以分配或关闭任何事件。(这是很好的面试素材 —— 见已知局限性。)
## 弹性与可观测性
- **断路器** (Resilience4j) 包裹了每一个 Feign 调用:10 次调用的滑动窗口,50% 的失败阈值,10 秒的打开状态。4xx 响应被排除在失败计数之外但仍然会到达 fallback 方法 —— 这里曾发现并修复过一个真实的 Bug(fallback 将 404/400 吞掉并返回了通用的 503),并且在其他几个包装类中也指出了相同的潜在模式(尚未修复)。
- **分布式追踪**: 以 100% 的采样率发送至 Zipkin,刻意未针对生产环境进行调优 —— 开发环境的意义就在于不错过你正在调试的那一次请求。Feign 和 Kafka 的 span 需要显式开启(`feign-micrometer`, `spring.kafka.*.observation-enabled`) —— 这两者在 Spring Boot 中都不是自动的,并且在修复之前都悄无声息地处于失效状态(Kafka 消费的 span 启动了一个断开连接的新 trace)。
- **死信机制**: 1 次初始尝试 + 3 次重试 (1s→2s→4s 退避),然后是按消费者的 DLT (`..DLT`) —— 而不是 Spring Kafka 共享的默认设置,因为有 3 个不同的服务在消费相同的事件 topic。
- **集中式日志记录**: 从 Logback 直接推送到 Loki (无 Promtail),关联 ID = trace ID,可以在一个 Grafana 查询中跨所有服务进行搜索。
- **Prometheus + Grafana**: 每个服务都公开暴露 `/actuator/prometheus` (与 `/actuator/**` 的其余部分不同,后者需要 ADMIN 权限) —— 因为静态的抓取目标无法持有生命周期仅为 15 分钟的 JWT。
## 测试
**258 个测试**,在原先几乎没有任何实际覆盖率(最初只有 Spring Boot 默认的上下文加载冒烟测试)的情况下从零开始添加,覆盖了 6 个包含实际业务逻辑的领域服务:
- **服务层**: 纯 Mockito,没有 Spring 上下文 —— 快速且隔离。
- **控制器**: 使用 `MockMvcBuilders.standaloneSetup(...)` 而不是 `@WebMvcTest`,以避免引入在没有真实 RSA 密钥时会快速失败的 JWT 自动配置 —— 同时依然能测试 `@Valid` 校验和真实的 `GlobalExceptionHandler`。
- **在关键处使用真实密码学**: JWT 签发测试会生成真实的 RSA 密钥对,并使用真实的 `jjwt` 库进行签名/验证往返,而不是将被测对象直接 Mock 掉。
- 编写测试**发现了真实的 Bug**,而不仅仅是验证了现有行为 —— 例如,`IncidentStatusValidator` 的 `switch` 语句在遇到 `ON_HOLD` 时会悄无声息地不作任何操作,而不是抛出异常,因为 Java 的 `switch` *语句*(与 `switch` *表达式*不同)不要求枚举的穷举性。
## 部署目标
有三种可互换的运行方式,具体取决于能解析出哪些环境变量 —— 应用代码中无需任何逻辑去判断当前处于哪种模式:
| 模式 | 方式 | 备注 |
|---|---|---|
| 宿主机运行 | 每个模块执行 `./mvnw spring-boot:run`,基础设施通过 `docker-compose up -d ` 启动 | 原始模式;服务间 URL 默认指向 `localhost:` |
| 完整 docker-compose | `docker-compose up -d --build` | 每个服务(应用 + 设施)都作为容器运行;Postgres 自动创建其所需的数据库 |
| Kubernetes | 位于 [`k8s/`](k8s/) 下的清单 | `ConfigMap`/`Secret` (源自同一个 `.env`),每个服务配备 `Deployment` + `Service` + 探针,`initContainers` 负责把控 Config Server/Postgres 的健康状态,Postgres 数据和事件附件使用 PVC |
## 已知局限性(特意保留的)
能够谈论真实存在且未修复的缺陷,比假装系统完美无缺是一个更有力的信号:
- **授权在刻意设计上是不一致的** —— `incident-service`/`workflow-service` 除了“已认证”之外没有任何角色检查;只有 `user-service` 设置了管理员门禁。
- **`X-Team-Id` 始终为空** —— `teamId` 声明在 JWT 签发端的部分一直未完成,尽管读取端已经完全打通。
- **存在两种分页包装结构** 跨服务存在 (`{content,page,size,...}` 对比 原始的 Spring Data `Page`) —— 从未统一过。
- **Kafka 没有毒丸消息处理机制** —— 在当前错误处理程序的设置下,*反序列化*失败的消息既不会重试,也不会进入死信队列。
- **事件附件采用单实例本地磁盘存储** —— 如果要进行多实例部署,则需要共享存储(如 S3 等)。
- **所有密钥均为明文** 存储在 YAML/环境变量中 —— 这对本地开发没问题,但在生产环境中需要引入真正的密钥管理工具(如 Vault, AWS Secrets Manager)。
- **端口冲突** 存在于 `auth-service` 自身的默认值 (8081) 与 Config Server 实际提供给它的配置 (8082) 之间 —— 从未进行过对账;一旦获取配置,Config Server 的值即生效。
- **网关路由是静态表**,而非 Consul 的 `lb://` —— 这是刻意为之,为了在没有运行 Consul 的情况下也能进行测试,但也承认了一个未来需要切换的 TODO。
## 项目结构
```
SIRP/
├── api-gateway/ # Reactive gateway (WebFlux) — JWT edge validation, rate limiting, CORS
├── auth-service/ # Login, refresh tokens, JWT minting
├── user-service/ # Users & teams (system of record)
├── incident-service/ # Incident lifecycle, comments, attachments, SLA
├── workflow-service/ # Assignment/escalation orchestration
├── audit-service/ # Immutable audit trail (Kafka-consumer-built)
├── notification-service/ # Email notifications + retry sweep
├── analytics-service/ # Dashboard metrics (Redis-cached, Kafka-built)
├── config-server/ # Spring Cloud Config Server
├── common-library/ # Shared DTOs, enums, Kafka event contracts
├── sirp-security/ # Shared JWT auto-configuration (servlet + reactive)
├── k8s/ # Kubernetes manifests
├── postman/ # Postman collection + environment
├── prometheus/, grafana/, loki/ # Observability stack config
├── scripts/GenerateJwtKeys.java # RSA keypair generator for JWT_PUBLIC_KEY/JWT_PRIVATE_KEY
└── docker-compose.yml # Full local stack — infra + every service
```
每个领域服务都遵循相同的内部分层结构:`controller → service/service.impl → repository`,辅以 MapStruct 的 `mapper`、用于动态过滤的 JPA `Specification`,以及服务内部的 `GlobalExceptionHandler`。
欲获取完整的技术深度解析(配置细节、每一个 Kafka topic、每一个已知缺陷、端到端的请求/响应结构),请参阅 [`CLAUDE.md`](CLAUDE.md)。
*个人/作品集项目 —— 在适合单个开发人员进行端到端推理的规模上,采用了生产级的设计模式(断路器、分布式追踪、死信队列、非对称 JWT 签名、幂等的 Kafka 消费者),但这并非一个生产级的 SaaS 系统。*
标签:Spring Boot, 域名枚举, 子域名突变, 安全运营, 工单系统, 扫描框架, 搜索引擎查询, 测试用例, 网络研究, 自定义请求头, 请求拦截, 运维监控