1 前言
上一章我们已经按照与前端的约定自定义了token的返回格式。同样的,程序在运行过程中会产生各种异常,这些默认的异常对前端非常的不友好,比如默认的Oauth2异常response的status=4XX,返回的格式如下:
{
"error": "invalid_grant",
"error_description": "Bad credentials"
}
现在我们同样要把异常的返回格式改为与前端约定的格式,并且让response的status=200,这样便于前端对ajax访问时的异常进行统一处理。目标格式如下?
{
"code": 4xx,
"msg": "msg",
"data": null;
}
我们需要处理两方面的异常,一个是授权认证服务器的异常,也就是获取token时的异常,包括帐号不存在、密码错误、验证码错误、client错误等等。另一个是资源服务器的异常,比如权限不足,token过期等等。下面我们就来对这两方面进行改造。
2 认证服务器异常
2.1 定义自已的异常处理类
定义MyOauthException继承OAuth2Exception,代码如下:
package com.example.oauth2.exception;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import org.springframework.security.oauth2.common.exceptions.OAuth2Exception;
@JsonSerialize(using = MyOauthExceptionSerializer.class)
public class MyOauthException extends OAuth2Exception {
public MyOauthException(String msg, Throwable t) {
super(msg, t);
}
public MyOauthException(String msg) {
super(msg);
}
}
2.2 序列化异常处理类
定义MyOauthExceptionSerializer序列化MyOauthException,代码如下:
package com.example.oauth2.exception;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.ser.std.StdSerializer;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
public class MyOauthExceptionSerializer extends StdSerializer<MyOauthException> {
protected MyOauthExceptionSerializer() {
super(MyOauthException.class);
}
@Override
public void serialize(MyOauthException e, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException {
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
jsonGenerator.writeStartObject();
jsonGenerator.writeStringField("code", String.valueOf(e.getHttpErrorCode()));
jsonGenerator.writeStringField("msg", e.getMessage());
jsonGenerator.writeStringField("data",null);
jsonGenerator.writeEndObject();
}
}
2.3 异常捕获并通过MyOauthException处理
新建MyOauthWebResponseExceptionTranslator继承WebResponseExceptionTranslator,从异常栈中获取各种类型的异常并用我们自定义的MyOauthException进行处理,代码如下:
package com.example.oauth2.exception;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.oauth2.common.DefaultThrowableAnalyzer;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.common.exceptions.InsufficientScopeException;
import org.springframework.security.oauth2.common.exceptions.InvalidGrantException;
import org.springframework.security.oauth2.common.exceptions.OAuth2Exception;
import org.springframework.security.oauth2.provider.error.OAuth2AuthenticationEntryPoint;
import org.springframework.security.oauth2.provider.error.WebResponseExceptionTranslator;
import org.springframework.security.web.util.ThrowableAnalyzer;
import org.springframework.stereotype.Component;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import java.io.IOException;
@Component
public class MyOauthWebResponseExceptionTranslator implements WebResponseExceptionTranslator {
private ThrowableAnalyzer throwableAnalyzer = new DefaultThrowableAnalyzer();
@Override
public ResponseEntity<OAuth2Exception> translate(Exception e) throws Exception {
Throwable[] causeChain = throwableAnalyzer.determineCauseChain(e);
Exception ase=null;
// 异常栈获取 OAuth2Exception 异常
ase = (OAuth2Exception) throwableAnalyzer.getFirstThrowableOfType(
OAuth2Exception.class, causeChain);
// 异常栈中有OAuth2Exception
if (ase != null) {
return handleOAuth2Exception((OAuth2Exception) ase);
}
ase = (AuthenticationException) throwableAnalyzer.getFirstThrowableOfType(AuthenticationException.class,
causeChain);
if (ase != null) {
return handleOAuth2Exception(new UnauthorizedException(e.getMessage(), e));
}
ase = (AccessDeniedException) throwableAnalyzer
.getFirstThrowableOfType(AccessDeniedException.class, causeChain);
if (ase instanceof AccessDeniedException) {
return handleOAuth2Exception(new ForbiddenException(ase.getMessage(), ase));
}
ase = (HttpRequestMethodNotSupportedException) throwableAnalyzer
.getFirstThrowableOfType(HttpRequestMethodNotSupportedException.class, causeChain);
if (ase instanceof HttpRequestMethodNotSupportedException) {
return handleOAuth2Exception(new MethodNotAllowed(ase.getMessage(), ase));
}
// 不包含上述异常则服务器内部错误
return handleOAuth2Exception(new ServerErrorException(HttpStatus.OK.getReasonPhrase(), e));
}
//使用自定义的异常处理类处理异常
private ResponseEntity<OAuth2Exception> handleOAuth2Exception(OAuth2Exception e) throws IOException {
int status = e.getHttpErrorCode();
HttpHeaders headers = new HttpHeaders();
headers.set("Cache-Control", "no-store");
headers.set("Pragma", "no-cache");
if (status == HttpStatus.UNAUTHORIZED.value() || (e instanceof InsufficientScopeException)) {
headers.set("WWW-Authenticate", String.format("%s %s", OAuth2AccessToken.BEARER_TYPE, e.getSummary()));
}
MyOauthException exception = new MyOauthException(e.getMessage(), e);
ResponseEntity<OAuth2Exception> response = new ResponseEntity<OAuth2Exception>(exception, headers,
HttpStatus.OK);
return response;
}
@SuppressWarnings("serial")
private static class ForbiddenException extends OAuth2Exception {
public ForbiddenException(String msg, Throwable t) {
super(msg, t);
}
public String getOAuth2ErrorCode() {
return "access_denied";
}
public int getHttpErrorCode() {
return 403;
}
}
@SuppressWarnings("serial")
private static class ServerErrorException extends OAuth2Exception {
public ServerErrorException(String msg, Throwable t) {
super(msg, t);
}
public String getOAuth2ErrorCode() {
return "server_error";
}
public int getHttpErrorCode() {
return 500;
}
}
@SuppressWarnings("serial")
private static class UnauthorizedException extends OAuth2Exception {
public UnauthorizedException(String msg, Throwable t) {
super(msg, t);
}
public String getOAuth2ErrorCode() {
return "unauthorized";
}
public int getHttpErrorCode() {
return 401;
}
}
@SuppressWarnings("serial")
private static class MethodNotAllowed extends OAuth2Exception {
public MethodNotAllowed(String msg, Throwable t) {
super(msg, t);
}
public String getOAuth2ErrorCode() {
return "method_not_allowed";
}
public int getHttpErrorCode() {
return 405;
}
}
}
注:以上代码参考了https://blog.csdn.net/qq_31063463/article/details/83752459文章,但是在我学习的过程中发现,不管是密码错误还是grant_type错误,捕获到的异常类型都是OAuth2Exception异常,代码中用于捕获AuthenticationException、AccessDeniedException感觉没什么必要。认证错误走的不是AuthenticationException,而AccessDeniedException关于资源鉴权异常后面我们会单独处理。
2.4 认证服务器设置异常处理器
在AuthorizationServerConfig的public void configure(AuthorizationServerEndpointsConfigurer endpoints)中加入异常处理器,代码如下:
@Autowired
@Qualifier("myOauthWebResponseExceptionTranslator")
private WebResponseExceptionTranslator webResponseExceptionTranslator;
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.tokenStore(new InMemoryTokenStore())
.authenticationManager(authenticationManager)
.userDetailsService(userDetailsService)
.exceptionTranslator(webResponseExceptionTranslator)//认证异常处理器
.tokenEnhancer(tokenEnhancer);
}
2.5 测试
访问/oauth/token时,使用错误的密码,结果如下:
可以看到返回的status=200 OK,返回的数据也已经按照我们的格式来进行处理了。但是还有一个问题,Bad credentials等信息都是英文的,对于中国用户来说不是很友好,我们可以自已抛出一些中文的错误信息,以免前端还要判断信息后再显示中文。
2.6 抛出自定义中文错误信息
首先,如果是一个网页进行登录,肯定不需要每个用户都要记住grant_type、client_id、client_secret这些信息的,用户只需要记住自已的帐号名和密码就可以登录了,所以grant_type、client_id、client_secret这三个信息一般来说写死在代码里的。那么我们需要处理的也就是帐号错误,密码错误,验证码错误等。这些错误只需要实现AuthenticationProvider接口,抛出自定义中文信息就可在了。代码如下:
package com.example.oauth2.service.impl;
import com.example.oauth2.entity.Account;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
@Service
public class AuthenticationProviderImpl implements AuthenticationProvider {
@Autowired
@Qualifier("userDetailsServiceImpl")
private UserDetailsService userDetailsService;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String username=authentication.getName();
String password=authentication.getCredentials().toString();
Account account=(Account)userDetailsService.loadUserByUsername(username);
if(account.isLocked()){
throw new BadCredentialsException("帐号已锁定!");
}
if(account.isExpire()){
throw new BadCredentialsException("帐号已过期!");
}
if(!new BCryptPasswordEncoder().matches(password,account.getPassword())){
throw new BadCredentialsException("密码错误!");
}
return new UsernamePasswordAuthenticationToken(account,account.getPassword(),account.getAuthorities());
}
@Override
public boolean supports(Class<?> aClass) {
return UsernamePasswordAuthenticationToken.class.equals(aClass);
}
}
说明:在userDetailsService里我们从数据库里获取用户信息的时候就已经有抛出帐号不存在的错误了,在这个例子里我没有用验证码,如果要用验证码,同样在这里进行验证后抛出异常。这些异常我全部都用的BadCredentialsException进行抛出,不管你用什么,最终都会在MyOauthWebResponseExceptionTranslator 里被捕获为OauthException,所以不用在意这些细节。
然后在WebSecurityConfig里使用我们自定义的验证器,代码如下:
@Autowired
@Qualifier("authenticationProviderImpl")
private AuthenticationProvider authenticationProvider;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
auth.authenticationProvider(authenticationProvider);
}
最后,关于上一章的第2节,因为我们需要使用/oauth/login来获取token,如果不设置如下代码:
if(token.getValue()==null){
return new ResponseResult(Integer.parseInt(token.getAdditionalInformation().get("code").toString()),token.getAdditionalInformation().get("msg").toString());
}else{
return new ResponseResult("登录成功!",token);
}
那么当出现异常时,格式就会变得很奇怪,如下图:
所以我们必须判断是否获取的token.value为空,如为空说明发生了异常,那么从token里拿出code和msg再进行返回。最终结果如下图:
3 资源服务器异常
3.1 处理无证驾驶(无token异常)
我们先做个小实验,当我们获取到token后,在访问资源的时候需要携带token进行访问。
在OauthController里新建两个接口,代码如下:
@RequestMapping("/user")
public Principal user(Principal principal){
return principal;
}
@RequestMapping("/test")
@PreAuthorize("hasRole('admin')")
public ResponseResult test(){
return new ResponseResult(0,"访问成功!");
}
@PreAuthorize(“hasRole(‘admin’)”)表示只有身份为admin的用户才能访问。
我们先不带token直接访问/oauth/user,如下图:
显然,返回的格式和status不是我们想要的格式。那么 现在进行改造,加一个过滤器,先判断有请求里面有没有access_token如果没有,返回我们想要的格式。这里我是把过滤器加在了BasicAuthenticationFilter之前。
新建一个过滤器继承OncePerRequestFilter保证过滤器只执行一次,具体代码如下:
package com.example.oauth2.filter;
import com.alibaba.fastjson.JSONObject;
import com.example.oauth2.util.ResponseResult;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Component
public class MyTokenFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String uri=request.getRequestURI();
if(uri.equals("/oauth/token")||uri.equals("/oauth/login")){
filterChain.doFilter(request,response);
}else{
boolean access_token=false;
boolean authorization=false;
if(request.getParameter("access_token")==null){
access_token=true;
}
if(request.getHeader("Authorization")==null){
authorization=true;
}else{
if(!request.getHeader("Authorization").startsWith("Bearer")){
authorization=true;
}
}
if(access_token&&authorization){
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(JSONObject.toJSONString(new ResponseResult(401,"未获得凭证!")));
}else{
filterChain.doFilter(request,response);
}
}
}
}
然后在WebSecurityConfig的protected void configure(HttpSecurity http)方法里将过滤器加在BasicAuthenticationFilter之前,代码如下:
@Autowired
private MyTokenFilter mytokenfilter;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/oauth/login").permitAll()
.anyRequest().authenticated()
.and()
.httpBasic()
.and()
.csrf().disable();
http.addFilterBefore(mytokenfilter, BasicAuthenticationFilter.class);
}
现在我们再试一下不带token访问/oauth/user,如下图:
现在已经是我们想要的status和格式了。
3.2 处理驾照过期(token过期或无效)
我们现在使用正确的token来访问/oauth/user,如下图:
使用正确的token是可以访问的,那现在换成一个错的token就是删几个字符再试一下:
显然,也不符合我们与前端的约定格式,那么如何处理呢?
token过期和无效我们只需要写个类继承OAuth2AuthenticationEntryPoint就可以了,下面看代码:
package com.example.oauth2.exception;
import com.alibaba.fastjson.JSONObject;
import com.example.oauth2.util.ResponseResult;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.oauth2.provider.error.OAuth2AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Component
public class MyTokenExceptionEntryPoint extends OAuth2AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
httpServletResponse.setContentType("application/json;charset=UTF-8");
httpServletResponse.getWriter().write(JSONObject.toJSONString(new ResponseResult(403,"token无效或已过期!")));
}
}
然后在ResourceServerConfig里重写public void configure(ResourceServerSecurityConfigurer resources)方法,把处理器加进去就行了,代码如下:
@Autowired
private MyTokenExceptionEntryPoint tokenExceptionEntryPoint;
@Override
public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
resources.authenticationEntryPoint(tokenExceptionEntryPoint);
}
现在我们再试一下,使用错误token访问/oauth/user,结果如下图:
3.3 处理准驾车型不符(权限不足)
从3.2我们可以看到用户的身份是“ROLE_teacher”,而“/oauth/test”接口需要的权限为"admin",那么现在用正确的token来访问test接口看看,结果如下图:
这个问题处理起来也很简单,只需要实现AccessDeniedHandler接口就可以了,代码如下:
package com.example.oauth2.exception;
import com.alibaba.fastjson.JSONObject;
import com.example.oauth2.util.ResponseResult;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Component
public class MyAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AccessDeniedException e) throws IOException, ServletException {
httpServletResponse.setContentType("application/json;charset=UTF-8");
httpServletResponse.getWriter().write(JSONObject.toJSONString(new ResponseResult(401,"权限不足,不允许访问!")));
}
}
然后同3.2一样,将处理器加入ResourceServerConfig里的public void configure(ResourceServerSecurityConfigurer resources)方法中,代码如下:
@Autowired
private MyAccessDeniedHandler accessDeniedHandler;
@Override
public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
resources.authenticationEntryPoint(tokenExceptionEntryPoint)
.accessDeniedHandler(accessDeniedHandler);
}
现在再试一次,使用正确的token访问/oauth/test接口,结果如下 图:
4 结语
至此,关于oauth2中的异常处理已全部完成!如果在学习过程中,大家有更好的方法,希望大家多多指教。