赞
踩
最近使用了 spring security oauth 来搭建认证服务,计划使用 oauth 的密码模式、以前端页面为客户端。
前后端交互要求统一相应结构,spring security oauth 默认的错误相应的 json 格式的,请求格式类似于下面这样:
{
"error": "invalid_client",
"error_description": "Bad client credentials"
}
然而, spring security oauth 授权服务对自定义异常处理、自定义异常响应的支持却不太友好。其间遇到一些坑,卡了一段时间,一遍遍翻看源码、一次次尝试之后,终于找到了解决办法,现将解决办法分享给大家,希望对大家有用。
内容较长,所以首先贴出解决方案。至于具体这样做的原因,有兴趣的可查看下一节的源码分析。
分三步:
OAuth2Exception
的 WebResponseExceptionTranslator
;ClientCredentialsTokenEndpointFilter
;allowFormAuthenticationForClients
配置,手动配置并添加 ClientCredentialsTokenEndpointFilter
、OAuth2AuthenticationEntryPoint
,使自定义的 ExceptionTranslator 生效。去除 allowFormAuthenticationForClients 这个配置,是因为一旦使用了这个配置 WebResponseExceptionTranslator 就会变成默认的,这样自定义的就不会生效。详情可见下一节的分析。
DefaultWebResponseExceptionTranslator
自定义 OAuth2WebResponseExceptionTranslator
这个类的主要作用就是将 OAuth2Exception
转换成 ResponseEntity< OAuth2Exception >
响应,由于泛型擦除的存在,我们可以指定返回实体的类型为你的错误响应类,而不仅限于 OAuth2Exception
。
OAuth2WebResponseExceptionTranslator.java:
public class OAuth2WebResponseExceptionTranslator implements WebResponseExceptionTranslator<OAuth2Exception> { private final ThrowableAnalyzer throwableAnalyzer = new DefaultThrowableAnalyzer (); @Override @SuppressWarnings({"unchecked", "rawtypes"}) public ResponseEntity translate(Exception e) throws Exception { ErrorCode errorCode; Throwable[] causeChain = throwableAnalyzer.determineCauseChain (e); Exception ase = (OAuth2Exception) throwableAnalyzer.getFirstThrowableOfType (OAuth2Exception.class, causeChain); if (ase != null) { errorCode = convertOAuthExceptionToErrorCode ((OAuth2Exception) ase); return handleOAuth2Exception ((OAuth2Exception) ase, errorCode); } ase = (AuthenticationException) throwableAnalyzer.getFirstThrowableOfType (AuthenticationException.class, causeChain); if (ase != null) { errorCode = ErrorCodeEnum.UNAUTHORIZED; return handleOAuth2Exception (new OAuth2WebResponseExceptionTranslator.UnauthorizedException (e.getMessage (), e), errorCode); } ase = (AccessDeniedException) throwableAnalyzer.getFirstThrowableOfType (AccessDeniedException.class, causeChain); if (ase != null) { errorCode = ErrorCodeEnum.FORBIDDEN; return handleOAuth2Exception (new OAuth2WebResponseExceptionTranslator.ForbiddenException (ase.getMessage (), ase), errorCode); } ase = (HttpRequestMethodNotSupportedException) throwableAnalyzer.getFirstThrowableOfType ( HttpRequestMethodNotSupportedException.class, causeChain); if (ase != null) { errorCode = ErrorCodeEnum.METHOD_NOT_ALLOWED; return handleOAuth2Exception (new OAuth2WebResponseExceptionTranslator.MethodNotAllowed (ase.getMessage (), ase), errorCode); } errorCode = ErrorCodeEnum.INTERNAL_SERVER_ERROR; return handleOAuth2Exception ( new OAuth2WebResponseExceptionTranslator .ServerErrorException (HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase (), e), errorCode); } private ResponseEntity<?> handleOAuth2Exception(OAuth2Exception e, ErrorCode errorCode) throws IOException { HttpHeaders headers = new HttpHeaders (); headers.set ("Cache-Control", "no-store"); headers.set ("Pragma", "no-cache"); int status = e.getHttpErrorCode (); if (status == HttpStatus.UNAUTHORIZED.value () || (e instanceof InsufficientScopeException)) { headers.set ("WWW-Authenticate", String.format ("%s %s", OAuth2AccessToken.BEARER_TYPE, e.getSummary ())); } R<Map<String, String>> r = R.error (errorCode).data (e.getAdditionalInformation ()); return new ResponseEntity<> (r, headers, HttpStatus.valueOf (status)); } ErrorCode convertOAuthExceptionToErrorCode(OAuth2Exception e) { ErrorCode errorCode; String oAuth2ErrorCode = e.getOAuth2ErrorCode (); switch (oAuth2ErrorCode) { case OAuth2Exception.INVALID_REQUEST: errorCode = ErrorCodeEnum.INVALID_REQUEST; break; case OAuth2Exception.INVALID_CLIENT: errorCode = ErrorCodeEnum.BAD_CREDENTIALS; break; case OAuth2Exception.INVALID_GRANT: case OAuth2Exception.INSUFFICIENT_SCOPE: case OAuth2Exception.INVALID_SCOPE: case OAuth2Exception.UNSUPPORTED_GRANT_TYPE: case OAuth2Exception.UNAUTHORIZED_CLIENT: case OAuth2Exception.REDIRECT_URI_MISMATCH: case OAuth2Exception.UNSUPPORTED_RESPONSE_TYPE: errorCode = ErrorCodeEnum.INVALID_LOGIN_CREDENTIAL; break; case OAuth2Exception.INVALID_TOKEN: errorCode = ErrorCodeEnum.INVALID_TOKEN; break; case OAuth2Exception.ACCESS_DENIED: errorCode = ErrorCodeEnum.FORBIDDEN; break; default: errorCode = ErrorCodeEnum.UNKNOWN_ERROR; break; } return errorCode; } @SuppressWarnings("serial") private static class ForbiddenException extends OAuth2Exception { public ForbiddenException(String msg, Throwable t) { super (msg, t); } @Override public String getOAuth2ErrorCode() { return "access_denied"; } @Override public int getHttpErrorCode() { return 403; } } @SuppressWarnings("serial") private static class ServerErrorException extends OAuth2Exception { public ServerErrorException(String msg, Throwable t) { super (msg, t); } @Override public String getOAuth2ErrorCode() { return "server_error"; } @Override public int getHttpErrorCode() { return 500; } } @SuppressWarnings("serial") private static class UnauthorizedException extends OAuth2Exception { public UnauthorizedException(String msg, Throwable t) { super (msg, t); } @Override public String getOAuth2ErrorCode() { return "unauthorized"; } @Override public int getHttpErrorCode() { return 401; } } @SuppressWarnings("serial") private static class MethodNotAllowed extends OAuth2Exception { public MethodNotAllowed(String msg, Throwable t) { super (msg, t); } @Override public String getOAuth2ErrorCode() { return "method_not_allowed"; } @Override public int getHttpErrorCode() { return 405; } } }
ErrorCode
和ErrorCodeEnum
根据自己项目进行更改。
ClientCredentialsTokenEndpointFilter
这一步是为了可以获得处理客户端认证的AuthenticationManager
:
public class CustomClientCredentialsTokenEndpointFilter extends ClientCredentialsTokenEndpointFilter { private final AuthorizationServerSecurityConfigurer configurer; private AuthenticationEntryPoint authenticationEntryPoint; public CustomClientCredentialsTokenEndpointFilter(AuthorizationServerSecurityConfigurer configurer) { this.configurer = configurer; } @Override public void setAuthenticationEntryPoint(AuthenticationEntryPoint authenticationEntryPoint) { super.setAuthenticationEntryPoint (null); this.authenticationEntryPoint = authenticationEntryPoint; } @Override protected AuthenticationManager getAuthenticationManager() { return configurer.and ().getSharedObject (AuthenticationManager.class); } @Override public void afterPropertiesSet() { //千万不要加 super.afterPropertiesSet(); setAuthenticationFailureHandler ((request, response, e) -> authenticationEntryPoint.commence (request, response, e)); setAuthenticationSuccessHandler ((request, response, authentication) -> { }); } }
很自然的,大家可以看到 AuthorizationServerEndpointsConfigurer
有关于 exceptionTranslator 的配置:
WebResponseExceptionTranslator<OAuth2Exception> exceptionTranslator = new OAuth2WebResponseExceptionTranslator (); @Override public void configure(AuthorizationServerEndpointsConfigurer endpoints) { endpoints // 配置使用redis保存token .tokenStore (new RedisTokenStore (redisConnectionFactory)) // 处理用户身份认证的 authenticationManager .authenticationManager (authenticationManager) // 用于支持令牌刷新 .userDetailsService (userDetailsService) // 使用自定义的 exceptionTranslator .exceptionTranslator (exceptionTranslator) ... ; }
以上配置对客户端登录凭据的认证无效,需要继续下一步的配置。
去除表单登录的配置:
@Override
public void configure(AuthorizationServerSecurityConfigurer oauthServer) {
// oauthServer.allowFormAuthenticationForClients ();
}
使用如下配置替代:
@Override public void configure(AuthorizationServerSecurityConfigurer security) { // 允许表单认证,同时也可以查询参数,但是开启此配置,就会使用默认的ClientCredentialsTokenEndpointFilter,不利于统一异常消息 // security.allowFormAuthenticationForClients (); OAuth2AuthenticationEntryPoint authenticationEntryPoint = new OAuth2AuthenticationEntryPoint (); authenticationEntryPoint.setTypeName ("Form"); authenticationEntryPoint.setRealmName ("oauth2/client"); // 使用自定义的 exceptionTranslator authenticationEntryPoint.setExceptionTranslator (exceptionTranslator); CustomClientCredentialsTokenEndpointFilter endpointFilter = new CustomClientCredentialsTokenEndpointFilter (security); // 必须首先执行这个,再继续接下来的配置 endpointFilter.afterPropertiesSet (); endpointFilter.setDefaultClientId (clienId); endpointFilter.setFilterProcessesUrl ("/login"); endpointFilter.setAuthenticationEntryPoint (authenticationEntryPoint); security.addTokenEndpointAuthenticationFilter (endpointFilter); security.authenticationEntryPoint (authenticationEntryPoint) .tokenKeyAccess ("isAuthenticated()") .checkTokenAccess ("permitAll()"); }
spring security oauth的异常处理有一个关键类DefaultWebResponseExceptionTranslator
,它实现了WebResponseExceptionTranslator<OAuth2Exception>
,用于将异常类统一转换成OAuth2Exception
,从而借助HttpMesssageConverters
来将OAuth2Exception
异常转换成错误响应(即前面所提到的格式)。
然而自定义OAuth2Exception
的HttpMesssageConverters
却又是不可行的,因为配置类中并不提供HttpMesssageConverter
的配置,并且我发现源码中有多处使用的HttpMesssageConverter
都是现new的,所以这条路基本上被堵死了。相反的AuthorizationServerConfigurerAdapter
类到是给出了一个exceptionTranslator
的配置(就是前面的WebResponseExceptionTranslator<OAuth2Exception>
):
@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
...
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
endpoints.exceptionTranslator (exceptionTranslator);
}
...
}
官方文档中也说了,建议我们自定义exceptionTranslator
而不是HttpMesssageConverter
来完成自定义错误响应。
但是呢如果我们自定义了一个exceptionTranslator,并且在上面的配置中配置上了自定义的异常转换器,还是会有问题。如果你开启了允许客户端表单登录的配置:
@Override
public void configure(AuthorizationServerSecurityConfigurer oauthServer) {
oauthServer.allowFormAuthenticationForClients ();
}
这个配置就是允许我们将 client_id和client_secret 写在from表单中,否则就只能将client_id和client_secret写在Basic认证里面了。
会发现对客户端的认证(即对client_id和client_secret认证)出现异常,还是走的默认的DefaultWebResponseExceptionTranslator
,根本不会有你配置的自定义异常转换器。
通过追踪AuthorizationServerSecurityConfigurer
的源码我发现,只要你开启了表单登录的配置,那么在最后都会重新new 一个 OAuth2AuthenticationEntryPoint
,使用默认的 DefaultWebResponseExceptionTranslator
,这就是自定义异常转换器不起作用的罪魁祸首。这个OAuth2AuthenticationEntryPoint
会被注入到ClientCredentialsTokenEndpointFilter
,作为客户端认证出现异常后的下一步动作。
这一部分的源码在
AuthorizationServerSecurityConfigurer
的configure()
和clientCredentialsTokenEndpointFilter()
两个方法中。
上面的解释大家看了可能会懵逼,这里简单解释一下,顺带捋一捋spring security oauth的认证过程。
了解过spring security的都知道,spring security的认证过程大致是这样的:
UsernamePasswordAuthenticationToken
对象(Authentication
的实现类);UsernamePasswordAuthenticationToken
交给AuthenticationManager
,由AuthenticationManager
校验用户名密码是否正确,即对登录凭据进行认证;Authentication
。然后将Authentication
放到SecurityContext
,即安全上下文中,方便鉴权操作。最后执行认证成功的回调等操作。AuthenticationFailureHandler
。上面的认证过程一般会被封装成一个过滤器,放到spring sesurity的过滤链中。当然也可以直接在controller中执行上面的过程。
而spring security oauth密码模式的认证过程实际上可以分为两个过程:一是对客户端凭据的认证(client_id和client_secret),这个过程被封装成了过滤器,即上面提到的ClientCredentialsTokenEndpointFilter
类;二是对用户凭据的认证(用户名、密码),这个过程是在controller中完成的,即"/oauth/token"
端点,在TokenEndpoint
类中实现。
上面对客户端的认证中我们自定义的exceptionTranslator失效的问题对应的就是ClientCredentialsTokenEndpointFilter
类。ClientCredentialsTokenEndpointFilter
对于认证过程中出现异常的处理类就是OAuth2AuthenticationEntryPoint
类,OAuth2AuthenticationEntryPoint
会使用exceptionTranslator对异常进行转换,生成http response,反馈给浏览器。
所以为了使自定义的exceptionTranslator在ClientCredentialsTokenEndpointFilter
中生效,我们需要仿照AuthorizationServerEndpointsConfigurer
的配置new出ClientCredentialsTokenEndpointFilter
、OAuth2AuthenticationEntryPoint
两个类,并给OAuth2AuthenticationEntryPoint
指定自定义的exceptionTranslator,最后在认证服务安全的配置中将ClientCredentialsTokenEndpointFilter
添加到过滤链。
ClientCredentialsTokenEndpointFilter
前面提到ClientCredentialsTokenEndpointFilter
和TokenEndpoint
都使用了AuthenticationManager
来完成认证,然而它们使用的AuthenticationManager
是不一样的,TokenEndpoint
的AuthenticationManager
来自于我们的配置:
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
endpoints
// 使用自定义的 authenticationManager 用于支持密码模式
.authenticationManager (authenticationManager)
;
}
而ClientCredentialsTokenEndpointFilter
的AuthenticationManager
来源于我们对客户端详情的配置:
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory ()
.withClient (clienId)
...
}
框架会生成对应的AuthenticationManager
。在AuthorizationServerSecurityConfigurer
中这个AuthenticationManager
是通过HttpSecurityBuilder.getSharedObject
获得的。但是在我们进行AuthorizationServerConfigurerAdapter.configure(AuthorizationServerSecurityConfigurer security)
的配置时,这个共享对象还未被设定。这就需要我们自定义ClientCredentialsTokenEndpointFilter
了,重写它的getAuthenticationManager
方法。
@Override
protected AuthenticationManager getAuthenticationManager() {
return configurer.and ().getSharedObject (AuthenticationManager.class);
}
这一步就是为了可以让我们正确的获取AuthenticationManager
,如果直接在AuthorizationServerConfigurerAdapter.configure(AuthorizationServerSecurityConfigurer security)
中调用security.and ().getSharedObject (AuthenticationManager.class)
获得的结果会是null
。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。