【Spring Security】 Oauth2 开放平台 资源服务器

Metadata

title: 【Spring Security】 Oauth2 开放平台 资源服务器
date: 2023-02-02 22:18
tags:
  - 行动阶段/完成
  - 主题场景/组件
  - 笔记空间/KnowladgeSpace/ProgramSpace/ModuleSpace
  - 细化主题/Module/SpringSecurity
categories:
  - SpringSecurity
keywords:
  - SpringSecurity
description: 【Spring Security】 Oauth2 开放平台 资源服务器

需求

搭建了授权服务器并能获取到令牌后,接下来就是如何使用令牌访问资源了。

资源服务器搭建

  1. 创建资源服务器模块,之前我们已经创建了并引入了相关依赖。

  1. 创建一个资源模拟接口,直接访问,会发现直接跳转到了登录页
@RequestMapping
@RestController
public class ResourceController {

    @GetMapping("/resource")
    @PreAuthorize("hasRole('USER')")
    public String resource(){
        return "访问到了resource 资源 ";
    }
}
  1. 授权服务器放行 /oauth/check_token 端点,此端点默认是受保护的。现在我们的 Oauth2 认证信息是在内存中,当携带令牌访问 Oauth 时,需要去授权服务器校验令牌及获取当前认证信息,所以需要放开此端点。
// 将 check_token 暴露出去,否则资源服务器访问时报错
        web.ignoring().antMatchers("/oauth/check_token");
  1. 配置 ResourceServerConfigurerAdapter,这里基本没做配置,只是开启了 @EnableResourceServer 。
@Configuration
@EnableResourceServer //标识为资源服务
@EnableWebSecurity(debug = true)
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true)
public class MyResourceServerConfig extends ResourceServerConfigurerAdapter {
    public MyResourceServerConfig() {
        super();
    }

    @Override
    public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
        super.configure(resources);
    }

    @Override
    public void configure(HttpSecurity http) throws Exception {
        super.configure(http);
    }

    // 密码解析器
    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}
  1. yml 添加授权服务器相关信息
security:
  oauth2:
    client:
      client-id: client
      client-secret: secret
      access-token-uri: http://localhost:20000/oauth/token
      user-authorization-uri: http://localhost:20000/oauth/authorize
    resource:
      token-info-uri: http://localhost:20000/oauth/check_token
  1. 配置完成,启动项目。

测试

  1. 在授权服务中,使用密码模式获取令牌。

  1. 直接访问,会返回一个未认证错误,而不是像 security 那样跳转到登录页。

  1. 携带令牌访问,令牌需要添加在请求头,key 为 Authorization,值为 Bearer xxtoken 格式,再次访问,发现获取到了资源。

也可以接口携带令牌直接访问:

http://localhost:9001/resource?access_token=4c0544e6-c558-4e2b-8f56-28f7c741bcb1

SecurityContextHolder 也能获取到认证信息,此时的 Authentication 为 OAuth2Authentication。

OAuth2Authentication [Principal=user, Credentials=[PROTECTED], Authenticated=true, Details=remoteAddress=0:0:0:0:0:0:0:1, tokenType=BearertokenValue=<TOKEN>, Granted Authorities=[ROLE_USER]]

源码分析

资源服务器只需要一个 token,就完成了认证流程,具体是怎么实现的呢,接下来我们追踪下源码。

1. 进入 OAuth2AuthenticationProcessingFilter

OAuth2AuthenticationProcessingFilter 是 OAuth2 受保护资源的身份验证过滤器。从传入的请求中提取 OAuth2 令牌,并使用它来填充带有 {@link OAuth2Authentication} 的 Spring Security context 。

doFilter 方法源码:

public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException,
            ServletException {

        final boolean debug = logger.isDebugEnabled();
        final HttpServletRequest request = (HttpServletRequest) req;
        final HttpServletResponse response = (HttpServletResponse) res;

        try {
            // 1. 从标头中提取OAuth令牌,
            Authentication authentication = tokenExtractor.extract(request);
            
            if (authentication == null) {
                if (stateless && isAuthenticated()) {
                    if (debug) {
                        logger.debug("Clearing security context.");
                    }
                    SecurityContextHolder.clearContext();
                }
                if (debug) {
                    logger.debug("No token in request, will continue chain.");
                }
            }
            else {
                // 2. request设置属性 OAuth2AuthenticationDetails.ACCESS_TOKEN_VALUE:4c0544e6-c558-4e2b-8f56-28f7c741bcb1
                request.setAttribute(OAuth2AuthenticationDetails.ACCESS_TOKEN_VALUE, authentication.getPrincipal());
                // 3. 转为AbstractAuthenticationToken并设置Details
                if (authentication instanceof AbstractAuthenticationToken) {
                    AbstractAuthenticationToken needsDetails = (AbstractAuthenticationToken) authentication;
                    needsDetails.setDetails(authenticationDetailsSource.buildDetails(request));
                }
                // 4. 认证管理器进行认证
                Authentication authResult = authenticationManager.authenticate(authentication);

                if (debug) {
                    logger.debug("Authentication success: " + authResult);
                }
                // 5. 发布认证成功
                eventPublisher.publishAuthenticationSuccess(authResult);
                // 6. 设置SecurityContext
                SecurityContextHolder.getContext().setAuthentication(authResult);

            }
        }
        // 7. 异常处理
        catch (OAuth2Exception failed) {
            SecurityContextHolder.clearContext();

            if (debug) {
                logger.debug("Authentication request failed: " + failed);
            }
            eventPublisher.publishAuthenticationFailure(new BadCredentialsException(failed.getMessage(), failed),
                    new PreAuthenticatedAuthenticationToken("access-token", "N/A"));

            authenticationEntryPoint.commence(request, response,
                    new InsufficientAuthenticationException(failed.getMessage(), failed));

            return;
        }

        chain.doFilter(request, response);
    }

2. 构造预处理的 Authentication

过滤器的第一步会调用 BearerTokenExtractor 的 extract 方法,从标头中提取 OAuth 令牌。

首先调用 BearerTokenExtractor.extractHeaderToken 方法获取令牌。

protected String extractHeaderToken(HttpServletRequest request) {
        // 1.获取Authorization消息头内容 Bearer 4c0544e6-c558-4e2b-8f56-28f7c741bcb1
        Enumeration<String> headers = request.getHeaders("Authorization");
        // 2. 循环消息头内容
        while (headers.hasMoreElements()) { // typically there is only one (most servers enforce that)
            String value = headers.nextElement();
            // 3. 如果已Bearer 开头
            if ((value.toLowerCase().startsWith(OAuth2AccessToken.BEARER_TYPE.toLowerCase()))) {
            // 4. 截取令牌 =》 4c0544e6-c558-4e2b-8f56-28f7c741bcb1
                String authHeaderValue = value.substring(OAuth2AccessToken.BEARER_TYPE.length()).trim();
                // Add this here for the auth details later. Would be better to change the signature of this method.
                // 5. request对象添加=》 OAuth2AuthenticationDetails.ACCESS_TOKEN_TYPE:Bearer 
                request.setAttribute(OAuth2AuthenticationDetails.ACCESS_TOKEN_TYPE,
                        value.substring(0, OAuth2AccessToken.BEARER_TYPE.length()).trim());
                int commaIndex = authHeaderValue.indexOf(',');
                if (commaIndex > 0) {
                    // 6.如果存在逗号,则重新截取
                    authHeaderValue = authHeaderValue.substring(0, commaIndex);
                }
                // 7. 返回token =》4c0544e6-c558-4e2b-8f56-28f7c741bcb1
                return authHeaderValue;
            }
        }

        return null;
    }

然后进入 BearerTokenExtractor.extractToken 方法:

protected String extractToken(HttpServletRequest request) {
        // first check the header...
        // 1. 检查消息头中的令牌
        String token = extractHeaderToken(request);
        // 2. 消息头没有检查请求参数中是否有access_token 
        // bearer type allows a request parameter as well
        if (token == null) {
            logger.debug("Token not found in headers. Trying request parameters.");
            token = request.getParameter(OAuth2AccessToken.ACCESS_TOKEN);
            if (token == null) {
                logger.debug("Token not found in request parameters.  Not an OAuth2 request.");
            }
            else {
                request.setAttribute(OAuth2AuthenticationDetails.ACCESS_TOKEN_TYPE, OAuth2AccessToken.BEARER_TYPE);
            }
        }
        // 3.返回token
        return token;
    }

最后调用 BearerTokenExtractor.extract 返回 PreAuthenticatedAuthenticationToken 对象。这是一个预认证对象,他的 principal 属性包含了当前令牌

public Authentication extract(HttpServletRequest request) {
        String tokenValue = extractToken(request);
        if (tokenValue != null) {
            // 创建PreAuthenticatedAuthenticationToken 
            PreAuthenticatedAuthenticationToken authentication = new PreAuthenticatedAuthenticationToken(tokenValue, "");
            return authentication;
        }
        return null;
    }

3. 调用认证管理器进行认证

这里的认证管理器为 OAuth2AuthenticationManager 的 authenticate 方法。

public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        // 1. Authentication预认证对象为null,抛出Invalid token (token not found)异常。
        if (authentication == null) {
            throw new InvalidTokenException("Invalid token (token not found)");
        }
        // 2. 获取token  4c0544e6-c558-4e2b-8f56-28f7c741bcb1
        String token = (String) authentication.getPrincipal();
        // 3. 调用RemoteTokenServices,远程获取认证信息并封装为OAuth2Authentication对象
        OAuth2Authentication auth = tokenServices.loadAuthentication(token);
        if (auth == null) {
            throw new InvalidTokenException("Invalid token: " + token);
        }

        Collection<String> resourceIds = auth.getOAuth2Request().getResourceIds();
        if (resourceId != null && resourceIds != null && !resourceIds.isEmpty() && !resourceIds.contains(resourceId)) {
            throw new OAuth2AccessDeniedException("Invalid token does not contain resource id (" + resourceId + ")");
        }
        // 4. 检查ClientDetails
        checkClientDetails(auth);
        // 5. 填充认证信息并返回Authention
        if (authentication.getDetails() instanceof OAuth2AuthenticationDetails) {
            OAuth2AuthenticationDetails details = (OAuth2AuthenticationDetails) authentication.getDetails();
            // Guard against a cached copy of the same details
            if (!details.equals(auth.getDetails())) {
                // Preserve the authentication details from the one loaded by token services
                details.setDecodedDetails(auth.getDetails());
            }
        }
        auth.setDetails(authentication.getDetails());
        auth.setAuthenticated(true);
        return auth;

    }

4. 调用 ResourceServerTokenServices

调用 ResourceServerTokenServices 进行根据令牌获取认证信息,这里的实际对象 RemoteTokenServices,会远程调用授权服务器的 / check_token 端点以获取访问令牌的内容。如果端点返回 400 响应,则表示令牌无效。

首先进入 RemoteTokenServices.loadAuthentication 方法:

public OAuth2Authentication loadAuthentication(String accessToken) throws AuthenticationException, InvalidTokenException {
        // 1. 创建请求,添加accessToken、配置文件中的clientId、clientSecret
        MultiValueMap<String, String> formData = new LinkedMultiValueMap<String, String>();
        formData.add(tokenName, accessToken);
        HttpHeaders headers = new HttpHeaders();
        headers.set("Authorization", getAuthorizationHeader(clientId, clientSecret));
        // 2. Post请求配置的http://localhost:20000/oauth/check_token
        Map<String, Object> map = postForMap(checkTokenEndpointUrl, formData, headers);
        // 3. 返回error则抛出InvalidTokenException异常
        if (map.containsKey("error")) {
            if (logger.isDebugEnabled()) {
                logger.debug("check_token returned error: " + map.get("error"));
            }
            throw new InvalidTokenException(accessToken);
        }

        // gh-838
        if (!Boolean.TRUE.equals(map.get("active"))) {
            logger.debug("check_token returned active attribute: " + map.get("active"));
            throw new InvalidTokenException(accessToken);
        }
        // 4. 提取认证
        return tokenConverter.extractAuthentication(map);
    }

5. 提取认证

访问令牌获取到了认证信息后,会进行提取认证,返回 OAuth2Authentication 对象。

public OAuth2Authentication extractAuthentication(Map<String, ?> map) {
        Map<String, String> parameters = new HashMap<String, String>();
        Set<String> scope = extractScope(map);
        Authentication user = userTokenConverter.extractAuthentication(map);
        String clientId = (String) map.get(clientIdAttribute);
        parameters.put(clientIdAttribute, clientId);
        if (includeGrantType && map.containsKey(GRANT_TYPE)) {
            parameters.put(GRANT_TYPE, (String) map.get(GRANT_TYPE));
        }
        Set<String> resourceIds = new LinkedHashSet<String>(map.containsKey(AUD) ? getAudience(map)
                : Collections.<String>emptySet());
        
        Collection<? extends GrantedAuthority> authorities = null;
        if (user==null && map.containsKey(AUTHORITIES)) {
            @SuppressWarnings("unchecked")
            String[] roles = ((Collection<String>)map.get(AUTHORITIES)).toArray(new String[0]);
            authorities = AuthorityUtils.createAuthorityList(roles);
        }
        OAuth2Request request = new OAuth2Request(parameters, clientId, authorities, true, scope, resourceIds, null, null,
                null);
        return new OAuth2Authentication(request, user);
    }

6. 检查 ClientDetails

获取到认证信息后,重新回到 OAuth2AuthenticationManager,之后进入 checkClientDetails 方法。

private void checkClientDetails(OAuth2Authentication auth) {
        // 1. Oauth客户端如果为null ,不检查,配置了ClientDetailsService的实现类,会进行检查
        if (clientDetailsService != null) {
            ClientDetails client;
            try {
                // 2. 获取客户端
                client = clientDetailsService.loadClientByClientId(auth.getOAuth2Request().getClientId());
            }
            catch (ClientRegistrationException e) {
                throw new OAuth2AccessDeniedException("Invalid token contains invalid client id");
            }
            // 3. 获取配置的客户端授权范围
            Set<String> allowed = client.getScope();
            for (String scope : auth.getOAuth2Request().getScope()) {
                // 4. 如果没有当前授权,则抛出异常OAuth2AccessDeniedException
                if (!allowed.contains(scope)) {
                    throw new OAuth2AccessDeniedException(
                            "Invalid token contains disallowed scope (" + scope + ") for this client");
                }
            }
        }
    }

7. 返回认证结果处理

认证管理器认证成功后,返回认证结果,发布认证成功,并设置 SecurityContext。

8. 异常处理 OAuth2Exception

当认证发生异常,会抛出 OAuth2Exception,在过滤器中会进行拦击处理,最终调用 OAuth2AuthenticationEntryPoint 直接返回错误信息。

catch (OAuth2Exception failed) {
            // 清理SecurityContext
            SecurityContextHolder.clearContext();
            
            if (debug) {
                logger.debug("Authentication request failed: " + failed);
            }
            // 发布认证失败
            eventPublisher.publishAuthenticationFailure(new BadCredentialsException(failed.getMessage(), failed),
                    new PreAuthenticatedAuthenticationToken("access-token", "N/A"));
            // 调用OAuth2AuthenticationEntryPoint返回ResponseEntity
            authenticationEntryPoint.commence(request, response,
                    new InsufficientAuthenticationException(failed.getMessage(), failed));

            return;
        }