微服务认证与授权:09 — banking-api-service(资源服务器)

09 --- banking-api-service(资源服务器)

📦 GitHub: https://github.com/geekchow/micro-service-auth

这个 Spring Boot 服务拥有受保护的银行数据,并在每个请求上作为纵深防御层再次检查安全性。


该服务做什么

banking-api-service 暴露两个端点:

  • GET /api/accounts/{accountId}
  • GET /api/accounts/{accountId}/transactions

它返回简单的内存银行数据。本文关注的是它的安全行为,而非数据存储。

安全职责:

  • 认证 bearer 令牌
  • 校验 JWT 的签名、签发者与受众
  • 依据 JWT 声明对请求的账户进行授权

为什么该服务仍要做安全检查

Kong 是边缘的 PEP,OPA 是判定 allow/deny 的 PDP。但 banking-api-service 仍然是一个独立的信任边界。PEP/PDP/资源服务器的定义见 01 --- 概念

该服务再次检查的理由:

  • 服务可能在网络内部被直接调用,绕过 Kong
  • 网关配置错误可能让没有有效令牌的请求通过
  • 未来的路由可能绕过部分边缘逻辑
  • 纵深防御是良好的安全实践 ------ 每一层都应保护自身

因此该服务重复关键检查:签名、签发者、受众,以及账户级授权。


请求流程概览

#mermaid-svg-VNQPrOeWUR7REkfp{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-VNQPrOeWUR7REkfp .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-VNQPrOeWUR7REkfp .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-VNQPrOeWUR7REkfp .error-icon{fill:#552222;}#mermaid-svg-VNQPrOeWUR7REkfp .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-VNQPrOeWUR7REkfp .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-VNQPrOeWUR7REkfp .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-VNQPrOeWUR7REkfp .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-VNQPrOeWUR7REkfp .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-VNQPrOeWUR7REkfp .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-VNQPrOeWUR7REkfp .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-VNQPrOeWUR7REkfp .marker{fill:#333333;stroke:#333333;}#mermaid-svg-VNQPrOeWUR7REkfp .marker.cross{stroke:#333333;}#mermaid-svg-VNQPrOeWUR7REkfp svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-VNQPrOeWUR7REkfp p{margin:0;}#mermaid-svg-VNQPrOeWUR7REkfp .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-VNQPrOeWUR7REkfp .cluster-label text{fill:#333;}#mermaid-svg-VNQPrOeWUR7REkfp .cluster-label span{color:#333;}#mermaid-svg-VNQPrOeWUR7REkfp .cluster-label span p{background-color:transparent;}#mermaid-svg-VNQPrOeWUR7REkfp .label text,#mermaid-svg-VNQPrOeWUR7REkfp span{fill:#333;color:#333;}#mermaid-svg-VNQPrOeWUR7REkfp .node rect,#mermaid-svg-VNQPrOeWUR7REkfp .node circle,#mermaid-svg-VNQPrOeWUR7REkfp .node ellipse,#mermaid-svg-VNQPrOeWUR7REkfp .node polygon,#mermaid-svg-VNQPrOeWUR7REkfp .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-VNQPrOeWUR7REkfp .rough-node .label text,#mermaid-svg-VNQPrOeWUR7REkfp .node .label text,#mermaid-svg-VNQPrOeWUR7REkfp .image-shape .label,#mermaid-svg-VNQPrOeWUR7REkfp .icon-shape .label{text-anchor:middle;}#mermaid-svg-VNQPrOeWUR7REkfp .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-VNQPrOeWUR7REkfp .rough-node .label,#mermaid-svg-VNQPrOeWUR7REkfp .node .label,#mermaid-svg-VNQPrOeWUR7REkfp .image-shape .label,#mermaid-svg-VNQPrOeWUR7REkfp .icon-shape .label{text-align:center;}#mermaid-svg-VNQPrOeWUR7REkfp .node.clickable{cursor:pointer;}#mermaid-svg-VNQPrOeWUR7REkfp .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-VNQPrOeWUR7REkfp .arrowheadPath{fill:#333333;}#mermaid-svg-VNQPrOeWUR7REkfp .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-VNQPrOeWUR7REkfp .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-VNQPrOeWUR7REkfp .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-VNQPrOeWUR7REkfp .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-VNQPrOeWUR7REkfp .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-VNQPrOeWUR7REkfp .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-VNQPrOeWUR7REkfp .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-VNQPrOeWUR7REkfp .cluster text{fill:#333;}#mermaid-svg-VNQPrOeWUR7REkfp .cluster span{color:#333;}#mermaid-svg-VNQPrOeWUR7REkfp div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-VNQPrOeWUR7REkfp .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-VNQPrOeWUR7REkfp rect.text{fill:none;stroke-width:0;}#mermaid-svg-VNQPrOeWUR7REkfp .icon-shape,#mermaid-svg-VNQPrOeWUR7REkfp .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-VNQPrOeWUR7REkfp .icon-shape p,#mermaid-svg-VNQPrOeWUR7REkfp .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-VNQPrOeWUR7REkfp .icon-shape .label rect,#mermaid-svg-VNQPrOeWUR7REkfp .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-VNQPrOeWUR7REkfp .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-VNQPrOeWUR7REkfp .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-VNQPrOeWUR7REkfp :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Incoming HTTP request
Spring Security filter chain
JwtDecoder
Signature · issuer · audience validation
Authenticated Jwt principal
AccountAccessGuard
AccountController or TransactionController
AccountRepository / TransactionRepository
JSON response


SecurityConfig 装配

SecurityConfig 标注了 @Configuration。Spring Boot 在启动时发现它,并从中创建两个 bean:

  • SecurityFilterChain
  • JwtDecoder

SecurityFilterChain

java 复制代码
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    return http
            .csrf(csrf -> csrf.disable())
            .authorizeHttpRequests(authorize -> authorize
                    .requestMatchers("/actuator/health").permitAll()
                    .anyRequest().authenticated())
            .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
            .build();
}

每一行的含义:

  • 禁用 CSRF ------ 这是一个无状态 API,不是浏览器表单应用
  • /actuator/health 公开 ------ 容器健康检查需要
  • 其余一切都要求认证
  • .oauth2ResourceServer(...jwt(...)) 安装 JWT 认证过滤器

这些过滤器提取 Authorization: Bearer 令牌,并在控制器被调用之前请 JwtDecoder 校验它。

JwtDecoder

java 复制代码
@Bean
JwtDecoder jwtDecoder(
        @Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}") String jwkSetUri,
        @Value("${banking-api.security.issuer-uri}") String issuerUri,
        @Value("${banking-api.security.audience}") String audience) {
    NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build();
    jwtDecoder.setJwtValidator(jwtValidator(issuerUri, audience));
    return jwtDecoder;
}

NimbusJwtDecoder.withJwkSetUri(...)Keycloak 的 JWKS 端点获取公钥,并用它们验证令牌签名。随后一个自定义的 jwtValidator 检查签发者与受众。

该服务使用 JWKS(公钥验证)而非令牌内省 ------ 无状态、每个请求都不需要往返 Keycloak。关于这一选择背后的理由,见 11 --- JWT 签名、校验与内省。关于 JWKS 机制,见 12 --- JWKS 深入解析


配置

application.yml 中的键

用途
spring.security.oauth2.resourceserver.jwt.jwk-set-uri 获取 Keycloak 公钥的 URL
banking-api.security.issuer-uri 受信任的签发者;必须与 JWT 的 iss 声明匹配
banking-api.security.audience 必需的受众值;必须出现在 JWT 的 aud 声明中

环境变量(来自 docker-compose.yml

变量 Docker Compose 中的值
BANKING_API_JWK_SET_URI http://keycloak:8080/realms/banking-poc/protocol/openid-connect/certs
BANKING_API_ISSUER_URI http://keycloak:8080/realms/banking-poc
BANKING_API_AUDIENCE mobile-banking-app

JWT 校验

SecurityConfig 中的 jwtValidator 方法组合了两个校验器:

签名

NimbusJwtDecoder 使用从 Keycloak 的 JWKS 端点获取的密钥验证令牌签名。被篡改的令牌会立即失败。

签发者

JwtValidators.createDefaultWithIssuer(issuerUri) 检查 iss 声明是否与配置的 Keycloak realm 匹配。来自不同签发者的令牌会被拒绝。

受众

一个 JwtClaimValidator 检查 aud 声明是否包含 mobile-banking-app。不针对本应用的令牌会被拒绝。

若任一检查失败,Spring Security 返回 401 Unauthorized,控制器永不会被调用。


该服务读取哪些声明

AccountAccessGuard 从已校验的 Jwt 中读取三个声明:

声明 读取方式
realm_access.roles jwt.getClaim("realm_access") 转为 Map,再 get("roles")
customer_id jwt.getClaimAsString("customer_id")
account_ids jwt.getClaimAsStringList("account_ids")

整个系统的完整声明目录见 14 --- 请求与响应细节


Spring Security 在运行时如何装配 SecurityConfig

启动时:

  1. Spring 发现 SecurityConfig@Configuration
  2. 创建 SecurityFilterChain bean ------ 安装 JWT 过滤器链
  3. 创建 JwtDecoder bean ------ Spring Security 自动使用它

每个请求:

  1. JWT 过滤器提取 bearer 令牌
  2. JwtDecoder 校验签名、签发者、受众
  3. Spring 把已认证的 Jwt 存入安全上下文
  4. 控制器通过 @AuthenticationPrincipal Jwt jwt 接收它
  5. 控制器调用 AccountAccessGuard 做授权

AccountAccessGuard AccountController SecurityContext JwtDecoder Spring Security filter chain Incoming request AccountAccessGuard AccountController SecurityContext JwtDecoder Spring Security filter chain Incoming request #mermaid-svg-bCtXvWv75Lpd4pSL{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-bCtXvWv75Lpd4pSL .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-bCtXvWv75Lpd4pSL .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-bCtXvWv75Lpd4pSL .error-icon{fill:#552222;}#mermaid-svg-bCtXvWv75Lpd4pSL .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-bCtXvWv75Lpd4pSL .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-bCtXvWv75Lpd4pSL .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-bCtXvWv75Lpd4pSL .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-bCtXvWv75Lpd4pSL .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-bCtXvWv75Lpd4pSL .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-bCtXvWv75Lpd4pSL .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-bCtXvWv75Lpd4pSL .marker{fill:#333333;stroke:#333333;}#mermaid-svg-bCtXvWv75Lpd4pSL .marker.cross{stroke:#333333;}#mermaid-svg-bCtXvWv75Lpd4pSL svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-bCtXvWv75Lpd4pSL p{margin:0;}#mermaid-svg-bCtXvWv75Lpd4pSL .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-bCtXvWv75Lpd4pSL text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-bCtXvWv75Lpd4pSL .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-bCtXvWv75Lpd4pSL .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-bCtXvWv75Lpd4pSL .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-bCtXvWv75Lpd4pSL .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-bCtXvWv75Lpd4pSL #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-bCtXvWv75Lpd4pSL .sequenceNumber{fill:white;}#mermaid-svg-bCtXvWv75Lpd4pSL #sequencenumber{fill:#333;}#mermaid-svg-bCtXvWv75Lpd4pSL #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-bCtXvWv75Lpd4pSL .messageText{fill:#333;stroke:none;}#mermaid-svg-bCtXvWv75Lpd4pSL .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-bCtXvWv75Lpd4pSL .labelText,#mermaid-svg-bCtXvWv75Lpd4pSL .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-bCtXvWv75Lpd4pSL .loopText,#mermaid-svg-bCtXvWv75Lpd4pSL .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-bCtXvWv75Lpd4pSL .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-bCtXvWv75Lpd4pSL .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-bCtXvWv75Lpd4pSL .noteText,#mermaid-svg-bCtXvWv75Lpd4pSL .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-bCtXvWv75Lpd4pSL .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-bCtXvWv75Lpd4pSL .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-bCtXvWv75Lpd4pSL .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-bCtXvWv75Lpd4pSL .actorPopupMenu{position:absolute;}#mermaid-svg-bCtXvWv75Lpd4pSL .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-bCtXvWv75Lpd4pSL .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-bCtXvWv75Lpd4pSL .actor-man circle,#mermaid-svg-bCtXvWv75Lpd4pSL line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-bCtXvWv75Lpd4pSL :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt invalid token valid token HTTP GET + Authorization: Bearer token validate JWT valid or invalid 401 Unauthorized store authenticated Jwt principal inject @AuthenticationPrincipal Jwt checkCanAccessAccountId(accountId, jwt) allow or 403 checkCanAccess(account, jwt) allow or 403 200 JSON or 404


AccountAccessGuard

文件:services/banking-api-service/src/main/java/com/banking/poc/bankingapi/account/AccountAccessGuard.java

该守卫有两个公开方法:

  • checkCanAccessAccountId(String accountId, Jwt jwt) ------ 在仓库查找之前调用
  • checkCanAccess(AccountDto account, Jwt jwt) ------ 在账户加载之后调用

为什么有两次检查

第一次检查 ------ 账户 ID 预检(checkCanAccessAccountId):

  • 尽早拒绝明显被禁止的账户 ID
  • 减少信息泄露 ------ 对于将被拒绝的请求,服务不触碰仓库
  • 避免为调用方明显无法访问的请求加载数据

第二次检查 ------ 对象级检查(checkCanAccess):

  • 确认已加载账户的 customerId 确实与 JWT 的 customer_id 匹配
  • 为「账户/客户关系与仅凭 ID 所暗示的不一致」的情形增加一层保险

授权规则

规则 1 ------ 无 JWT → 401

Jwt 对象为 null,守卫抛出 401 Unauthorized

规则 2 ------ ops-admin 可访问任意账户

realm_access.roles 包含 ops-admin,两次检查都无条件通过。

规则 3 ------ customer 必须匹配声明上下文

checkCanAccessAccountId,以下全部必须为真:

  • realm_access.roles 包含 customer
  • customer_id 声明非 null 且非空白
  • account_ids 声明包含请求的 accountId

checkCanAccess,还需额外满足:

  • account.customerId() 等于 JWT 的 customer_id
  • account.accountId() 在 JWT 的 account_ids 列表中

规则 4 ------ 否则 403


AccountController

文件:services/banking-api-service/src/main/java/com/banking/poc/bankingapi/account/AccountController.java

端点:GET /api/accounts/{accountId}

java 复制代码
@GetMapping("/{accountId}")
public AccountDto getAccount(@PathVariable String accountId, @AuthenticationPrincipal Jwt jwt) {
    accountAccessGuard.checkCanAccessAccountId(accountId, jwt);
    AccountDto account = accountRepository.findById(accountId)
            .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
    accountAccessGuard.checkCanAccess(account, jwt);
    return account;
}

流程:

  1. 从路径接收 accountId,从安全上下文接收已校验的 Jwt
  2. checkCanAccessAccountId ------ 若调用方无法访问该账户 ID,尽早拒绝
  3. AccountRepository 加载账户 ------ 未找到则返回 404
  4. checkCanAccess ------ 确认已加载账户与调用方的声明上下文匹配
  5. 以 JSON 返回 AccountDto

TransactionController

文件:services/banking-api-service/src/main/java/com/banking/poc/bankingapi/transaction/TransactionController.java

端点:GET /api/accounts/{accountId}/transactions

java 复制代码
@GetMapping("/{accountId}/transactions")
public List<TransactionDto> getTransactions(@PathVariable String accountId, @AuthenticationPrincipal Jwt jwt) {
    accountAccessGuard.checkCanAccessAccountId(accountId, jwt);
    AccountDto account = accountRepository.findById(accountId)
            .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
    accountAccessGuard.checkCanAccess(account, jwt);
    return transactionRepository.findByAccountId(accountId);
}

流程与 AccountController 相同。两次守卫检查都通过后,控制器从 TransactionRepository 加载交易列表并返回。


类之间的关系

#mermaid-svg-HGcqD0jrS4aib9Yi{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-HGcqD0jrS4aib9Yi .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-HGcqD0jrS4aib9Yi .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-HGcqD0jrS4aib9Yi .error-icon{fill:#552222;}#mermaid-svg-HGcqD0jrS4aib9Yi .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-HGcqD0jrS4aib9Yi .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-HGcqD0jrS4aib9Yi .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-HGcqD0jrS4aib9Yi .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-HGcqD0jrS4aib9Yi .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-HGcqD0jrS4aib9Yi .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-HGcqD0jrS4aib9Yi .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-HGcqD0jrS4aib9Yi .marker{fill:#333333;stroke:#333333;}#mermaid-svg-HGcqD0jrS4aib9Yi .marker.cross{stroke:#333333;}#mermaid-svg-HGcqD0jrS4aib9Yi svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-HGcqD0jrS4aib9Yi p{margin:0;}#mermaid-svg-HGcqD0jrS4aib9Yi .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-HGcqD0jrS4aib9Yi .cluster-label text{fill:#333;}#mermaid-svg-HGcqD0jrS4aib9Yi .cluster-label span{color:#333;}#mermaid-svg-HGcqD0jrS4aib9Yi .cluster-label span p{background-color:transparent;}#mermaid-svg-HGcqD0jrS4aib9Yi .label text,#mermaid-svg-HGcqD0jrS4aib9Yi span{fill:#333;color:#333;}#mermaid-svg-HGcqD0jrS4aib9Yi .node rect,#mermaid-svg-HGcqD0jrS4aib9Yi .node circle,#mermaid-svg-HGcqD0jrS4aib9Yi .node ellipse,#mermaid-svg-HGcqD0jrS4aib9Yi .node polygon,#mermaid-svg-HGcqD0jrS4aib9Yi .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-HGcqD0jrS4aib9Yi .rough-node .label text,#mermaid-svg-HGcqD0jrS4aib9Yi .node .label text,#mermaid-svg-HGcqD0jrS4aib9Yi .image-shape .label,#mermaid-svg-HGcqD0jrS4aib9Yi .icon-shape .label{text-anchor:middle;}#mermaid-svg-HGcqD0jrS4aib9Yi .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-HGcqD0jrS4aib9Yi .rough-node .label,#mermaid-svg-HGcqD0jrS4aib9Yi .node .label,#mermaid-svg-HGcqD0jrS4aib9Yi .image-shape .label,#mermaid-svg-HGcqD0jrS4aib9Yi .icon-shape .label{text-align:center;}#mermaid-svg-HGcqD0jrS4aib9Yi .node.clickable{cursor:pointer;}#mermaid-svg-HGcqD0jrS4aib9Yi .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-HGcqD0jrS4aib9Yi .arrowheadPath{fill:#333333;}#mermaid-svg-HGcqD0jrS4aib9Yi .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-HGcqD0jrS4aib9Yi .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-HGcqD0jrS4aib9Yi .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-HGcqD0jrS4aib9Yi .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-HGcqD0jrS4aib9Yi .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-HGcqD0jrS4aib9Yi .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-HGcqD0jrS4aib9Yi .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-HGcqD0jrS4aib9Yi .cluster text{fill:#333;}#mermaid-svg-HGcqD0jrS4aib9Yi .cluster span{color:#333;}#mermaid-svg-HGcqD0jrS4aib9Yi div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-HGcqD0jrS4aib9Yi .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-HGcqD0jrS4aib9Yi rect.text{fill:none;stroke-width:0;}#mermaid-svg-HGcqD0jrS4aib9Yi .icon-shape,#mermaid-svg-HGcqD0jrS4aib9Yi .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-HGcqD0jrS4aib9Yi .icon-shape p,#mermaid-svg-HGcqD0jrS4aib9Yi .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-HGcqD0jrS4aib9Yi .icon-shape .label rect,#mermaid-svg-HGcqD0jrS4aib9Yi .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-HGcqD0jrS4aib9Yi .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-HGcqD0jrS4aib9Yi .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-HGcqD0jrS4aib9Yi :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} SecurityConfig
JwtDecoder
SecurityFilterChain
AccountController
TransactionController
AccountAccessGuard
AccountRepository
TransactionRepository


互操作

Keycloak

Keycloak 签名 JWT、设置签发者,并嵌入 customer_idaccount_ids 与角色声明。该服务用 Keycloak 的 JWKS 端点校验签名,并且不信任来自未签名或错误签发令牌的任何声明。

Kong

Kong 是把请求转发到 banking-api-service 的 PEP。然而,该服务并不无条件信任 Kong ------ 它独立校验 JWT。这意味着对该服务的直接调用(绕过 Kong)仍会得到同样的安全执行。

OPA

OPAKong 在边缘咨询的 PDP。banking-api-service 不直接调用 OPA。取而代之,它从受信任的 JWT 声明出发,再次执行同样的业务访问规则(账户归属、角色)。边缘与服务会收敛到同一答案,因为二者读取的是同一组声明。

identity-bootstrap-service

identity-bootstrap-service 创建带有角色、customer_idaccount_idsKeycloak 用户。这些值会嵌入到 banking-api-service 后续接收并校验的每个 JWT 中。


实际示例

示例 1 ------ alice 访问她自己的账户

alice 的 JWT:

  • realm_access.roles["customer"]
  • customer_idC-1001
  • account_ids["A-1001"]

请求:GET /api/accounts/A-1001

结果:

  • JWT 校验通过(签名、签发者、受众)
  • checkCanAccessAccountId 通过(A-1001account_ids 中)
  • 加载账户
  • checkCanAccess 通过(customerId 匹配)
  • 200 OK 及账户 JSON

示例 2 ------ alice 请求一个她不拥有的账户

JWT 同上。

请求:GET /api/accounts/A-2001

结果:

  • JWT 校验通过
  • checkCanAccessAccountId 失败(A-2001 不在 account_ids = ["A-1001"] 中)
  • 403 Forbidden ------ 控制器永不执行

示例 3 ------ ops-admin 访问任意账户

ops-admin 的 JWT:

  • realm_access.roles["ops-admin"]

请求:GET /api/accounts/A-2001

结果:

  • JWT 校验通过
  • checkCanAccessAccountId 通过(角色 ops-admin 短路)
  • 加载账户
  • checkCanAccess 通过(角色 ops-admin 短路)
  • 200 OK 及账户 JSON

示例 4 ------ 被篡改的令牌

请求携带一个签名无效的 JWT。

结果:

  • JwtDecoder 在签名验证时拒绝该令牌
  • Spring Security 返回 401 Unauthorized
  • 控制器永不执行

思维模型

  1. SecurityConfig 在启动时装配两个 bean:SecurityFilterChainJwtDecoder
  2. 每个请求,过滤器链提取并校验 bearer 令牌(签名 → 签发者 → 受众)
  3. 有效令牌成为一个受信任的 Jwt principal,通过 @AuthenticationPrincipal 注入
  4. AccountAccessGuard 使用 realm_access.rolescustomer_idaccount_ids 执行账户级授权
  5. 只有在认证与授权检查都通过后,控制器才返回数据

← Prev: 08 --- OPA · Next: 10 --- identity-bootstrap-service

相关推荐
ZhengO_Oz1 小时前
虚拟机CentOS 网络配置【实操】
运维·服务器·网络
utf8mb4安全女神1 小时前
如果复制windows 内容到 Linux,带入不可见特殊符,怎么解决
linux·运维·服务器
许彰午2 小时前
从Oracle看MySQL聚簇索引的架构性缺陷与缓解之道
mysql·oracle·架构
fei_sun2 小时前
网卡、网关、网桥、交换机、路由器
linux·服务器·网络
XUHUOJUN2 小时前
Storage Spaces on Shared SAS JBOD 在 Azure Local 解耦架构中的重新定位
microsoft·架构·azure·s2d
qq_454245032 小时前
状态的演进:从混沌单体到边界清晰的模块化格局
架构
烬羽2 小时前
200 行代码复刻 Cursor 核心:手把手写一个 Coding Agent 🔧
深度学习·架构·设计
跨境数据猎手2 小时前
多平台电商比价系统从零搭建合规
开发语言·爬虫·架构
用户1040747270232 小时前
《架构特别篇三:DEVICES 层》
架构
捷配链2 小时前
新能源四层板叠层逻辑-最优架构适配电控高低压混合场景
架构·制造·pcb