写在最前面
最近使用了 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) -> {
});
}
}
接下来是修改配置
设置使用自定义的 exceptionTranslator
很自然的,大家可以看到 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
。
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
文章由极客之家整理,本文链接:https://www.bmabk.com/index.php/post/15277.html