SpringSecurity OAuth2 (10) 自定义: 启用 CsrfToken 保护授权服务器颁发的 AccessToken

目前 Auth 体系存在的问题

CSRF (Cross-Site Request Forgery), 本质上是对用户侧的: 已颁发令牌后的操作的防护. 设想如果用户操作时, 令牌被攻击者盗取了, 资源服务应该如何保证接下来的请求仍然是用户侧的请求? 这就是本文要探讨的内容 (之前我们已经在 SpringSecurity 下应用了 CSRF 策略, 本篇着重在 SpringSecurity OAuth2 的授权服务器和资源服务器分离体系下, 应用 CSRF 机制).

☞ 本文代码基于 上一篇 构建.

☞ 关于 CSRF 和 SpringSecurity 的策略, 在 SpringSecurity (4) CSRF 与 CSRF-TOKEN 的处理 已有详述

目前的认证流程是: 请求去向授权服务器 → 授权服务器认证并颁发令牌 → 请求方拿到令牌访问资源服务器.

授权服务器颁发了 JWT 格式的 ACCESS-TOKEN 后, 如果这个 JWT 被盗取了, 如何保证安全? 有一种思路: 当一次成功的认证完成后, 在授权服务器颁发 ACCESS-TOKEN 同时, 也生成一个 CSRF-TOKEN, 后者随响应头返回给请求方, 同时在授权服务器, 以 “唯一请求标识” 缓存这个 CSRF-TOKEN. 接着请求方用 ACCESS-TOKEN 请求资源服务器的时候同时也要在请求头中置入授权服务器颁发的 CSRF-TOKEN, 在资源服务器, 每一次成功的请求之后, 都刷新并返回一个新的 CSRF-TOKEN, 同时也以这个 “唯一请求标识” 为 KEY, 缓存新的 CSRF-TOKEN. 所以这么看起来, CSRF-TOKEN 和一次请求一一对应.

本文我们将讨论, 如何在授权服务器和资源服务器分离的场景下, 启用 CSRF 策略保护 JWT.

授权服务器颁发 CSRF-TOKEN

授权服务器的职责新增有二:

一. 生成 CSRF-TOKEN 并随响应头返回;

二. 以 “唯一请求标识” 为 KEY 缓存 CSRF-TOKEN, 供资源服务器读取并刷新;

来总结一下 OAuth 2.0 的 5 种授权类型的请求 ACCESS-TOKEN 的 URL 样式:

  1. AuthorizationCode (先校验用户信息, 再校验客户端信息):
    1. 首先以 /oauth/authorize response_type=code 获取授权码;
    2. 接着携带授权码请求 /oauth/token grant_type=authorization_code 换取 ACCESS-TOKEN;
  2. Implicit (先校验用户信息, 再校验客户端信息): 请求 /oauth/authorize response_type=token 用户同意后直接获取令牌;
  3. ResourceOwnerPassword (先校验客户端信息, 再校验用户信息): /oauth/token grant_type=password;
  4. ClientCredentials (只校验客户端信息): /oauth/token grant_type=client_credentials;
  5. RefreshToken: /oauth/token grant_type=refresh_token;

可见除了隐式模式外, 其他授权模式都会访问 /oauth/token 端点.

引言

我们需要在返回给前端的响应中放入 CSRF-TOKEN, 主要问题是响应, 如何在响应中放入自定义的数据? 有两个方式: [过滤器 / 拦截器] 或 [AOP 环绕通知]. 我们知道在过滤器中可以为响应置入自定义的响应头, 但是, 在 chain.doFilter(request, response) 方法之后, 这个操作是无效的, 原因就是 ServletResponse 的 isCommitted 已经是 true, 而在 doFilter 之前, 认证尚未完成, 所以看起来采用过滤器的方式似乎不行; 而 AOP 环绕通知, 可以在方法执行前后插入自己的逻辑, 并且包装返回值, 感觉可取.

所以最终, 考虑用分别对应 TokenEndpointAuthorizationEndpoint 的两个切面类来处理 CSRF-TOKEN.

TokenEndpoint 和 AuthorizationEndpoint

来看看 TokenEndpoint 的接收请求的方法究竟干了什么事情:

@FrameworkEndpoint
public class TokenEndpoint extends AbstractEndpoint {
    // ...
    
    @RequestMapping(value = "/oauth/token", method=RequestMethod.GET)
	public ResponseEntity<OAuth2AccessToken> getAccessToken(Principal principal, @RequestParam
	Map<String, String> parameters) throws HttpRequestMethodNotSupportedException {
		if (!allowedRequestMethods.contains(HttpMethod.GET)) {
			throw new HttpRequestMethodNotSupportedException("GET");
		}
		return postAccessToken(principal, parameters);
	}
	
	@RequestMapping(value = "/oauth/token", method=RequestMethod.POST)
	public ResponseEntity<OAuth2AccessToken> postAccessToken(Principal principal, @RequestParam
	Map<String, String> parameters) throws HttpRequestMethodNotSupportedException {

		if (!(principal instanceof Authentication)) {
			throw new InsufficientAuthenticationException(
					"There is no client authentication. Try adding an appropriate authentication filter.");
		}

		String clientId = getClientId(principal);
		ClientDetails authenticatedClient = getClientDetailsService().loadClientByClientId(clientId);

		TokenRequest tokenRequest = getOAuth2RequestFactory().createTokenRequest(parameters, authenticatedClient);

		if (clientId != null && !clientId.equals("")) {
			// Only validate the client details if a client authenticated during this
			// request.
			if (!clientId.equals(tokenRequest.getClientId())) {
				// double check to make sure that the client ID in the token request is the same as that in the
				// authenticated client
				throw new InvalidClientException("Given client ID does not match authenticated client");
			}
		}
		if (authenticatedClient != null) {
			oAuth2RequestValidator.validateScope(tokenRequest, authenticatedClient);
		}
		if (!StringUtils.hasText(tokenRequest.getGrantType())) {
			throw new InvalidRequestException("Missing grant type");
		}
		if (tokenRequest.getGrantType().equals("implicit")) {
			throw new InvalidGrantException("Implicit grant type not supported from token endpoint");
		}

		if (isAuthCodeRequest(parameters)) {
			// The scope was requested or determined during the authorization step
			if (!tokenRequest.getScope().isEmpty()) {
				logger.debug("Clearing scope of incoming token request");
				tokenRequest.setScope(Collections.<String> emptySet());
			}
		}

		if (isRefreshTokenRequest(parameters)) {
			// A refresh token has its own default scopes, so we should ignore any added by the factory here.
			tokenRequest.setScope(OAuth2Utils.parseParameterList(parameters.get(OAuth2Utils.SCOPE)));
		}

		OAuth2AccessToken token = getTokenGranter().grant(tokenRequest.getGrantType(), tokenRequest);
		if (token == null) {
			throw new UnsupportedGrantTypeException("Unsupported grant type: " + tokenRequest.getGrantType());
		}

		return getResponse(token);

	}
    
    private ResponseEntity<OAuth2AccessToken> getResponse(OAuth2AccessToken accessToken) {
		HttpHeaders headers = new HttpHeaders();
		headers.set("Cache-Control", "no-store");
		headers.set("Pragma", "no-cache");
		return new ResponseEntity<OAuth2AccessToken>(accessToken, headers, HttpStatus.OK);
	}
    
    // ...
}

读取客户端详情, 构建请求对象, 调用对应 Granter 生成 OAuth2AccessToken. 其中, 读取客户端详情 (CustomClientDetailsService) 和 Granter (CustomTokenGranter) 都是我们自定义的. 并且最终返回的是 ResponseEntity 对象, 它是 Spring 提供的响应对象实体, 由 HttpStatus, HttpHeaders 和 Body 组成. 由此可见在环绕通知中, 可以获取到原本返回的 ResponseEntity, 将其重新包装并附带 CSRF-TOKEN 返回.


AuthorizationEndpoint 方面, 对于端点 /oauth/authorize 有两个接收方法, 其中 authorize 的职责是: 首次请求转发 (forward:/oauth/confirm_access), 在用户允许后, 处理令牌申请请求的的 api 实际上是 approveOrDeny 方法.

@FrameworkEndpoint
@SessionAttributes("authorizationRequest")
public class AuthorizationEndpoint extends AbstractEndpoint {
    // ...
    
    private final String userApprovalPage = "forward:/oauth/confirm_access";
    
    @RequestMapping(value = "/oauth/authorize")
	public ModelAndView authorize(Map<String, Object> model, @RequestParam Map<String, String> parameters,
			SessionStatus sessionStatus, Principal principal) {

		// Pull out the authorization request first, using the OAuth2RequestFactory. All further logic should
		// query off of the authorization request instead of referring back to the parameters map. The contents of the
		// parameters map will be stored without change in the AuthorizationRequest object once it is created.
		AuthorizationRequest authorizationRequest = getOAuth2RequestFactory().createAuthorizationRequest(parameters);

		Set<String> responseTypes = authorizationRequest.getResponseTypes();

		if (!responseTypes.contains("token") && !responseTypes.contains("code")) {
			throw new UnsupportedResponseTypeException("Unsupported response types: " + responseTypes);
		}

		if (authorizationRequest.getClientId() == null) {
			throw new InvalidClientException("A client id must be provided");
		}

		try {

			if (!(principal instanceof Authentication) || !((Authentication) principal).isAuthenticated()) {
				throw new InsufficientAuthenticationException(
						"User must be authenticated with Spring Security before authorization can be completed.");
			}

			ClientDetails client = getClientDetailsService().loadClientByClientId(authorizationRequest.getClientId());

			// The resolved redirect URI is either the redirect_uri from the parameters or the one from
			// clientDetails. Either way we need to store it on the AuthorizationRequest.
			String redirectUriParameter = authorizationRequest.getRequestParameters().get(OAuth2Utils.REDIRECT_URI);
			String resolvedRedirect = redirectResolver.resolveRedirect(redirectUriParameter, client);
			if (!StringUtils.hasText(resolvedRedirect)) {
				throw new RedirectMismatchException(
						"A redirectUri must be either supplied or preconfigured in the ClientDetails");
			}
			authorizationRequest.setRedirectUri(resolvedRedirect);

			// We intentionally only validate the parameters requested by the client (ignoring any data that may have
			// been added to the request by the manager).
			oauth2RequestValidator.validateScope(authorizationRequest, client);

			// Some systems may allow for approval decisions to be remembered or approved by default. Check for
			// such logic here, and set the approved flag on the authorization request accordingly.
			authorizationRequest = userApprovalHandler.checkForPreApproval(authorizationRequest,
					(Authentication) principal);
			// TODO: is this call necessary?
			boolean approved = userApprovalHandler.isApproved(authorizationRequest, (Authentication) principal);
			authorizationRequest.setApproved(approved);

			// Validation is all done, so we can check for auto approval...
			if (authorizationRequest.isApproved()) {
				if (responseTypes.contains("token")) {
					return getImplicitGrantResponse(authorizationRequest);
				}
				if (responseTypes.contains("code")) {
					return new ModelAndView(getAuthorizationCodeResponse(authorizationRequest,
							(Authentication) principal));
				}
			}

			// Place auth request into the model so that it is stored in the session
			// for approveOrDeny to use. That way we make sure that auth request comes from the session,
			// so any auth request parameters passed to approveOrDeny will be ignored and retrieved from the session.
			model.put("authorizationRequest", authorizationRequest);

			return getUserApprovalPageResponse(model, authorizationRequest, (Authentication) principal);

		}
		catch (RuntimeException e) {
			sessionStatus.setComplete();
			throw e;
		}

	}

	@RequestMapping(value = "/oauth/authorize", method = RequestMethod.POST, params = OAuth2Utils.USER_OAUTH_APPROVAL)
	public View approveOrDeny(@RequestParam Map<String, String> approvalParameters, Map<String, ?> model,
			SessionStatus sessionStatus, Principal principal) {

		if (!(principal instanceof Authentication)) {
			sessionStatus.setComplete();
			throw new InsufficientAuthenticationException(
					"User must be authenticated with Spring Security before authorizing an access token.");
		}

		AuthorizationRequest authorizationRequest = (AuthorizationRequest) model.get("authorizationRequest");

		if (authorizationRequest == null) {
			sessionStatus.setComplete();
			throw new InvalidRequestException("Cannot approve uninitialized authorization request.");
		}

		try {
			Set<String> responseTypes = authorizationRequest.getResponseTypes();

			authorizationRequest.setApprovalParameters(approvalParameters);
			authorizationRequest = userApprovalHandler.updateAfterApproval(authorizationRequest,
					(Authentication) principal);
			boolean approved = userApprovalHandler.isApproved(authorizationRequest, (Authentication) principal);
			authorizationRequest.setApproved(approved);

			if (authorizationRequest.getRedirectUri() == null) {
				sessionStatus.setComplete();
				throw new InvalidRequestException("Cannot approve request when no redirect URI is provided.");
			}

			if (!authorizationRequest.isApproved()) {
				return new RedirectView(getUnsuccessfulRedirect(authorizationRequest,
						new UserDeniedAuthorizationException("User denied access"), responseTypes.contains("token")),
						false, true, false);
			}

			if (responseTypes.contains("token")) {
				return getImplicitGrantResponse(authorizationRequest).getView();
			}

			return getAuthorizationCodeResponse(authorizationRequest, (Authentication) principal);
		}
		finally {
			sessionStatus.setComplete();
		}

	}
    
    // ...
}

approvalOrDeny 看名字都知道这就是根据用户允许或是拒绝授权后的处理方法. 其返回值是一个 RedirectView, 而 RedirectView 本身支持更改 URL (AbstractUrlBasedView#setUrl(String url)). 我们也知道隐式授权模式在用户同意后直接会将 ACCESS-TOKEN 放到 URL 参数中返回. 对于 CSRF-TOKEN, 同样的也应该放到 URL 参数中. 而重新包装 RedirectView, 就可以做到这一点.

private View getAuthorizationCodeResponse(AuthorizationRequest authorizationRequest, Authentication authUser) {
	try {
		return new RedirectView(getSuccessfulRedirect(authorizationRequest,
				generateCode(authorizationRequest, authUser)), false, true, false);
	}
	catch (OAuth2Exception e) {
		return new RedirectView(getUnsuccessfulRedirect(authorizationRequest, e, false), false, true, false);
	}
}

唯一请求标识: 指纹码机制

以往的方式, 我们会将生成的 CSRF-TOKEN 放到服务器内存中, 用于请求读取和程序刷新. 但是现在授权服务器和资源服务器分离, 且无论是授权服务器还是资源服务器, 都可能部署多份, 放到内存里显然不行.

那放到缓存里, 如何标识? 用户ID 或者客户端ID (客户端授权模式)?

如果同一用户在多终端的登陆请求如何区分: 用户在 A 端登陆, 生成了一个以用户ID 为标识的 CSRF-TOKEN, 几秒后用户在 B 端登陆, 同样生成, 由于 KEY 一致所以导致本应缓存的对应 A 端的 CSRF-TOKEN 覆盖, 然后在 A 端已经获取到 ACCESS-TOKEN 和 CSRF-TOKEN 的请求无论怎样, 都无法访问到资源服务器, 因为其持有的 CSRF-TOKEN 已经被 B 端的登陆请求覆盖了.

客户端同理, 同一客户端在第一次请求时, 获取到了 授权服务器以客户端ID为 KEY 缓存的 CSRF-TOKEN, 第一次请求资源服务器, 资源服务器刷新了 CSRF-TOKEN, 但是在响应给客户端时耗时较长. 与此同时, 来自同一客户端的第二次请求进来了, 这一次请求携带的 CSRF-TOKEN 还是原始的那一份 (因为第一次响应还没来得及返回给客户端所以客户端的 CSRF-TOKEN 也没有得到更新), 这种情况也会发生 CSRF-TOKEN 冲突.


可以采用指纹码机制解决这个问题: 针对用户来自不同终端的请求, 由前端生成一个与浏览器关联的唯一标识 (例如 fingerprintjs2, 就可以做到这一点 ); 对于客户端, 同样的, 生成由 JVM 进程和线程 ID 标识的唯一 “指纹码”. 这样就能做到将 CSRF-TOKEN 与一次请求对应起来.

实现

获取用户端 / 客户端标识

授权服务器需要以 用户ID 或 客户端ID 加之指纹码缓存 CSRF-TOKEN, 如何在切面类中获取到 用户ID 或是 客户端ID 呢? 一般会想到通过 SecurityContextHolder 获取, 但是实际上对于像 AuthorizationCode & Implicit 这种授权方式, 用户登陆授权和获取令牌是两个独立的请求, 在首次用户登陆时, 确实能通过 SecurityContextHolder#getContext().getAuthentication() 获取到用户ID, 但是我们也知道每次请求结束后, 安全上下文就会被清空. 而在客户端拿到授权码换取 ACCESS-TOKEN 的那一次请求, 这个认证对象代表的就是客户端的信息了. 对于有用户介入的授权模式, 授权服务器需要以 “用户ID.指纹码” 作为缓存 KEY, 而非 客户端ID, 所以我们需要在 CSRF 逻辑中获取到 用户ID. 如何做到?

来定义一个自己的 OAuth2AuthenticationHolder:

/**
 * Description: 与线程绑定的 {@link OAuth2Authentication} Holder<br>
 * Details: 区别于 {@link org.springframework.security.core.context.SecurityContextHolder}, {@link OAuth2AuthenticationHolder}
 * 持有的 {@link OAuth2Authentication} 独立于安全框架的认证逻辑之外.
 *
 * @author LiKe
 * @version 1.0.0
 * @date 2020-08-09 17:53
 */
public final class OAuth2AuthenticationHolder {

    private static final ThreadLocal<OAuth2Authentication> holder = new ThreadLocal<>();

    /**
     * Description: 获得与线程绑定的认证对象
     *
     * @return org.springframework.security.oauth2.provider.OAuth2Authentication
     * @author LiKe
     * @date 2020-08-10 10:58:36
     */
    public static OAuth2Authentication get() {
        return holder.get();
    }

    /**
     * Description: 将认证对象与线程绑定
     *
     * @param oAuth2Authentication {@link OAuth2Authentication}
     * @return void
     * @author LiKe
     * @date 2020-08-10 10:58:50
     */
    public static void set(OAuth2Authentication oAuth2Authentication) {
        holder.set(oAuth2Authentication);
    }

    /**
     * Description: 清除与线程绑定的认证对象
     *
     * @return void
     * @author LiKe
     * @date 2020-08-10 10:59:04
     */
    public static void clear() {
        holder.remove();
    }

}

每一种授权模式, 都会通过 TokenGranter 获取 OAuth2AccessToken. 而构建 OAuth2AccessToken 需要 OAuth2Authentication, OAuth2Authentication 在之前的文章中有分析过, 持有的是用户侧和客户端侧的认证对象. 所以只要能够将 OAuth2Authentication 放到 OAuth2AuthenticationHolder 中, 在切面我们就可以拿到 用户ID 或是 客户端ID了.

为此, 我们需要在默认的 Granter 上增加这段逻辑:

AuthenticationCodeTokenGranter
public class CustomAuthorizationCodeTokenGranter extends AuthorizationCodeTokenGranter {

    public CustomAuthorizationCodeTokenGranter(AuthorizationServerTokenServices tokenServices, AuthorizationCodeServices authorizationCodeServices, ClientDetailsService clientDetailsService, OAuth2RequestFactory requestFactory) {
        super(tokenServices, authorizationCodeServices, clientDetailsService, requestFactory);
    }

    /**
     * Description: 覆盖 {@link AuthorizationCodeTokenGranter#getOAuth2Authentication(ClientDetails, TokenRequest)} 逻辑.<br>
     * 将认证对象 ({@link OAuth2Authentication}) 与安全框架的安全逻辑 ({@link org.springframework.security.core.context.SecurityContextHolder}) 无关的上下文绑定 ({@link OAuth2AuthenticationHolder}), 供后续使用.
     * Details: 被 {@link org.springframework.security.oauth2.provider.token.AbstractTokenGranter#grant(String, TokenRequest)} 调用.<br>
     * 与线程绑定的 {@link OAuth2Authentication} 会用于 csrf 保护机制判断逻辑的依据之一 (ref: {@link c.c.d.s.s.o.c.as.configuration.csrf.TokenEndpointCsrfStrategyAspect}).
     *
     * @param client       {@link ClientDetails} 的自定义实现: {@link c.c.d.s.s.o.c.as.configuration.support.client.CustomClientDetails}
     * @param tokenRequest {@link TokenRequest}
     * @return org.springframework.security.oauth2.provider.OAuth2Authentication
     * @author LiKe
     * @date 2020-08-09 17:31:39
     * @see AuthorizationCodeTokenGranter#getOAuth2Authentication(ClientDetails, TokenRequest)
     * @see OAuth2AuthenticationHolder
     * @see c.c.d.s.s.o.c.as.configuration.csrf.TokenEndpointCsrfStrategyAspect
     */
    @Override
    protected OAuth2Authentication getOAuth2Authentication(ClientDetails client, TokenRequest tokenRequest) {
        final OAuth2Authentication oAuth2Authentication = super.getOAuth2Authentication(client, tokenRequest);

        // ~ 将认证对象与线程绑定
        OAuth2AuthenticationHolder.set(oAuth2Authentication);
        return oAuth2Authentication;
    }
}
ImplicitTokenGranter
public class CustomImplicitTokenGranter extends ImplicitTokenGranter {

    public CustomImplicitTokenGranter(AuthorizationServerTokenServices tokenServices, ClientDetailsService clientDetailsService, OAuth2RequestFactory requestFactory) {
        super(tokenServices, clientDetailsService, requestFactory);
    }

    /**
     * @see CustomAuthorizationCodeTokenGranter#getOAuth2Authentication(ClientDetails, TokenRequest)
     */
    @Override
    protected OAuth2Authentication getOAuth2Authentication(ClientDetails client, TokenRequest clientToken) {
        final OAuth2Authentication oAuth2Authentication = super.getOAuth2Authentication(client, clientToken);

        // ~ 将认证对象与线程绑定
        OAuth2AuthenticationHolder.set(oAuth2Authentication);
        return oAuth2Authentication;
    }
}
ResourceOwnerPasswordTokenGranter
public class CustomResourceOwnerPasswordTokenGranter extends ResourceOwnerPasswordTokenGranter {

    public CustomResourceOwnerPasswordTokenGranter(AuthenticationManager authenticationManager, AuthorizationServerTokenServices tokenServices, ClientDetailsService clientDetailsService, OAuth2RequestFactory requestFactory) {
        super(authenticationManager, tokenServices, clientDetailsService, requestFactory);
    }

    /**
     * @see CustomAuthorizationCodeTokenGranter#getOAuth2Authentication(ClientDetails, TokenRequest)
     */
    @Override
    protected OAuth2Authentication getOAuth2Authentication(ClientDetails client, TokenRequest tokenRequest) {
        final OAuth2Authentication oAuth2Authentication = super.getOAuth2Authentication(client, tokenRequest);

        // ~ 将认证对象与线程绑定
        OAuth2AuthenticationHolder.set(oAuth2Authentication);
        return oAuth2Authentication;
    }
}
ClientCredentialsTokenGranter
public class CustomClientCredentialsTokenGranter extends ClientCredentialsTokenGranter {

    public CustomClientCredentialsTokenGranter(AuthorizationServerTokenServices tokenServices, ClientDetailsService clientDetailsService, OAuth2RequestFactory requestFactory) {
        super(tokenServices, clientDetailsService, requestFactory);
    }

    /**
     * @see CustomAuthorizationCodeTokenGranter#getOAuth2Authentication(ClientDetails, TokenRequest)
     */
    @Override
    protected OAuth2Authentication getOAuth2Authentication(ClientDetails client, TokenRequest tokenRequest) {
        final OAuth2Authentication oAuth2Authentication = super.getOAuth2Authentication(client, tokenRequest);

        // ~ 将认证对象与线程绑定
        OAuth2AuthenticationHolder.set(oAuth2Authentication);
        return oAuth2Authentication;
    }
}
RefreshToken

对于刷新令牌, 比较特殊, 通过 RefreshTokenGranter 的源码也可以看到, 它实现的是 getAccessToken 方法, 返回的不是 OAuth2Authentication, 而直接是 OAuth2AccessToken, 后者是没持有用户信息的.

public class RefreshTokenGranter extends AbstractTokenGranter {
    // ...
 	
    @Override
	protected OAuth2AccessToken getAccessToken(ClientDetails client, TokenRequest tokenRequest) {
		String refreshToken = tokenRequest.getRequestParameters().get("refresh_token");
		return getTokenServices().refreshAccessToken(refreshToken, tokenRequest);
	}
}

只能在更下一层的 tokenServices 中获取到 OAuth2Authentication, 好在 tokenServices 我们也自定义了 (请翻阅之前的文章): CustomAuthorizationServerTokenServices. 在其 refreshAccessToken 方法中:

/**
 * 自定义的 {@link AuthorizationServerTokenServices}
 *
 * @author LiKe
 * @version 1.0.0
 * @date 2020-07-08 14:59
 * @see DefaultTokenServices
 * @see AuthorizationServerTokenServices
 */
public class CustomAuthorizationServerTokenServices extends DefaultTokenServices {
    // ...
 
    /**
     * 刷新 access-token
     *
     * @see DefaultTokenServices#refreshAccessToken(String, TokenRequest)
     */
    @Override
    public OAuth2AccessToken refreshAccessToken(String refreshTokenValue, TokenRequest tokenRequest) throws AuthenticationException {
        final String clientId = tokenRequest.getClientId();
        if (Objects.isNull(clientId) || !StringUtils.equals(clientId, tokenRequest.getClientId())) {
            throw new InvalidGrantException("Unmatched client for this token request.");
        }

        if (!isSupportRefreshToken(clientId)) {
            throw new InvalidGrantException(String.format("Refresh token is not supported for client: (%s)!", clientId));
        }

        final OAuth2RefreshToken refreshToken = tokenStore.readRefreshToken(refreshTokenValue);
        if (Objects.isNull(refreshToken)) {
            throw new InvalidTokenException(String.format("Invalid refresh token: %s!", refreshTokenValue));
        }

        // ~ 用 refresh_token 获取 OAuth2 认证信息
        OAuth2Authentication oAuth2Authentication = tokenStore.readAuthenticationForRefreshToken(refreshToken);
        if (Objects.nonNull(this.authenticationManager) && !oAuth2Authentication.isClientOnly()) {
            oAuth2Authentication = new OAuth2Authentication(
                    oAuth2Authentication.getOAuth2Request(),
                    authenticationManager.authenticate(
                            new PreAuthenticatedAuthenticationToken(oAuth2Authentication.getUserAuthentication(), StringUtils.EMPTY, oAuth2Authentication.getAuthorities())
                    )
            );
            oAuth2Authentication.setDetails(oAuth2Authentication.getDetails());
        }

        tokenStore.removeAccessTokenUsingRefreshToken(refreshToken);

        if (isExpired(refreshToken)) {
            tokenStore.removeRefreshToken(refreshToken);
            throw new InvalidTokenException("Invalid refresh token: expired!");
        }

        // ~ 刷新 OAuth2 认证信息, 并基于此构建新的 OAuth2AccessToken
        oAuth2Authentication = createRefreshedAuthentication(oAuth2Authentication, tokenRequest);
        // ~ 绑定 OAuth2Authentication
        bindOAuth2Authentication(oAuth2Authentication);
        // ~ 获取新的 refresh_token
        final OAuth2AccessToken refreshedAccessToken = tokenGenerator.createAccessToken(oAuth2Authentication, refreshToken);
        tokenStore.storeAccessToken(refreshedAccessToken, oAuth2Authentication);
        return refreshedAccessToken;
    }
    
    // ...
    
    /**
     * Description: 绑定 {@link OAuth2Authentication}<br>
     * Details: 将 {@link OAuth2Authentication} 用自定义的 Holder 绑定, 供 CSRF 机制使用
     *
     * @param authentication {@link OAuth2Authentication}
     * @return void
     * @author LiKe
     * @date 2020-08-14 10:33:05
     */
    private void bindOAuth2Authentication(OAuth2Authentication authentication) {
        OAuth2AuthenticationHolder.set(authentication);
    }
    
    // ...
}

定义 CSRF 策略

接下来玩玩设计模式, 为每一种授权模式分别定义策略类. 根据请求匹配到指定的策略并执行对应逻辑. 越说越复杂还不如直接看代码:

AbstractCsrfStrategy

抽象策略模板类.

/**
 * Description: CSRF 防护机制抽象策略标准<br>
 * Details:
 * <ul>
 *   <li>
 *       对于授权码模式, 密码模式, 隐式模式, 这个 CSRF-TOKEN 以用户 ID 标识, 接下来外部携带 ACCESS-TOKEN 和 CSRF-TOKEN 访问资源服务器时,
 *       每次会刷新 CSRF-TOKEN 并返回给前端.<br>
 *       <b>可以采用用户 ID + 用户指纹码来标识来自指定浏览器的用户的请求</b>.
 *   </li>
 *   <li>
 *       对于客户端模式来说, 本身仅用于后端受信任客户端的交互, 如果一定也要用 CSRF 机制, 也需要在授权服务器颁发 ACCESS-TOKEN 时,
 *       也同时生成 CSRF-TOKEN.<br>
 *       <b>可以采用客户端 ID + MD5(JVM 进程 ID + 线程 ID) 标识来自指定客户端的请求</b>.
 *   </li>
 * </ul>
 * <b>Fingerprint Mechanism</b> (指纹码机制): 在原有 OAuth 2.0 标准请求参数之外追加 "指纹码" 参数, 授权服务器首次办法令牌时, 以指纹码为 Key 的一部分缓存 CSRF-TOKEN,
 * 同时请求方以这个指纹码作为额外凭证随授权服务器颁发的 CSRF-TOKEN 请求资源服务器. 只要指纹码变动, CSRF-TOKEN 也会唯一对应.<br>
 * ☞ 对于客户端来说, 这个机制保证了同一客户端短期内多次请求可能导致的 CSRF-TOKEN 冲突问题: 同一客户端第一次请求, 刷新了 CSRF-TOKEN,
 * 与此同时在第一次请求返回之前, 第二次请求携带旧的 CSRF-TOKEN 继续请求, 就会导致 CSRF-TOKEN 冲突.<br>
 * ☞ 对于用户端, 指纹码可以区分同一用户在不同浏览器的请求.
 *
 * @author LiKe
 * @version 1.0.0
 * @date 2020-08-10 14:11
 */
@Slf4j
public abstract class AbstractCsrfStrategy {

    public static final String CSRF_HEADER_NAME = "X-CSRF-TOKEN";

    /**
     * 指纹码 (用于区分请求)
     */
    protected static final String FORM_FINGERPRINT = "fingerprint";

    /**
     * 由授权服务器颁发的, 缓存的 csrf-token 的 Key 的前缀
     */
    private static final String CACHE_PREFIX_CSRF_TOKEN = "authorization-server." + Strings.toLowerCase(CSRF_HEADER_NAME);

    private static final String DASH = "-";

    // ~ Template methods
    // -----------------------------------------------------------------------------------------------------------------

    /**
     * 子类实现: 提供授权类型
     */
    public abstract GrantType getGrantType();

    /**
     * Description: 判断当前策略是否支持这个请求
     *
     * @param parameters 请求参数 Map
     * @return boolean
     * @author LiKe
     * @date 2020-08-10 13:53:14
     */
    public abstract boolean supports(Map<String, ?> parameters);

    // =================================================================================================================

    /**
     * Description: 执行策略逻辑
     *
     * @param parameters 请求参数 Map
     * @return void
     * @author LiKe
     * @date 2020-08-10 14:05:18
     */
    public String execute(Map<String, ?> parameters) {
        final OAuth2Authentication oAuth2Authentication = OAuth2AuthenticationHolder.get();

        // ~ 请求方 ID, 用于构建出唯一请求标识
        String id;
        if (this.getGrantType() == GrantType.CLIENT_CREDENTIALS) {
            id = oAuth2Authentication.getName();
        } else {
            id = oAuth2Authentication.getUserAuthentication().getName();
        }

        final String uniqueRequestIdentifier = buildUniqueRequestIdentifier(id, parameters);
        final String token = generateToken();

        final String clientId = oAuth2Authentication.getOAuth2Request().getClientId();
        saveToken(uniqueRequestIdentifier, token, getValiditySeconds(clientId));

        OAuth2AuthenticationHolder.clear();
        return token;
    }

    /**
     * Description: 生成 CSRF-TOKEN
     *
     * @return void
     * @author LiKe
     * @date 2020-08-10 14:34:24
     */
    private String generateToken() {
        return StringUtils.replace(UUID.randomUUID().toString(), DASH, StringUtils.EMPTY);
    }

    /**
     * Description: 保存 CSRF-TOKEN<br>
     * Details: 与 OAuth2AccessToken 同一生命周期
     *
     * @param id              唯一请求标识
     * @param csrfToken       CSRF-TOKEN
     * @param validitySeconds CSRF-TOKEN 的有效时间 (秒)
     * @return void
     * @author LiKe
     * @date 2020-08-11 20:04:58
     */
    private void saveToken(String id, String csrfToken, int validitySeconds) {
        final RedisKey redisKey = RedisKey.builder().prefix(CACHE_PREFIX_CSRF_TOKEN).suffix(id).build();
        ApplicationContext.getBean(RedisService.class).setValue(redisKey, csrfToken, validitySeconds);
    }

    /**
     * Description: 构建唯一请求标识<br>
     * Details: 会尝试从请求参数中获取名为 {@link AbstractCsrfStrategy#FORM_FINGERPRINT} 的参数, 作为缓存 Key 的一级标识: <br>
     * - 对用户端, 用以区分同一用户在不同浏览器的访问, 分别颁发 CSRF-TOKEN;<br>
     * - 对客户端, 用以区分同一客户端的同时多次的请求.
     *
     * @param id         用户端 或者 客户端 ID (client_credentials)
     * @param parameters 用于构建唯一请求标识的参数 Map
     * @return java.lang.String 唯一标识请求的 ID
     * @author LiKe
     * @date 2020-08-11 14:52:52
     * @see AbstractCsrfStrategy
     */
    private String buildUniqueRequestIdentifier(String id, Map<String, ?> parameters) {
        final String fingerprint = MapUtils.getString(parameters, FORM_FINGERPRINT);
        if (StringUtils.isNotBlank(fingerprint)) {
            return StringUtils.join(id, RedisKey.SEPARATOR, fingerprint);
        }
        return id;
    }

    /**
     * Description: 获取 CSRF-TOKEN 的有效时间
     *
     * @param clientId 客户端 ID
     * @return int ACCESS-TOKEN 的有效时间 (秒), 同时作为 CSRF-TOKEN 的
     * @author LiKe
     * @date 2020-08-13 13:53:28
     */
    private int getValiditySeconds(String clientId) {
        final ClientDetails clientDetails = ApplicationContext.getBean(CustomClientDetailsService.class).loadClientByClientId(clientId);
        return clientDetails.getAccessTokenValiditySeconds();
    }
}
AuthorizationCodeCsrfStrategy
public class AuthorizationCodeCsrfStrategy extends AbstractCsrfStrategy {

    private static final String FORM_GRANT_TYPE = "grant_type";

    @Override
    public GrantType getGrantType() {
        return GrantType.AUTHORIZATION_CODE;
    }

    @Override
    public boolean supports(Map<String, ?> parameters) {
        return (getGrantType().getCode()).equals(MapUtils.getString(parameters, FORM_GRANT_TYPE)) && Objects.nonNull(MapUtils.getString(parameters, "code"));
    }

}
ImplicitCsrfStrategy
public class ImplicitCsrfStrategy extends AbstractCsrfStrategy {

    private static final String TOKEN = "token";

    private static final String KEY_AUTHORIZATION_REQUEST = "authorizationRequest";

    private static final String KEY_RESPONSE_TYPES = "responseTypes";

    @Override
    public GrantType getGrantType() {
        return GrantType.IMPLICIT;
    }

    @Override
    public boolean supports(Map<String, ?> parameters) {
        final JSONObject authorizationRequest = (JSONObject) MapUtils.getObject(parameters, KEY_AUTHORIZATION_REQUEST);
        final JSONArray responseTypes = authorizationRequest.getObject(KEY_RESPONSE_TYPES, JSONArray.class);
        return responseTypes.contains(TOKEN);
    }

}
ResourceOwnerPasswordCsrfStrategy
public class ResourceOwnerPasswordCsrfStrategy extends AbstractCsrfStrategy {

    private static final String FORM_GRANT_TYPE = "grant_type";

    @Override
    public GrantType getGrantType() {
        return GrantType.PASSWORD;
    }

    @Override
    public boolean supports(Map<String, ?> parameters) {
        return StringUtils.equals(MapUtils.getString(parameters, FORM_GRANT_TYPE), getGrantType().getCode());
    }

}
ClientCredentialsCsrfStrategy
public class ClientCredentialsCsrfStrategy extends AbstractCsrfStrategy {

    private static final String FORM_GRANT_TYPE = "grant_type";

    @Override
    public GrantType getGrantType() {
        return GrantType.CLIENT_CREDENTIALS;
    }

    @Override
    public boolean supports(Map<String, ?> parameters) {
        return StringUtils.equals(MapUtils.getString(parameters, FORM_GRANT_TYPE), getGrantType().getCode());
    }
}
RefreshTokenCsrfStrategy
public class RefreshTokenCsrfStrategy extends AbstractCsrfStrategy {

    private static final String FORM_GRANT_TYPE = "grant_type";

    @Override
    public GrantType getGrantType() {
        return GrantType.REFRESH_TOKEN;
    }

    @Override
    public boolean supports(Map<String, ?> parameters) {
        return StringUtils.equals(MapUtils.getString(parameters, FORM_GRANT_TYPE), getGrantType().getCode());
    }
}

定义 Aspect

切面的职责是, 拦截目标端点方法的调用, 应用合适的策略, 并包装返回值.

TokenEndpoint 的切面类: TokenEndpointCsrfStrategyAspect

我们定义一个针对 TokenEndpoint 的切面类, 用以实现特定 AuthorizationCode Grant, ResourceOwnerPassword Grant, ClientCredentials Grant 和 RefreshToken Grant 请求的 CSRF 机制.

/**
 * Description: {@link org.springframework.security.oauth2.provider.endpoint.TokenEndpoint} 切面.<br>
 * Details: 在颁发 ACCESS-TOKEN 后, 生成 CSRF-TOKEN, 将其缓存并置入响应头.
 *
 * @author LiKe
 * @version 1.0.0
 * @date 2020-08-11 16:58
 */
@Slf4j
@Aspect
@Component
public class TokenEndpointCsrfStrategyAspect extends AbstractCsrfStrategyAspect {

    /**
     * 所有支持 CSRF 策略的授权模式
     */
    private final List<? extends AbstractCsrfStrategy> delegates = new ArrayList<>(Arrays.asList(
            new AuthorizationCodeCsrfStrategy(),
            new ResourceOwnerPasswordCsrfStrategy(),
            new ClientCredentialsCsrfStrategy(),
            new RefreshTokenCsrfStrategy()
    ));

    @Pointcut(
            "execution(* org.springframework.security.oauth2.provider.endpoint.TokenEndpoint.*(..)) && @annotation(org.springframework.web.bind.annotation.RequestMapping)"
    )
    public void pointcut() {
    }

    @Around("pointcut()")
    @SuppressWarnings("unchecked")
    public Object doAround(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
        final Object any = proceedingJoinPoint.proceed();

        try {
            final ResponseEntity<OAuth2AccessToken> responseEntity = (ResponseEntity<OAuth2AccessToken>) any;

            // ~ 获取请求参数
            final Map<String, ?> parameters = super.extractParameters(proceedingJoinPoint);

            // ~ 获取匹配的策略
            final AbstractCsrfStrategy strategy = super.decide(parameters, delegates);

            if (Objects.isNull(strategy)) {
                log.warn("No eligible strategy.");
                return responseEntity;
            }

            log.debug("Eligible strategy {} for grant type: {}.", strategy.getClass().getSimpleName(), strategy.getGrantType().getCode());

            // ~ 执行策略并返回 CSRF-TOKEN
            final String csrfToken = strategy.execute(parameters);

            // ~ 重新封装 ResponseEntity, 置入 CSRF-TOKEN
            final OAuth2AccessToken oAuth2AccessToken = responseEntity.getBody();
            final HttpHeaders headers = new HttpHeaders();
            headers.set("Cache-Control", "no-store");
            headers.set("Pragma", "no-cache");
            headers.set(AbstractCsrfStrategy.CSRF_HEADER_NAME, csrfToken);
            return new ResponseEntity<>(oAuth2AccessToken, headers, HttpStatus.OK);
        } catch (ClassCastException exception) {
            log.warn(exception.getMessage());
            return any;
        }
    }

}
AuthorizationEndpoint 切面类: AuthorizationEndpointCsrfStrategyAspect
/**
 * Description: {@link org.springframework.security.oauth2.provider.endpoint.AuthorizationEndpoint} 切面.<br>
 * Details: 在颁发 ACCESS-TOKEN 后, 生成 CSRF-TOKEN, 将其缓存并置入响应头.<br>
 * 执行顺序:
 * <ol>
 *     <li>org.springframework.security.oauth2.provider.endpoint.AuthorizationEndpoint#authorize(..)</li>
 *     <li>org.springframework.security.oauth2.provider.endpoint.AuthorizationEndpoint#approveOrDeny(..)</li>
 * </ol>
 *
 * @author LiKe
 * @version 1.0.0
 * @date 2020-08-12 11:22
 */
@Slf4j
@Aspect
@Component
public class AuthorizationEndpointCsrfStrategyAspect extends AbstractCsrfStrategyAspect {

    /**
     * 所有支持 CSRF 策略的授权模式
     */
    private final List<? extends AbstractCsrfStrategy> delegates = Collections.singletonList(new ImplicitCsrfStrategy());

    @Pointcut(
            "execution(* org.springframework.security.oauth2.provider.endpoint.AuthorizationEndpoint.approveOrDeny(..)) && @annotation(org.springframework.web.bind.annotation.RequestMapping))"
    )
    public void pointcut() {
    }

    @Around("pointcut()")
    public Object doAround(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
        final Object any = proceedingJoinPoint.proceed();

        try {
            final RedirectView redirectView = (RedirectView) any;

            // ~ 获取请求参数
            final Map<String, ?> parameters = super.extractParameters(proceedingJoinPoint);

            // ~ 获取匹配的策略
            final AbstractCsrfStrategy strategy = super.decide(parameters, delegates);

            if (Objects.isNull(strategy)) {
                log.warn("No eligible strategy.");
                return redirectView;
            }

            log.debug("Eligible strategy {} for grant type: {}.", strategy.getClass().getSimpleName(), strategy.getGrantType().getCode());

            // ~ 执行策略并返回 CSRF-TOKEN
            final String csrfToken = strategy.execute(parameters);

            // ~ 置入 CSRF-TOKEN
            redirectView.setUrl(redirectView.getUrl() + "&csrf_token=" + csrfToken);
            return redirectView;
        } catch (ClassCastException exception) {
            exception.printStackTrace();
            log.warn(exception.getMessage());
            return any;
        }
    }

}

总结

就这样, 授权服务器对 CSRF-TOKEN 的生成策略就实现完了, 启动授权服务器, 对于每一种授权模式, 请求获取 ACCESS-TOKEN, 都可以在响应头中看到 X-CSRF-TOKEN: {CSRF-TOKEN}. 并且如果请求 URL 中携带了名为 fingerprint 的指纹码后, 可以看到 Redis 中缓存的 CSRF-TOKEN 的 KEY 的最后一级, 就是这个指纹码. 正如上面说的, 用于唯一标识请求.

授权服务器的 CSRF-TOKEN 颁发和缓存特性就扩展完毕, 接下来, 我们看看资源服务器的 CSRF-TOKEN 验证和刷新特性集成.

本部分只罗列了核心代码, 其余请直接查阅代码仓库.

资源服务器验证 CSRF-TOKEN

引言

目前, 授权服务器已经具备颁发 ACCESS-TOKEN 的同时授予 CSRF-TOKEN 的能力了. 那么对于资源服务器, 则需要验证 CSRF-TOKEN 和刷新 CSRF-TOKEN 的能力. 本章节我们将探讨如何实现这两点.

关于 CSRF-TOKEN, 可以参考之前的两篇文章: SpringSecurity (4) CSRF 与 CSRF-TOKEN 的处理SpringSecurity (5) SpringBoot + JWT + CSRF 动态权限的实现.

实现

对资源服务器的访问必须同时具备 ACCESS-TOKEN 和 CSRF-TOKEN. 与本系列前面有关于 CSRF 的文章里的介绍到的一样, 我们直接实现 CsrfTokenRepository:

/**
 * Description: 自定义的 {@link CsrfTokenRepository}<br>
 * Details: 基于 Redis 的实现: 依托 Redis 读取, 刷新 CSRF-TOKEN.<br>
 * 执行顺序: <br>
 * <ol>
 *     <li>{@link CsrfTokenRedisRepository#loadToken(HttpServletRequest)}</li>
 *     <li>{@link CsrfTokenRedisRepository#generateToken(HttpServletRequest)}</li>
 *     <li>{@link CsrfTokenRedisRepository#saveToken(CsrfToken, HttpServletRequest, HttpServletResponse)}</li>
 * </ol>
 *
 * @author LiKe
 * @version 1.0.0
 * @date 2020-08-15 13:36
 */
@Slf4j
@Component
public class CsrfTokenRedisRepository implements CsrfTokenRepository {

    private static final String DASH = "-";

    private static final String CSRF_PARAMETER_NAME = "_csrf";

    /**
     * CSRF-TOKEN 存在于响应头中的名称
     */
    private static final String CSRF_HEADER_NAME = "X-CSRF-TOKEN";

    /**
     * Authorization 在请求头中的名称
     */
    private static final String AUTHORIZATION_HEADER_NAME = "Authorization";

    /**
     * ACCESS-TOKEN 类型
     */
    private static final String BEARER_TOKEN_PREFIX = "Bearer ";

    /**
     * 请求参数中的指纹码的名称
     */
    private static final String FORM_FINGERPRINT = "fingerprint";

    /**
     * 由授权服务器颁发的, 缓存的 csrf-token 的 Key 的前缀
     */
    private static final String CACHE_PREFIX_CSRF_TOKEN = /*"authorization-server." + */Strings.toLowerCase(CSRF_HEADER_NAME);

    // -----------------------------------------------------------------------------------------------------------------

    private RedisService redisService;

    /**
     * {@link CustomResourceServerTokenServices}
     */
    private ResourceServerTokenServices resourceServerTokenServices;

    // =================================================================================================================

    @Override
    public CsrfToken generateToken(HttpServletRequest request) {
        log.debug("Generate token.");
        final String token = StringUtils.replace(UUID.randomUUID().toString(), DASH, StringUtils.EMPTY);
        return new DefaultCsrfToken(CSRF_HEADER_NAME, CSRF_PARAMETER_NAME, token);
    }

    /**
     * Description: 将 CSRF-TOKEN 保存至 Redis
     *
     * @see CsrfTokenRepository#saveToken(CsrfToken, HttpServletRequest, HttpServletResponse)
     */
    @Override
    public void saveToken(CsrfToken token, HttpServletRequest request, HttpServletResponse response) {
        final String principalName = SecurityContextHolder.getContext().getAuthentication().getName();
        log.debug("Save token for {}", principalName);

        // 由于过期时间交由 Redis 管理, 所以 token 为 null 时, 不进行任何操作直接返回.
        if (Objects.isNull(token)) {
            log.debug("csrf filter: do nothing while token is null. The token's lifecycle will be handled by Redis.");
            return;
        }

        final String tokenValue = token.getToken();
        // 刷新 CSRF-TOKEN
        redisService.setValue(
                RedisKey.builder().prefix(CACHE_PREFIX_CSRF_TOKEN).suffix(buildUniqueRequestIdentifier(principalName, request)).build(),
                tokenValue
        );
        // 返回 CSRF-TOKEN
        response.setHeader(CSRF_HEADER_NAME, tokenValue);
    }

    /**
     * Description: 从 Redis 中读取期望的 CSRF-TOKEN
     *
     * @see CsrfTokenRepository#loadToken(HttpServletRequest)
     */
    @Override
    public CsrfToken loadToken(HttpServletRequest request) {
        log.debug("Load token from request.");

        final String accessToken = StringUtils.substring(request.getHeader(AUTHORIZATION_HEADER_NAME), BEARER_TOKEN_PREFIX.length());
        final OAuth2Authentication oAuth2Authentication = resourceServerTokenServices.loadAuthentication(accessToken);
        final String principalName = oAuth2Authentication.getName();

        // 获取期望 CSRF-TOKEN
        final String cachedToken = redisService.getValue(
                RedisKey.builder().prefix(CACHE_PREFIX_CSRF_TOKEN).suffix(buildUniqueRequestIdentifier(principalName, request)).build(),
                String.class
        );

        if (StringUtils.isNotBlank(cachedToken)) {
            return new DefaultCsrfToken(CSRF_HEADER_NAME, CSRF_PARAMETER_NAME, cachedToken);
        }

        throw new NoCachedCsrfTokenException("No cached csrf-token possibly due to token expired.");
    }

    /**
     * Description: 构建唯一请求标识
     *
     * @param principalName 用户 ID / 客户端 ID
     * @param request       {@link HttpServletRequest}
     * @return java.lang.String
     * @author LiKe
     * @date 2020-08-15 16:03:05
     */
    private String buildUniqueRequestIdentifier(String principalName, HttpServletRequest request) {
        // ~ 获取指纹码
        final String fingerprint = request.getParameter(FORM_FINGERPRINT);
        if (StringUtils.isNotBlank(fingerprint)) {
            return StringUtils.join(principalName, RedisKey.SEPARATOR, fingerprint);
        }
        return principalName;
    }

    // ~ Autowired
    // -----------------------------------------------------------------------------------------------------------------

    @Autowired
    public void setRedisService(RedisService redisService) {
        this.redisService = redisService;
    }

    @Autowired
    public void setResourceServerTokenServices(@Qualifier("customResourceServerTokenServices") ResourceServerTokenServices resourceServerTokenServices) {
        this.resourceServerTokenServices = resourceServerTokenServices;
    }
}

值得注意的是, 我们需要在 CsrfFilter 执行时获取到 OAuth2Authentication, 从而拿到 用户ID / 客户ID 作为缓存 KEY 的一部分. 所以这里我们用到了 resourceServerTokenServices 先行解析令牌.


同时在 ResourceServerConfiguration 中配置上:

/**
 * 资源服务器配置
 *
 * @author LiKe
 * @version 1.0.0
 * @date 2020-06-13 20:55
 */
@Slf4j
@Configuration
@EnableResourceServer
public class ResourceServerConfiguration extends ResourceServerConfigurerAdapter {
    // ...
 
    @Override
    public void configure(HttpSecurity http) throws Exception {
        // @formatter:off

        // ~ 启用 csrf 保护
        http.csrf().requireCsrfProtectionMatcher(new AlwaysMatchRequestMatcher()).csrfTokenRepository(csrfTokenRepository);

        http.authorizeRequests().anyRequest().authenticated()
                // ~ 动态权限设置
                .withObjectPostProcessor(new FilterSecurityInterceptorPostProcessor(accessDecisionManager, filterInvocationSecurityMetadataSource));

        // @formatter:on
    }    
    
    // ...
}

总结

关于 CSRF 的机制和调用链请看本系列之前的文章., 本部分只罗列了核心代码, 其余请直接查阅代码仓库.

总结

就这样, 授权服务器和资源服务器对于 CSRF 的支持都实现了. 接下来我们启动授权服务器和资源服务器, 首次申请令牌可以看到授权服务器的响应头也附带了 CSRF-TOKEN.
在这里插入图片描述
并且随后携带 ACCESS-TOKEN 和 CSRF-TOKEN 访问资源服务器的时候也能看到, CSRF-TOKEN 正常刷新并且随每次请求而变化.
在这里插入图片描述
在这里插入图片描述

  • 1
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值