美文网首页
自定义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