当前位置:   article > 正文

spring security oauth:自定义异常处理(授权服务)_webresponseexceptiontranslator

webresponseexceptiontranslator

写在最前面

最近使用了 spring security oauth 来搭建认证服务,计划使用 oauth 的密码模式、以前端页面为客户端。

前后端交互要求统一相应结构,spring security oauth 默认的错误相应的 json 格式的,请求格式类似于下面这样:

{
    "error": "invalid_client",
    "error_description": "Bad client credentials"
}
  • 1
  • 2
  • 3
  • 4

然而, spring security oauth 授权服务对自定义异常处理、自定义异常响应的支持却不太友好。其间遇到一些坑,卡了一段时间,一遍遍翻看源码、一次次尝试之后,终于找到了解决办法,现将解决办法分享给大家,希望对大家有用。

解决办法

内容较长,所以首先贴出解决方案。至于具体这样做的原因,有兴趣的可查看下一节的源码分析。

分三步:

  1. 自定义 OAuth2ExceptionWebResponseExceptionTranslator
  2. 自定义ClientCredentialsTokenEndpointFilter
  3. 去除 allowFormAuthenticationForClients 配置,手动配置并添加 ClientCredentialsTokenEndpointFilterOAuth2AuthenticationEntryPoint,使自定义的 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;
		}

	}
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73
  • 74
  • 75
  • 76
  • 77
  • 78
  • 79
  • 80
  • 81
  • 82
  • 83
  • 84
  • 85
  • 86
  • 87
  • 88
  • 89
  • 90
  • 91
  • 92
  • 93
  • 94
  • 95
  • 96
  • 97
  • 98
  • 99
  • 100
  • 101
  • 102
  • 103
  • 104
  • 105
  • 106
  • 107
  • 108
  • 109
  • 110
  • 111
  • 112
  • 113
  • 114
  • 115
  • 116
  • 117
  • 118
  • 119
  • 120
  • 121
  • 122
  • 123
  • 124
  • 125
  • 126
  • 127
  • 128
  • 129
  • 130
  • 131
  • 132
  • 133
  • 134
  • 135
  • 136
  • 137
  • 138
  • 139
  • 140
  • 141
  • 142
  • 143
  • 144
  • 145
  • 146
  • 147
  • 148
  • 149
  • 150
  • 151
  • 152
  • 153
  • 154
  • 155
  • 156
  • 157
  • 158
  • 159
  • 160
  • 161
  • 162
  • 163
  • 164
  • 165
  • 166
  • 167
  • 168
  • 169
  • 170
  • 171
  • 172
  • 173
  • 174
  • 175
  • 176
  • 177

ErrorCodeErrorCodeEnum 根据自己项目进行更改。

自定义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) -> {
		});
	}
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29

接下来是修改配置

设置使用自定义的 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)
				...
		;
	}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17

以上配置对客户端登录凭据的认证无效,需要继续下一步的配置。

客户端认证的配置

去除表单登录的配置:

	@Override
	public void configure(AuthorizationServerSecurityConfigurer oauthServer) {
		// oauthServer.allowFormAuthenticationForClients ();
	}
  • 1
  • 2
  • 3
  • 4

使用如下配置替代:

	@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()");
	}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24

源码分析

异常转换器类

spring security oauth的异常处理有一个关键类DefaultWebResponseExceptionTranslator,它实现了WebResponseExceptionTranslator<OAuth2Exception>,用于将异常类统一转换成OAuth2Exception,从而借助HttpMesssageConverters来将OAuth2Exception异常转换成错误响应(即前面所提到的格式)。

然而自定义OAuth2ExceptionHttpMesssageConverters却又是不可行的,因为配置类中并不提供HttpMesssageConverter的配置,并且我发现源码中有多处使用的HttpMesssageConverter都是现new的,所以这条路基本上被堵死了。相反的AuthorizationServerConfigurerAdapter类到是给出了一个exceptionTranslator的配置(就是前面的WebResponseExceptionTranslator<OAuth2Exception>):

@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
	...
	@Override
	public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
		endpoints.exceptionTranslator (exceptionTranslator);
	}
	...
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

官方文档中也说了,建议我们自定义exceptionTranslator而不是HttpMesssageConverter来完成自定义错误响应。

客户端表单认证的坑

但是呢如果我们自定义了一个exceptionTranslator,并且在上面的配置中配置上了自定义的异常转换器,还是会有问题。如果你开启了允许客户端表单登录的配置:

	@Override
	public void configure(AuthorizationServerSecurityConfigurer oauthServer) {
		oauthServer.allowFormAuthenticationForClients ();
	}
  • 1
  • 2
  • 3
  • 4

这个配置就是允许我们将 client_id和client_secret 写在from表单中,否则就只能将client_id和client_secret写在Basic认证里面了。

会发现对客户端的认证(即对client_id和client_secret认证)出现异常,还是走的默认的DefaultWebResponseExceptionTranslator,根本不会有你配置的自定义异常转换器。

通过追踪AuthorizationServerSecurityConfigurer的源码我发现,只要你开启了表单登录的配置,那么在最后都会重新new 一个 OAuth2AuthenticationEntryPoint ,使用默认的 DefaultWebResponseExceptionTranslator,这就是自定义异常转换器不起作用的罪魁祸首。这个OAuth2AuthenticationEntryPoint会被注入到ClientCredentialsTokenEndpointFilter,作为客户端认证出现异常后的下一步动作。

这一部分的源码在 AuthorizationServerSecurityConfigurerconfigure()clientCredentialsTokenEndpointFilter()两个方法中。

更进一步

上面的解释大家看了可能会懵逼,这里简单解释一下,顺带捋一捋spring security oauth的认证过程。

了解过spring security的都知道,spring security的认证过程大致是这样的:

  1. 从请求中提取出用户名和密码,构建UsernamePasswordAuthenticationToken对象(Authentication的实现类);
  2. UsernamePasswordAuthenticationToken交给AuthenticationManager,由AuthenticationManager校验用户名密码是否正确,即对登录凭据进行认证;
  3. 第二步的认证通过,就会将用户信息、用户权限这些填充到Authentication。然后将Authentication放到SecurityContext,即安全上下文中,方便鉴权操作。最后执行认证成功的回调等操作。
  4. 如果第二步的认证失败了,就会清除安全上下文的相关数据,执行认证失败的处理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出ClientCredentialsTokenEndpointFilterOAuth2AuthenticationEntryPoint两个类,并给OAuth2AuthenticationEntryPoint指定自定义的exceptionTranslator,最后在认证服务安全的配置中将ClientCredentialsTokenEndpointFilter添加到过滤链。

为什么要自定义ClientCredentialsTokenEndpointFilter

前面提到ClientCredentialsTokenEndpointFilterTokenEndpoint都使用了AuthenticationManager来完成认证,然而它们使用的AuthenticationManager是不一样的,TokenEndpointAuthenticationManager来自于我们的配置:

	@Override
	public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
		endpoints
				// 使用自定义的 authenticationManager 用于支持密码模式
				.authenticationManager (authenticationManager)
		;
	}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

ClientCredentialsTokenEndpointFilterAuthenticationManager来源于我们对客户端详情的配置:

	@Override
	public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
		clients.inMemory ()
				.withClient (clienId)
				...
	}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

框架会生成对应的AuthenticationManager。在AuthorizationServerSecurityConfigurer中这个AuthenticationManager是通过HttpSecurityBuilder.getSharedObject获得的。但是在我们进行AuthorizationServerConfigurerAdapter.configure(AuthorizationServerSecurityConfigurer security)的配置时,这个共享对象还未被设定。这就需要我们自定义ClientCredentialsTokenEndpointFilter了,重写它的getAuthenticationManager方法。

	@Override
	protected AuthenticationManager getAuthenticationManager() {
		return configurer.and ().getSharedObject (AuthenticationManager.class);
	}
  • 1
  • 2
  • 3
  • 4

这一步就是为了可以让我们正确的获取AuthenticationManager,如果直接在AuthorizationServerConfigurerAdapter.configure(AuthorizationServerSecurityConfigurer security)中调用security.and ().getSharedObject (AuthenticationManager.class)获得的结果会是null

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/Gausst松鼠会/article/detail/277142
推荐阅读
相关标签
  

闽ICP备14008679号