微服务认证与授权: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是边缘的 PEPOPA是判定 allow/deny 的 PDP。但banking-api-service仍然是一个独立的信任边界。PEP/PDP/资源服务器的定义见 01 — 概念。该服务再次检查的理由服务可能在网络内部被直接调用绕过Kong网关配置错误可能让没有有效令牌的请求通过未来的路由可能绕过部分边缘逻辑纵深防御是良好的安全实践 —— 每一层都应保护自身因此该服务重复关键检查签名、签发者、受众以及账户级授权。请求流程概览Incoming HTTP requestSpring Security filter chainJwtDecoderSignature · issuer · audience validationAuthenticated Jwt principalAccountAccessGuardAccountController or TransactionControllerAccountRepository / TransactionRepositoryJSON responseSecurityConfig装配SecurityConfig标注了Configuration。Spring Boot 在启动时发现它并从中创建两个 beanSecurityFilterChainJwtDecoderSecurityFilterChainBeanSecurityFilterChainsecurityFilterChain(HttpSecurityhttp)throwsException{returnhttp.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校验它。JwtDecoderBeanJwtDecoderjwtDecoder(Value(${spring.security.oauth2.resourceserver.jwt.jwk-set-uri})StringjwkSetUri,Value(${banking-api.security.issuer-uri})StringissuerUri,Value(${banking-api.security.audience})Stringaudience){NimbusJwtDecoderjwtDecoderNimbusJwtDecoder.withJwkSetUri(jwkSetUri).build();jwtDecoder.setJwtValidator(jwtValidator(issuerUri,audience));returnjwtDecoder;}NimbusJwtDecoder.withJwkSetUri(...)从Keycloak的 JWKS 端点获取公钥并用它们验证令牌签名。随后一个自定义的jwtValidator检查签发者与受众。该服务使用 JWKS公钥验证而非令牌内省 —— 无状态、每个请求都不需要往返Keycloak。关于这一选择背后的理由见 11 — JWT 签名、校验与内省。关于 JWKS 机制见 12 — JWKS 深入解析。配置application.yml中的键键用途spring.security.oauth2.resourceserver.jwt.jwk-set-uri获取Keycloak公钥的 URLbanking-api.security.issuer-uri受信任的签发者必须与 JWT 的iss声明匹配banking-api.security.audience必需的受众值必须出现在 JWT 的aud声明中环境变量来自docker-compose.yml变量Docker Compose 中的值BANKING_API_JWK_SET_URIhttp://keycloak:8080/realms/banking-poc/protocol/openid-connect/certsBANKING_API_ISSUER_URIhttp://keycloak:8080/realms/banking-pocBANKING_API_AUDIENCEmobile-banking-appJWT 校验SecurityConfig中的jwtValidator方法组合了两个校验器签名NimbusJwtDecoder使用从Keycloak的 JWKS 端点获取的密钥验证令牌签名。被篡改的令牌会立即失败。签发者JwtValidators.createDefaultWithIssuer(issuerUri)检查iss声明是否与配置的Keycloakrealm 匹配。来自不同签发者的令牌会被拒绝。受众一个JwtClaimValidator检查aud声明是否包含mobile-banking-app。不针对本应用的令牌会被拒绝。若任一检查失败Spring Security 返回401 Unauthorized控制器永不会被调用。该服务读取哪些声明AccountAccessGuard从已校验的Jwt中读取三个声明声明读取方式realm_access.rolesjwt.getClaim(realm_access)转为Map再get(roles)customer_idjwt.getClaimAsString(customer_id)account_idsjwt.getClaimAsStringList(account_ids)整个系统的完整声明目录见 14 — 请求与响应细节。Spring Security 在运行时如何装配SecurityConfig启动时Spring 发现SecurityConfigConfiguration创建SecurityFilterChainbean —— 安装 JWT 过滤器链创建JwtDecoderbean —— Spring Security 自动使用它每个请求JWT 过滤器提取 bearer 令牌JwtDecoder校验签名、签发者、受众Spring 把已认证的Jwt存入安全上下文控制器通过AuthenticationPrincipal Jwt jwt接收它控制器调用AccountAccessGuard做授权AccountAccessGuardAccountControllerSecurityContextJwtDecoderSpring Security filter chainIncoming requestAccountAccessGuardAccountControllerSecurityContextJwtDecoderSpring Security filter chainIncoming requestalt[invalid token][valid token]HTTP GET Authorization: Bearer tokenvalidate JWTvalid or invalid401 Unauthorizedstore authenticated Jwt principalinject AuthenticationPrincipal JwtcheckCanAccessAccountId(accountId, jwt)allow or 403checkCanAccess(account, jwt)allow or 403200 JSON or 404AccountAccessGuard文件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包含customercustomer_id声明非 null 且非空白account_ids声明包含请求的accountId对checkCanAccess还需额外满足account.customerId()等于 JWT 的customer_idaccount.accountId()在 JWT 的account_ids列表中规则 4 —— 否则403AccountController文件services/banking-api-service/src/main/java/com/banking/poc/bankingapi/account/AccountController.java端点GET /api/accounts/{accountId}GetMapping(/{accountId})publicAccountDtogetAccount(PathVariableStringaccountId,AuthenticationPrincipalJwtjwt){accountAccessGuard.checkCanAccessAccountId(accountId,jwt);AccountDtoaccountaccountRepository.findById(accountId).orElseThrow(()-newResponseStatusException(HttpStatus.NOT_FOUND));accountAccessGuard.checkCanAccess(account,jwt);returnaccount;}流程从路径接收accountId从安全上下文接收已校验的JwtcheckCanAccessAccountId—— 若调用方无法访问该账户 ID尽早拒绝从AccountRepository加载账户 —— 未找到则返回404checkCanAccess—— 确认已加载账户与调用方的声明上下文匹配以 JSON 返回AccountDtoTransactionController文件services/banking-api-service/src/main/java/com/banking/poc/bankingapi/transaction/TransactionController.java端点GET /api/accounts/{accountId}/transactionsGetMapping(/{accountId}/transactions)publicListTransactionDtogetTransactions(PathVariableStringaccountId,AuthenticationPrincipalJwtjwt){accountAccessGuard.checkCanAccessAccountId(accountId,jwt);AccountDtoaccountaccountRepository.findById(accountId).orElseThrow(()-newResponseStatusException(HttpStatus.NOT_FOUND));accountAccessGuard.checkCanAccess(account,jwt);returntransactionRepository.findByAccountId(accountId);}流程与AccountController相同。两次守卫检查都通过后控制器从TransactionRepository加载交易列表并返回。类之间的关系SecurityConfigJwtDecoderSecurityFilterChainAccountControllerTransactionControllerAccountAccessGuardAccountRepositoryTransactionRepository互操作与KeycloakKeycloak签名 JWT、设置签发者并嵌入customer_id、account_ids与角色声明。该服务用Keycloak的 JWKS 端点校验签名并且不信任来自未签名或错误签发令牌的任何声明。与KongKong是把请求转发到banking-api-service的 PEP。然而该服务并不无条件信任Kong—— 它独立校验 JWT。这意味着对该服务的直接调用绕过Kong仍会得到同样的安全执行。与OPAOPA是Kong在边缘咨询的 PDP。banking-api-service不直接调用OPA。取而代之它从受信任的 JWT 声明出发再次执行同样的业务访问规则账户归属、角色。边缘与服务会收敛到同一答案因为二者读取的是同一组声明。与identity-bootstrap-serviceidentity-bootstrap-service创建带有角色、customer_id与account_ids的Keycloak用户。这些值会嵌入到banking-api-service后续接收并校验的每个 JWT 中。实际示例示例 1 ——alice访问她自己的账户alice的 JWTrealm_access.roles[customer]customer_idC-1001account_ids[A-1001]请求GET /api/accounts/A-1001结果JWT 校验通过签名、签发者、受众checkCanAccessAccountId通过A-1001在account_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的 JWTrealm_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控制器永不执行思维模型SecurityConfig在启动时装配两个 beanSecurityFilterChain与JwtDecoder每个请求过滤器链提取并校验 bearer 令牌签名 → 签发者 → 受众有效令牌成为一个受信任的Jwtprincipal通过AuthenticationPrincipal注入AccountAccessGuard使用realm_access.roles、customer_id与account_ids执行账户级授权只有在认证与授权检查都通过后控制器才返回数据← Prev: 08 — OPA · Next: 10 — identity-bootstrap-service →