出发点:从最小 RBAC 起步,顺着探索长成小系统
作为偏前端的全栈开发,微服务鉴权一直是我认知里偏薄的一块。做 RBAC 时常见的做法是直接上 Spring Security + Nacos + Gateway 全套方案,偏重、上手慢,文档里一笔带过的版本与配置组合经常把人卡住。于是想换个更轻的思路:让 AI 把一套最小 RBAC 鉴权闭环写出来,我边读边学、把来龙去脉理清楚,再在它基础上探索。
起初确实只想做"最小可用":用户注册登录、JWT 签发验证、RBAC 角色权限。但鉴权这条线跑通后,自然就开始问下一个问题:真有个业务服务接在网关后面,这套鉴权能不能管住它?于是加了 CRM 客户域。又想看每次放行/拒绝到底发生了什么,于是加了跨服务审计。最后想有个界面点点看,于是加了 Next.js 前端。这篇记录的就是这个演进过程–重点不在"我写了什么",而在"每个环节到底在做什么、为什么这样搭"。
当前栈:Java 17 · Spring Boot 3.2.5 · Spring Cloud 2023.0.3 · Spring Cloud Gateway(WebFlux)· Eureka · Config Server(native)· Spring Data JPA · H2 · Resilience4j · Maven 多模块(7 个)· Next.js 14 前端。三套运行模式(裸 jar / Docker / k3s)零改配置,同一份源码直接跑。
架构总览
7 个后端服务 + 1 个 Next.js 前端:
browser ──► web :3000 (Next.js BFF:/api/* rewrite 到网关,无 CORS)
│
▼
gateway-service :4100 PEP:JWT 校验 + 权限裁决 + 审计旁路 + trace ID
│ via Eureka (lb://)
┌───────────┼───────────┬──────────────┐
▼ ▼ ▼ ▼
auth:4101 rbac:4102 customer:4103 audit:4104
注册/登录 PDP CRM+审批流 append-only 审计
签发 JWT /api/check 被保护资源 (网关异步发射 / GET 查询)
config-server :8888 (native 配置中心) eureka-server :8761 (服务注册)
| 服务 | 端口 | 职责 |
|---|---|---|
| eureka-server | 8761 | 服务注册中心(@EnableEurekaServer) |
| config-server | 8888 | 配置中心(@EnableConfigServer,native 后端) |
| gateway-service | 4100 | PEP:JWT 校验 + 权限裁决 + 审计旁路 + trace ID |
| auth-service | 4101 | 注册 / 登录 / /api/me,签发 JWT |
| rbac-service | 4102 | PDP:角色 / 权限 / 授予,BFS 算有效权限 |
| customer-service | 4103 | CRM 客户域 CRUD + 删除审批流 |
| audit-service | 4104 | 跨服务 append-only 审计日志 |
| web | 3000 | Next.js BFF dashboard,权限驱动 UI |
鉴权核心:PEP/PDP + 零依赖 JWT
鉴权严格分两层,"放行/拒绝"和"凭什么放行"各管各的:
- PEP(Policy Enforcement Point) 在网关
gateway-service/.../filter/AuthGlobalFilter.java(@Order(-1) GlobalFilter)。 - PDP(Policy Decision Point) 在
rbac-service,回答"这个用户有没有这个权限"。
AuthGlobalFilter 的处理流程:
- trace ID:读
X-Trace-Id,没有就生成一个 8 位 hex;回写响应头并往下传,全链路可追踪。 - 公开路由放行:
/api/login、/api/register、/health、/actuator/**直接放行;非/api/**也放行。 - 验 JWT:要求
Authorization: Bearer <token>,调jwtUtil.verify;缺 token 返回 401(审计auth:missing),无效返回 401(审计auth:invalid)。 - 注入身份:校验通过则往下游请求加
X-User: <username>头。 - 算所需权限:
mapPermission(path, method)把路由映射到权限;映射不到(如/api/me、/api/check)则只需登录即可。 - 调 PDP:用
WebClient调http://rbac-service/api/check?user=&permission=,包在熔断器rbac-check里;allowed放行并发审计 ALLOW,否则 403 发审计 DENY。 - 审计旁路:每次裁决后异步 fire-and-forget 发给
audit-service(审计本身 fail-open,不影响业务)。
零依赖 JWT:没有引入 jjwt 或 nimbus-jose-jwt,用 JDK 原生的 Mac + Base64 + Jackson 直接实现 HS256(auth-service/util/JwtUtil.java 与 gateway-service/util/JwtUtil.java)。零依赖只是附带好处,真正的目的是把 JWT 到底在算什么摊开来看清–header / payload / base64url 怎么拼、HMAC 怎么签、签名怎么验,几十行代码就不再是黑盒。共享密钥经 app.jwt-secret 注入(auth 与 gateway 共用对称密钥),TTL app.jwt-ttl 默认 86400000(24h)。gateway 的 JwtUtil 刻意只保留 verify 一份。密码哈希用 PBKDF2WithHmacSHA256(auth-service/util/PasswordUtil.java):迭代 65536、key 256 bit、salt 16 字节,输出 salt:hash。
熔断兜底(fail-closed):网关对 PDP 的调用用 Resilience4j 包裹,instance rbac-check,参数:
slidingWindowType: COUNT_BASED,slidingWindowSize: 10minimumNumberOfCalls: 5failureRateThreshold: 50,slowCallRateThreshold: 50slowCallDurationThreshold: 800mswaitDurationInOpenState: 5spermittedNumberOfCallsInHalfOpenState: 3automaticTransitionFromOpenToHalfOpenEnabled: true
选 fail-closed:熔断打开或 PDP 异常时,网关直接返回 403,绝不返回 200。安全组件里 deny-by-default 比 allow-by-default 稳妥。熔断状态经 Actuator 暴露:management.endpoints.web.exposure.include: health,circuitbreakers。
RBAC 模型:角色、权限、授予
种子数据在 rbac-service/.../service/RbacService.java 的 seedIfEmpty(),由 CommandLineRunner 启动时注入(H2 ddl-auto,每次启动干净种子):
- 角色:
admin/editor/viewer(三层,无继承–seed 里parentId全为 null)。 - 权限 11 个:
- 用户/角色/权限管理:
users:read、users:write、roles:read、roles:write、permissions:read - CRM 业务域:
customers:read、customers:create、customers:update、customers:delete、customers:approve - 审计:
audit:read
- 用户/角色/权限管理:
- 授予:
admin:全部 11 个(含customers:approve、audit:read)editor:users:read、roles:read、permissions:read+customers:read/create/update/delete(7 个;无 approve、无 audit:read)viewer:users:read、roles:read、permissions:read、customers:read(4 个,纯只读)
- 用户 -> 角色:
admin-> admin、user-> editor、viewer-> viewer - 登录账号:
admin/admin123、user/user123、viewer/viewer123
BFS 有效权限:resolveEffectivePermissions(username) 沿 parentId 链 BFS 收集权限(visited 防环),支持任意深度继承。当前 seed 没启用继承,所以实际等于直授角色权限的并集–机制留着,需要多级继承时 seed 里给 parentId 即可。PDP 端点:GET /api/check?user=&permission=。
业务域与审计:让鉴权真的管住东西
鉴权闭环跑通后,加业务资源才看得出它管不管用。
customer-service(:4103,CRM 客户域):CRUD + 检索,网关路由映射到 customers:read/create/update/delete。亮点是删除审批流:DELETE /api/customers/{id} 时,若调用方有 customers:approve(admin)直接删(200);否则返回 202 并建一条 pending 审批。/api/approvals 提供列表 + approve / reject(需 customers:approve,approver 从 X-User 头取)。它自己也有一份 RbacClient 调 rbac /api/check 来判断走直接删还是审批(出错 fail 到审批流)。H2 ./data/customer,ddl-auto=update。
audit-service(:4104,跨服务 append-only 审计):网关是唯一发射方–PEP 每次裁决后异步 best-effort 发一条审计事件。写入仅允许网关经服务发现直连(带私有头 X-Internal-Audit: gateway),对外路由 /api/audit 只放行 GET,无法从外部伪造写入。查询:GET /api/audit(分页,可按 decision / traceId 过滤)、GET /api/audit/stats(今日概览)。读需 audit:read(仅 admin)。H2 ./data/audit,ddl-auto=update。
这俩加上去之后,"鉴权"就不再是空中楼阁:customer 是被保护的业务资源,audit 让每次裁决可追溯。
前端 BFF:权限驱动的 dashboard
web(:3000,Next.js 14 + React 18 + TypeScript):采用 BFF 模式,浏览器只调同源 /api/*,Next.js 服务端 rewrite 到网关 :4100,无 CORS。dashboard 有 7 个 tab:Roles / Permissions / Users / Customers / Approvals / Audit / Check。
关键是权限驱动 UI:登录后对每个权限调一次 PDP /api/check,没权限的 tab 显示锁。前端不自己判断权限,只根据 PDP 结果显隐–权限逻辑始终集中在 rbac,UI 只是个可视化的壳。
设计取舍:为什么这样选
这套组合不是唯一解,也未必是"最优解",是在"最小依赖 + 能跑通 + 边探索边回答问题"这几个约束下的刻意取舍:
- PDP 留在
rbac-service内,不接 OPA / Casbin:先验证"网关只做 enforcement、决策集中一处"这个分层本身是否成立,而不是先背上策略引擎的复杂度。 - 网关用 Spring Cloud Gateway,而不是 Kong / APISIX:后者是成熟的生产网关,但会引入额外运维实体。目标是"用 Spring 生态内最小代价把 PEP 跑起来",所以留在应用层。
- JWT 用 JDK 原生实现,不引
jjwt/nimbus:与其说为了省依赖,不如说为了把签名 / 验证的机制摊开来看清–代价是失去成熟库的边缘 case 覆盖和审计保障。生产里还是该用成熟库,demo 阶段借这套实现把 JWT 本身搞懂。 - 鉴权没上 Spring Security:同样是不过早引入"版本冲突 + 配置纠缠",先把 RBAC 最小闭环跑通。
- customer / audit / web 是探索出来的,不是为完整而堆:每加一个都回答一个具体问题–业务资源怎么管住?裁决怎么追溯?有没有界面点点看?
往生产走,通常还要补这些:密钥进 KMS / Vault 而不是硬编码;PDP 拆独立服务并接策略引擎;补可观测性(trace / metric / 熔断事件告警);做水平扩展与无状态化;以及更严谨的鉴权强度(token 吊销、权限变更实时生效)。这篇文章验证的是"思路是否站得住",不是"这套能直接上生产"。
实现要点与注意事项
把这套组合真跑起来,有几个点值得提前知道。措辞与 DOCKER.md 对齐。
Eureka 冷启动期网关可能 503
Eureka 服务端响应缓存默认 30000ms、客户端拉取默认 30s,服务刚注册、网关还没拿到实例列表时,lb://service-name 解析会 503。调法:eureka-server 的 response-cache-update-interval-ms 调到 3000(默认 30000),gateway 的 registry-fetch-interval-seconds 调到 3(默认 30);docker-compose 里网关还多 sleep 6 缓冲(注释写"服务端缓存 3s + 网关拉取 3s")。
Map 类型配置在容器里用 -D 注入
eureka.client.serviceUrl 是 Map 类型,spring.config.import 是 bootstrap 期属性。这俩用扁平 env 变量(EUREKA_CLIENT_SERVICEURL_DEFAULTZONE / SPRING_CONFIG_IMPORT)注入不可靠:Map 绑不到 defaultZone 键、config import 的 URI 在 bootstrap 阶段解析时扁平 env 也常读不到,结果 client 回退到 application.yml 里的 localhost 默认值、注册失败。改用 JVM 系统属性(-D,优先级最高,Map 也能绑)经 JAVA_TOOL_OPTIONS 注入。这是 Docker / k3s 里实际生效的做法(详见 DOCKER.md)。
k8s 容器启动即退出(CrashLoopBackOff)
各服务 Dockerfile 只有 COPY ... app.jar + EXPOSE,没有 ENTRYPOINT / CMD;docker-compose 靠 command: 兜底能跑,但 k8s manifest 没设 command,容器无进程可跑、立刻退出(Completed = exit 0)-> CrashLoopBackOff。修:k8s 每个容器补 command: ["java","-Xmx256m","-jar","app.jar"](web 是 npm run start,网关带 sleep 5 缓冲)。
k3s 镜像拉不到(ContainerCreating 卡住)
imagePullPolicy: Never 在 OrbStack 下偶发无法解析本地镜像,kubelet 卡在创建容器。改成 IfNotPresent(本地有镜像就直接用、不拉取)。
initContainer 等的是 config,不是 eureka
manifest 里 initContainer 叫 wait-config(等 config-server:8888),网关那个等 config + auth + rbac + customer + audit 五个端口,web 那个等 gateway(wait-gateway)。不是等 eureka。
三套运行模式:一份源码零改配置
application.yml 从不修改,地址全靠 env / JAVA_TOOL_OPTIONS 注入。
裸 jar 模式
make build # mvn clean package -DskipTests -> 七个 jar
make start # 按序启动 7 个服务 + web,等待就绪
make demo # bash scripts/demo.sh,经网关 :4100 走完整流程
Docker Compose 模式
make docker-up # build + compose up -d --build
make docker-demo # 等待就绪 + 运行演示
make docker-logs # compose logs -f
make docker-stop # compose down(保留数据卷)
k3s 模式
orb start k8s # OrbStack 启用 k3s(或:k3s server)
make k3s-build # 实为 docker-build 别名
make k3s-deploy # kubectl apply -f k8s/spring-rbac.yaml
make k3s-demo # port-forward 网关 41000 + web 3000,自检 Pod 全 Running,demo
k8s/spring-rbac.yaml:8 个 Deployment + 8 个 Service,命名空间 rbac-demo,initContainer 等依赖就绪,配置通过 env + JAVA_TOOL_OPTIONS 注入。三套模式直接复用同一份源码,不需要任何配置修改。
结果:从鉴权闭环到小型微服务系统
功能清单:
- 用户注册登录 + JWT 签发验证(零依赖,JDK 原生)
- RBAC:3 角色 / 11 权限 / 3 授权 / BFS 有效权限(支持继承,seed 未启用)
- 网关边缘鉴权:PEP + 熔断 fail-closed + trace ID + 审计旁路
- PDP 集中决策:
rbac /api/check - CRM 业务域:customer-service CRUD + 删除审批流
- 跨服务审计:audit-service append-only,网关异步发射
- 前端 BFF:Next.js 权限驱动 dashboard
- 服务发现(Eureka)+ 配置中心(Config Server);三套运行模式零改配置
它仍然是个学习 / 探索性质的骨架,离生产还差得远–鉴权强度、密钥管理、可观测性、扩缩容、token 吊销等都还要补。但比当初"最小 RBAC"时完整得多,而且每个增量都对应一个具体问题。
源码导航
README.md/README.zh.md– 项目说明、架构总览、运行指南pom.xml– 父 POM,声明 Spring Cloud BOM 和 7 个子模块eureka-server/– 服务注册中心(@EnableEurekaServer,响应缓存调 3000ms)config-server/– 配置中心(@EnableConfigServer,native 后端,配置在src/main/resources/config-repo/,5 个业务服务配置)auth-service/src/main/java/com/example/rbac/auth/util/JwtUtil.java– HS256 JWT 签发与验证auth-service/src/main/java/com/example/rbac/auth/util/PasswordUtil.java– PBKDF2WithHmacSHA256 密码哈希auth-service/src/main/java/com/example/rbac/auth/AuthApplication.java– seeded 登录账号gateway-service/src/main/java/com/example/rbac/gateway/filter/AuthGlobalFilter.java– 网关 PEP:JWT 校验 + 权限裁决 + 审计旁路 + trace IDgateway-service/src/main/java/com/example/rbac/gateway/util/JwtUtil.java– 网关侧 JWT 解析(verify-only)gateway-service/src/main/resources/application.yml– 5 条路由(lb://)+ 熔断器配置 + 拉取间隔 3srbac-service/src/main/java/com/example/rbac/rbac/service/RbacService.java– seed 数据 + BFS 有效权限 + checkrbac-service/src/main/java/com/example/rbac/rbac/controller/RbacController.java– PDP 端点/api/checkcustomer-service/– CRM 客户域 + 删除审批流,含RbacClientaudit-service/– append-only 跨服务审计日志web/– Next.js 14 BFF 前端(next.config.mjsrewrite、lib/permissions.ts权限驱动)k8s/spring-rbac.yaml– k3s / Kubernetes 部署清单(8 Deployment + 8 Service)docker-compose.yml– Docker Compose 编排(8 服务)Makefile– 所有 make 目标DOCKER.md– Docker Compose 与 k3s 运行手册(含各坑点详解)ARCHITECTURE.md– 架构深度解析与设计决策
发表回复