前言

在​​微服务实战:基于Spring Cloud Gateway + AWS Cognito 的BFF案例​​一文中, 介绍了基于Amazon Cognito的OAuth授权码模式的认证流程。本文中,我们将研究可能针对此流程的恶意冲击及如何防止它们。你将了解如何使用状态随机数(state nonce)来防止可能的 CSRF 冲击,以及 OAuth 推荐的用于防止重定向拦截冲击的 PKCE(Proof Key for Code Exchange)机制。最后将附上基于Spring Security的代码实现。

CSRF

Cognito 的 登录端点(LOGIN Endpoint) 只需要几个参数:


  • ​response_type=code​
  • ​client_id​
  • ​redirect_uri​

这些都是公开信息,因为所有用户都使用相同的值。 因此,如果用户位于受冲击者控制的页面上,则冲击者可以将其重定向到有效的登录 URL。 如果用户最近登录过,Cognito 中的 AUTHORIZATION 端点将直接用有效​​code​​重定向,登录端点甚至可能不会提示用户登录。 因此,冲击者无需动一根手指就可让用户登录到 Web 应用程序。

冲击者无法控制 ​​redirect_uri​​ 参数,因为Cognito用户池配置了合法的重定向URI列表。 所以这块没有漏洞,冲击者没办法改变它。

但是冲击者控制的登录流程中有一个可选参数:​​state​​。 如果在 LOGIN 重定向中提供了该值,则 Cognito 将原封不动的返回给 Web 应用程序。


你的微服务在用 OAuth2 却不知道 CSRF 和 PKCE ?_客户端


​state​​本身不容易受到任何冲击。 但是 Web 应用程序可能会实现自定义逻辑,这种逻辑使用冲击者有机可乘。

想象一下,一个 CRM 应用程序处理客户帐户,它的实现方式是在访问令牌过期时自动重试失败的操作。 例如,管理员可能想要删除客户帐户,但后端返回一个错误,指出浏览器需要重新登录。 webapp 重定向到 Cognito登录页面,然后 webapp 获取新令牌,然后使用新令牌重试操作。 实现这一点的最简单方法是将失败的操作添加到状态参数(state)中,以便 webapp 可以知道要重试什么样的操作。

你的微服务在用 OAuth2 却不知道 CSRF 和 PKCE ?_ide_02





冲击

这样的实现为冲击者提供了一种欺骗用户向后端发送任意请求的方法。 这就是所谓的CSRF(跨站点请求伪造)冲击。 这是它的工作原理是:你的微服务在用 OAuth2 却不知道 CSRF 和 PKCE ?_ide_03



即使没有这么严重的后果,webapp 也应该防止在合法登录尝试之外获取令牌的行为。

防御

为了实现对 CSRF 冲击的防御,WebApp 需要生成并存储一个 nonce(随机生成的值仅使用一次)并将其用作状态。 然后,当 Cognito 重定向回 WebApp 时,WebApp 需要对比存储的状态值和接收到的状态值,如果不匹配则抛出错误。

你的微服务在用 OAuth2 却不知道 CSRF 和 PKCE ?_应用程序_04


使用这种安全机制,冲击者将无计可施。

PKCE

PKCE,发音为“pixy”,是 Proof Key for Code Exchange 的首字母缩写。 PKCE 流程和标准授权代码流程之间的主要区别是用户不需要提供 client_secret。 PKCE 降低了本地应用程序(Native Application, OAuth客户端的一种)的安全风险,因为源代码中不需要嵌入​​secret​​​,这限制了通过逆向工程的获取​​secret​​的可能性。

在​​rfc7636 - Proof Key for Code Exchange by OAuth Public Clients​​ 标准中定义的授权码重定向拦截冲击如下所示:

你的微服务在用 OAuth2 却不知道 CSRF 和 PKCE ?_重定向_05

|                                |     | +-------------+   +----------+ | (6) Access Token  +----------+     | |Legitimate   |   | Malicious|<--------------------|          |     | |OAuth 2.0 App|   | App      |-------------------->|          |     | +-------------+   +----------+ | (5) Authorization |          |     |        |    ^          ^       |        Grant      |          |     |        |     \         |       |                   |          |     |        |      \   (4)  |       |                   |          |     |    (1) |       \  Authz|       |                   |          |     |   Authz|        \ Code |       |                   |  Authz   |     | Request|         \     |       |                   |  Server  |     |        |          \    |       |                   |          |     |        |           \   |       |                   |          |     |        v            \  |       |                   |          |     | +----------------------------+ |                   |          |     | |                            | | (3) Authz Code    |          |     | |     Operating System/      |<--------------------|          |     | |         Browser            |-------------------->|          |     | |                            | | (2) Authz Request |          |     | +----------------------------+ |                   +----------+     +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~+               Figure 1: Authorization Code Interception Attack 复制代码


它是如何工作的?

为了代替 ​​client_secret​​​,客户端应用程序创建了一个唯一的字符串值 ​​code_verifier​​​,并其进行哈希处理生成 ​​code_challenge​​​。 当客户端应用程序启动授权码流程的第一部分时,它会发送一个 ​​code_challenge​​。

一旦用户通过身份验证并将授权码返回给客户端应用程序,它就会用授权码请求换取一个 ​​access_token​​。

在此步骤中,客户端应用程序必须在 ​​code_verifier​​​ 参数中包含原始唯一字符串值。 如果成功匹配,则身份验证完成并返回 ​​access_token​​。


冲击

当 Cognito 使用授权码参数重定向到 web 应用程序时,会出现上述安全问题。接下来会调用 TOKEN 端点,用得到的授权码换取访问令牌。 此调用需要以下参数:


  • ​grant_type=authorization_code​
  • ​client_id​
  • ​redirect_uri​
  • ​code​

除了授权码之外的所有信息都是公共信息,因为每个用户都是相同的。 因此,任何拥有有效授权码的人都可以获得有效的访问令牌。

同样,冲击者无法控制 Cognito 在登录后将用户重定向到何处,因为用户池配置了合法的重定向地址列表。 但在很多情况下,请求可能被捕获。 例如,移动应用程序可以为该特定 URL 注册一个处理程序,或者后端应用程序将这些请求日志记录到一个不安全的地方。 在这两种情况下,冲击者都可以读取授权码参数,然后获得有效的令牌。

你的微服务在用 OAuth2 却不知道 CSRF 和 PKCE ?_spring_06


防御

PKCE是一个 OAuth 2.0 的扩展,用于保护授权码重定向。 这类似于 ​​state​​​ 参数,但它由 TOKEN 端点强制执行。 当 webapp 重定向到 LOGIN 端点时,它会设置一个 ​​code_challenge​​​。 Cognito 生成的授权码与这个​​code_challenge​​​相关联,在获取令牌是,除了授权码之外,还需要一个 ​​code_verifier​​ 参数。

即使冲击者能够拦截重定向并获取到​​code​​​参数,但没有webapp生成的​​code_verifier​​也是白搭。 0% 0%


实现

我们使用Spring Security对OAuth2认证流程提供支持。幸运的是Spring Security已经原生支持 CSRF 和 PKCE 了。使用Spring Security跳转到 LOGIN 端点时,会自动设置​​state​​​和​​nonce​​参数,对开发者来说确实很省心。

但是,需要注意的是,对于 PKCE ,Spring Security默认只支持 ​​Public Client​​​类型,比如Native Application或者基于浏览器的SPA等等。参考文档:​​Initiating the Authorization Request​​,Public Client需要满足如下条件:


  1. 在application.yaml中没有配置​​client-secret​​,或者值为空
  2. ​client-authentication-method​​​ 设置为 "none" (​​ClientAuthenticationMethod.NONE​​)

那么问题来了,我们的OAuth客户端是采用的Spring Cloud Gateway,是属于Confidential Client,Spring Security默认是不支持对这种客户端采取 PKCE 机制的。幸好Spring Security的设计非常易于扩展,我们可以自定义一个​​ServerOAuth2AuthorizationRequestResolver​​​,用于添加​​code_verifier​​​和​​code_challenge​​参数。代码如下:


@Component
public class MyOAuth2AuthorizationRequestResolver implements ServerOAuth2AuthorizationRequestResolver {

private DefaultServerOAuth2AuthorizationRequestResolver defaultResolver;

private final StringKeyGenerator secureKeyGenerator =
new Base64StringKeyGenerator(Base64.getUrlEncoder().withoutPadding(), 96);

public MyOAuth2AuthorizationRequestResolver(ReactiveClientRegistrationRepository clientRegistrationRepository) {
defaultResolver = new DefaultServerOAuth2AuthorizationRequestResolver(clientRegistrationRepository);
}

@Override
public Mono<OAuth2AuthorizationRequest> resolve(ServerWebExchange exchange) {
return defaultResolver.resolve(exchange).map(req -> customizeAuthorizationRequest(req));
}

@Override
public Mono<OAuth2AuthorizationRequest> resolve(ServerWebExchange exchange, String clientRegistrationId) {
return defaultResolver.resolve(exchange, clientRegistrationId).map(req -> customizeAuthorizationRequest(req));
}

private OAuth2AuthorizationRequest customizeAuthorizationRequest(OAuth2AuthorizationRequest req) {
if (req == null) { return null; }

Map<String, Object> attributes = new HashMap<>(req.getAttributes());
Map<String, Object> additionalParameters = new HashMap<>(req.getAdditionalParameters());
addPkceParameters(attributes, additionalParameters);
return OAuth2AuthorizationRequest.from(req)
.attributes(attributes)
.additionalParameters(additionalParameters)
.build();
}

private void addPkceParameters(Map<String, Object> attributes, Map<String, Object> additionalParameters) {
String codeVerifier = this.secureKeyGenerator.generateKey();
attributes.put(PkceParameterNames.CODE_VERIFIER, codeVerifier);
try {
String codeChallenge = createHash(codeVerifier);
additionalParameters.put(PkceParameterNames.CODE_CHALLENGE, codeChallenge);
additionalParameters.put(PkceParameterNames.CODE_CHALLENGE_METHOD, "S256");
} catch (NoSuchAlgorithmException e) {
additionalParameters.put(PkceParameterNames.CODE_CHALLENGE, codeVerifier);
}
}

private static String createHash(String value) throws NoSuchAlgorithmException {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] digest = md.digest(value.getBytes(StandardCharsets.US_ASCII));
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
}
}
复制代码



同时,在​​SecurityWebFilterChain​​​的设置中,需要加入自定义的​​ServerOAuth2AuthorizationRequestResolver​​。如下所示:



@Bean
SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http,
ReactiveClientRegistrationRepository clientRegistrationRepository,
MyAuthorizationManager authorizationManager,
MyOAuth2AuthorizationRequestResolver oAuth2AuthorizationRequestResolver) {
// Authenticate through configured OpenID Provider
// user custom request resolver to add code_challenge & code_challenge_method(S256) for PKCE
http.oauth2Login(oAuth2LoginSpec -> oAuth2LoginSpec.authorizationRequestResolver(oAuth2AuthorizationRequestResolver));
// ...

return http.build();
}
复制代码



这样,在API网关向Cognito发出的 LOGIN 请求时,​​code_verifier​​​和​​code_challenge​​参数就被加上了。

你的微服务在用 OAuth2 却不知道 CSRF 和 PKCE ?_应用程序_07


总结

本文介绍了基于OAuth认证的微服务可能遇到的安全问题,即CSRF冲击和重定向冲击,同时介绍了基于Spring Security的解决方案。

如果对你有所帮助,请点赞订阅分享,感谢!