美文网首页
自定义Spring security oauth2 响应/异常信

自定义Spring security oauth2 响应/异常信

作者: 万物始 | 来源:发表于2020-08-13 15:22 被阅读0次

    最近使用spring security oauth2 做开放平台,想要返回统一的返回值格式,做的过程中发现相当麻烦,好在效果总算达到了,在这里总结一下,希望能帮助到遇到相同问题的同学。实现方式并不完美,如果你有更好的方式或发现文内有问题,希望不吝赐教。

    Oauth2 协议有4种认证方式,项目里用到了客户端模式和密码模式,都是依托于spring security oauth2。开发过程中发现框架原生响应的格式一般如下:

    {
        "error": "invalid_grant",
        "error_description": "Bad credentials"
    }
    

    为了调用方的便利、保证平台的统一性,希望统一响应的格式,最起码有code和友好的提示信息message,比如:

    {
        "code": 401101,
        "message": "客户端认证失败"
    }
    

    开发过程中需要对如下几种请求统一响应值格式

    1. 客户端/密码模式获取token失败——参数中未携带client_id、参数中client_id或client_secret不正确
    2. 密码模式获取token失败——参数中userName或password不正确
    3. 资源接口请求失败——未带token、token过期、token有效但资源权限不足
    4. 正常的oauth/token的响应体结构——重写原token格式

    客户端/密码模式获取token失败——参数中未携带client_id、参数中client_id或client_secret不正确

    密码模式获取token时需要先验证客户端、再验证用户,因此可以合这两种情况,由于密码模式获取token过程中也需要验证client,且验证逻辑与客户端模式相同,都会使用下面的方式。

    这里有一个前提是client验证必须是basic auth方式,即在请求头中设置Authorization参数,将client_id和client_secret以:间隔进行拼接,然后将拼接后的字符串使用 BASE64 编码与Basic拼接,可生成 Authorization 参数的值。还有一个传参方式是form形式,form形式无法定义返回值格式,框架里写死了。

    自定义一个OncePerRequestFilter子类,在filter中重写认证逻辑,再将其注入到AuthorizationServerSecurityConfigurer

    /**
     * basic auth 方式client认证过滤器
     * 置于{@link org.springframework.security.web.authentication.www.BasicAuthenticationFilter}之前,
     * 以实现客户端信息不全、认证失败时返回自定义响应信息
     *
     * @author zhangjw
     * @version 1.0
     */
    @Component
    @Slf4j
    public class CustomBasicAuthenticationFilter extends OncePerRequestFilter {
        @Resource
        private ClientDetailsService clientDetailsService;
        @Override
        protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
            if (!request.getRequestURI().contains("/oauth/token")) {
                filterChain.doFilter(request, response);
                return;
            }
    
            String[] clientDetails = this.isHasClientDetails(request);
            // 客户端信息缺失
            if (clientDetails == null) {
                response.setCharacterEncoding("UTF-8");
                response.setContentType("application/json; charset=utf-8");
                OpenApiResp resp = OpenApiResp.build(OpenApiRespEnum.OAUTH_GET_TOKEN_FAIL_CLIENT_MISSING);
                response.getWriter().write(JsonUtil.beanToJson(resp));
                return;
            }
    
            try {
                this.handle(request, response, clientDetails, filterChain);
            } catch (CustomOauthException coe) {
                // 客户端认证失败
                response.setCharacterEncoding("UTF-8");
                response.setContentType("application/json; charset=utf-8");
                OpenApiResp resp = OpenApiResp.build(OpenApiRespEnum.OAUTH_GET_TOKEN_FAIL_CLIENT);
                response.getWriter().write(JsonUtil.beanToJson(resp));
            }
    
        }
    
        private void handle(HttpServletRequest request, HttpServletResponse response, String[] clientDetails, FilterChain filterChain) throws IOException, ServletException {
            Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
            if (authentication != null && authentication.isAuthenticated()) {
                filterChain.doFilter(request, response);
                return;
            }
    
            ClientDetails details = null;
            try {
                details = this.getClientDetailsService().loadClientByClientId(clientDetails[0]);
            } catch (ClientRegistrationException e) {
                log.info("client认证失败,{},{}", e.getMessage(), clientDetails[0]);
                throw new CustomOauthException("client_id 或client_secret 不正确");
            }
    
            if (details == null) {
                log.info("client认证失败,{}", clientDetails[0]);
                throw new CustomOauthException("client_id或client_secret不正确");
            }
    
            UsernamePasswordAuthenticationToken token =
                    new UsernamePasswordAuthenticationToken(details.getClientId(), details.getClientSecret(), details.getAuthorities());
            SecurityContextHolder.getContext().setAuthentication(token);
            filterChain.doFilter(request, response);
        }
    
        /**
         * 判断请求头中是否包含client信息,不包含返回null  Base64编码
         */
        private String[] isHasClientDetails(HttpServletRequest request) {
            String[] params = null;
            String header = request.getHeader(HttpHeaders.AUTHORIZATION);
            if (header != null) {
                String basic = header.substring(0, 5);
                if (basic.toLowerCase().contains("basic")) {
                    String tmp = header.substring(6);
                    String defaultClientDetails = new String(Base64.getDecoder().decode(tmp));
                    String[] clientArrays = defaultClientDetails.split(":");
    
                    if (clientArrays.length != 2) {
                        return params;
                    } else {
                        params = clientArrays;
                    }
                }
            }
            String id = request.getParameter("client_id");
            String secret = request.getParameter("client_secret");
            if (header == null && id != null) {
                params = new String[]{id, secret};
            }
            return params;
        }
    
        public ClientDetailsService getClientDetailsService() {
            return clientDetailsService;
        }
    
        public void setClientDetailsService(ClientDetailsService clientDetailsService) {
            this.clientDetailsService = clientDetailsService;
        }
    }
    
    

    配置:在授权服务器 AuthorizationServerConfig 配置如下

    @Configuration
    @EnableAuthorizationServer
    public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
            @Resource
        private CustomBasicAuthenticationFilter customBasicAuthenticationFilter;
        @Override
        public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
            security.addTokenEndpointAuthenticationFilter(customBasicAuthenticationFilter);
        }
    }
    

    密码模式获取token失败——参数中userName和password不正确

    重写AuthenticationProvider,继承AbstractUserDetailsAuthenticationProvider抽象类,重点在重写retrieveUser这个方法,这个方法内调用自己的账户服务来认证用户信息,如果用户名密码不匹配时,抛出 InvalidGrantException异常,可以附带message,该异常是AuthenticationException的子类。

    @Service
    @Slf4j
    public class CustomAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {  
        /**
         * 验证用户
         */
        @Override
        protected UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
          
          // 伪代码,如果认证失败抛出 InvalidGrantException 然后下面的定义异常解析器oauth2ResponseExceptionTranslator捕获处理
          throw new InvalidGrantException("用户名或密码验证失败");
          
        }
    }
    
    
    /**
     * 当执行 CustomAuthenticationProvider#retrieveUser抛出异常时,会被这个异常解析器处理,
     * 可以在这里构造返回{@link ResponseEntity},加入code、message等字段,
     *
     * @author zhangjw
     * @version 1.0
     */
    @Slf4j
    @Configuration
    public class Oauth2ExceptionTranslatorConfiguration {
        @Bean
        public WebResponseExceptionTranslator<OAuth2Exception> oauth2ResponseExceptionTranslator() {
            return new DefaultWebResponseExceptionTranslator() {
                @Override
                public ResponseEntity<OAuth2Exception> translate(Exception e) throws Exception {
                    OAuth2Exception body = OAuth2Exception.create(OAuth2Exception.ACCESS_DENIED, e.getMessage());
                    // 捕获后在返回值添加code、message
                    body.addAdditionalInformation("code", String.valueOf(OpenApiRespEnum.OAUTH_GET_TOKEN_FAIL_USER.getCode()));
                    body.addAdditionalInformation("message", OpenApiRespEnum.OAUTH_GET_TOKEN_FAIL_USER.getDesc());
                    HttpHeaders headers = new HttpHeaders();
                    return new ResponseEntity<>(body, headers, HttpStatus.UNAUTHORIZED);
                }
            };
        }
    }
    

    配置:在授权服务器 AuthorizationServerConfig 配置如下

    @Configuration
    @EnableAuthorizationServer
    public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
            @Autowired
        private WebResponseExceptionTranslator<OAuth2Exception> oauth2ResponseExceptionTranslator;
    
        @Override
        public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
            endpoints
                    .authenticationManager(authenticationManager)
                    .exceptionTranslator(oauth2ResponseExceptionTranslator) // 设置自定义的异常解析器
                    .tokenServices(tokenServices());
        }
    }
    

    资源接口请求失败——未带token、token过期、资源权限不足

    实现一个AuthenticationEntryPoint,直接用component注解,加入spring容器即可生效。在commence方法里通过response.getWriter().write 自定义响应值。这里可以通过异常cause区分是未带token还是token过期

    /**
     * resource服务器请求,验证token失败(未带token/token失效)时返回值重写
     *
     * @author zhangjw
     * @version 1.0
     */
    @Component
    @Slf4j
    public class CustomOAuthEntryPoint implements AuthenticationEntryPoint {
    
        @Autowired
        private ObjectMapper mapper;
    
        @Override
        public void commence(HttpServletRequest request, HttpServletResponse response,
                             AuthenticationException authException) throws ServletException {
            Throwable cause = authException.getCause();
            response.setStatus(HttpStatus.OK.value());
            response.setHeader("Content-Type", "application/json;charset=UTF-8");
            try {
                if (cause instanceof OAuth2AccessDeniedException) {
                    // 资源权限不足
                    OpenApiResp resp = OpenApiResp.build(OpenApiRespEnum.OAUTH_ACCESS_RESOURCE_INSUFFICIENT_AUTHORITY);
                    response.getWriter().write(mapper.writeValueAsString(resp));
                } else if (cause == null || cause instanceof InvalidTokenException) {
                    // 未带token或token无效
                    // cause == null 一般可能是未带token
                    OpenApiResp resp = OpenApiResp.build(OpenApiRespEnum.OAUTH_ACCESS_RESOURCE_TOKEN_INVALID);
                    response.getWriter().write(mapper.writeValueAsString(resp));
                }
            } catch (IOException e) {
                log.error("其他异常error", e);
                throw new RuntimeException(e);
            }
        }
    

    配置:在资源服务器中配置如下

    @Configuration
    @EnableResourceServer
    public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
        @Autowired
        private DefaultTokenServices tokenServices;
        @Autowired
        private AuthenticationEntryPoint oauthEntryPoint;
    
        @Override
        public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
            // 自定义oauthEntryPoint
            resources.authenticationEntryPoint(oauthEntryPoint);
            resources
                    .tokenServices(tokenServices)
                    .resourceId("xxx");
        }
    }
    

    正常的oauth/token的响应体结构

    默认的token如下,这里希望在外层加一层包裹,加上code、message字段,便于适用方判断token是否获取成功

    {
      "access_token": "b27c596d-db80-4393-ad4e-dddcad024b6b",
      "token_type": "bearer",
      "refresh_token": "21cee608-5775-48b3-8427-d7e894abd947",
      "expires_in": 50662,
      "scope": "read write"
    }
    

    适用切面来实现,切点是org.springframework.security.oauth2.provider.endpoint.TokenEndpoint.postAccessToken(..),目的是对oahth/token 的返回值重写

    @Component
    @Aspect
    @Slf4j
    public class AuthTokenAspect {
        @Around("execution(* org.springframework.security.oauth2.provider.endpoint.TokenEndpoint.postAccessToken(..))")
        public Object handleControllerMethod(ProceedingJoinPoint pjp) throws Throwable {
            WebResp<Object> response = WebResp.ok();
            Object proceed = null;
            try {
                proceed = pjp.proceed();
            } catch (Throwable throwable) {
                throw throwable;
            }
            if (proceed != null) {
                ResponseEntity<OAuth2AccessToken> responseEntity = (ResponseEntity<OAuth2AccessToken>)proceed;
                OAuth2AccessToken body = responseEntity.getBody();
                if (responseEntity.getStatusCode().is2xxSuccessful()) {
                    response.setCode(0);
                    response.setMessage(WebResp.SUCCESS_MSG);
                    response.setData(body);
                } else {
                    log.error("error:{}", responseEntity.getStatusCode().toString());
                    response.setCode(OpenApiRespEnum.OAUTH_GET_TOKEN_FAIL_USER.getCode());
                    response.setMessage(OpenApiRespEnum.OAUTH_GET_TOKEN_FAIL_USER.getDesc());
                }
            }
            return ResponseEntity.status(200).body(response);
        }
    }
    

    加好后获取token结果如下

    {
        "code": 0,
        "message": "操作成功",
        "data": {
            "access_token": "b27c596d-db80-4393-ad4e-dddcad024b6b",
            "token_type": "bearer",
            "refresh_token": "21cee608-5775-48b3-8427-d7e894abd947",
            "expires_in": 50662,
            "scope": "read write"
        }
    }
    

    更好的方法

    因为开始做项目的时候,授权服务器和资源服务器放在了一个服务中,第三方请求到该服务后认证、授权。一种更好的方法是,把授权服务器、资源服务器单独部署,请求到API Gateway里,再路由到授权/资源服务器,在API Gateway 里根据授权/资源服务器返回结果(结果的类型是有限的,可以实现定义个枚举)重写,构造成统一的格式返回,换言之,不直接使用spirng security oauth框架的返回值,把整个服务包裹一层。

    相关文章

      网友评论

          本文标题:自定义Spring security oauth2 响应/异常信

          本文链接:https://www.haomeiwen.com/subject/hcnwrktx.html