在前面的篇章中,我们在请求认证服务器的交互过程去中,如果出现了异常,则服务器响应给客户端的信息格式非常不友好,我们希望服务器在发生异常时,将异常信息按照固定的json格式返回给客户端,这时需要我们来自定义异常,我们需要将异常进行捕获,然后按照我们定义的格式输出。那么本篇,我们就来介绍一下如何自定义异常。
认证服务器异常举例
下面先列举一些异常出现的状况。
用户名不存在
我们在使用postman向认证服务器获取token信息的时候,如果输入的用户名不存在,则直接返回下面的异常。
上面的响应结果中,服务器给postman返回了登录页html,这让客户端有点摸不着头脑,其实控制台是有准确输出异常信息的。
用户密码不正确
我们在使用postman向认证服务器获取token信息的时候,如果输入的密码不正确,则直接返回下面的异常。
该异常信息比起上面的用户名不存在所响应的异常,已经是比较友好的了,起码已经准确的反馈了异常信息,并且也是输出json格式,但是这还不能达到我们的期望,我们按照下面的格式进行输出。
{
"code": 201,
"message": "失败",
"data": "密码不正确!"
}
请求受保护的资源未带令牌
其实认证服务器也可以是资源资源服务器,将之前资源服务器项目中的MessagesController复制一份到认证服务器org.oauth.server.controler目录下,代码如下。
@RestController
public class MessagesController {
@GetMapping("/messages1")
public String getMessages1() {
return " hello Message 1";
}
@GetMapping("/messages2")
@PreAuthorize("hasAuthority('SCOPE_profile')")
public String getMessages2() {
return " hello Message 2";
}
@GetMapping("/messages3")
@PreAuthorize("hasAuthority('SCOPE_Message')")
public String getMessages3() {
return " hello Message 3";
}
@GetMapping("/messages4")
@PreAuthorize("hasAuthority('SCOPE_USER')")
public String getMessages4() {
return " hello Message 4";
}
}
DefaultSecurityConfig配置类添加@EnableMethodSecurity(jsr250Enabled = true, securedEnabled = true)注解打开权限校验。
我们在使用postman请求受保护的资源接口的时候,如果请求未带上令牌(token),则直接返回下面的异常。
上面的异常直接返回401状态,Body中什么信息都没有,在响应头中就直接返回“Bearer”。
请求受保护的资源令牌权限不足
postman获取token如下。
将access_token使用jwt解析如下。
通过jwt解析可以看到,access_token拥有的权限为address email phone USER,而访问“http://spring-oauth-server:9000/messages3”接口需要Message权限,将access_token拿去访问http://spring-oauth-server:9000/messages3接口,结果如下。
上面的异常响应信息中,状态直接返回403,Body中也是什么信息都没有,响应头中返回的“Bearer error="insufficient_scope", error_descriptinotallow="The request requires higher privileges than provided by the access token.", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1"”是准确返回了异常信息,但我们也是希望通过json格式响应到Body中。
框架捕获异常代码逻辑
在Spring Authorization Server的过滤链中有一个叫ExceptionTranslationFilter的过滤器,在认证或授权过程中,如果出现AuthenticationException(认证异常)和AccessDeniedException(授权异常),都由ExceptionTranslationFilter过滤器捕获进行处理。ExceptionTranslationFilter捕获异常的代码如下。
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
try {
chain.doFilter(request, response);
} catch (IOException var7) {
throw var7;
} catch (Exception var8) {
Throwable[] causeChain = this.throwableAnalyzer.determineCauseChain(var8);
RuntimeException securityException = (AuthenticationException)this.throwableAnalyzer.getFirstThrowableOfType(AuthenticationException.class, causeChain);
if (securityException == null) {
securityException = (AccessDeniedException)this.throwableAnalyzer.getFirstThrowableOfType(AccessDeniedException.class, causeChain);
}
if (securityException == null) {
this.rethrow(var8);
}
if (response.isCommitted()) {
throw new ServletException("Unable to handle the Spring Security Exception because the response is already committed.", var8);
}
this.handleSpringSecurityException(request, response, chain, (RuntimeException)securityException);
}
}
当ExceptionTranslationFilter捕获到AuthenticationException或AccessDeniedException后,交由this.handleSpringSecurityException(request, response, chain, (RuntimeException)securityException)方法进行处理。this.handleSpringSecurityException(request, response, chain, (RuntimeException)securityException)方法代码如下。
private void handleSpringSecurityException(HttpServletRequest request, HttpServletResponse response, FilterChain chain, RuntimeException exception) throws IOException, ServletException {
if (exception instanceof AuthenticationException) {
this.handleAuthenticationException(request, response, chain, (AuthenticationException)exception);
} else if (exception instanceof AccessDeniedException) {
this.handleAccessDeniedException(request, response, chain, (AccessDeniedException)exception);
}
}
该方法对异常进行分类处理,如果是AuthenticationException类型异常,则交由 this.handleAuthenticationException(request, response, chain, (AuthenticationException)exception)方法进行处理,如果是AccessDeniedException类型异常,则交由this.handleAccessDeniedException(request, response, chain, (AccessDeniedException)exception)方法进行处理。
下面对AuthenticationException、AccessDeniedException异常处理分别进行讲解。
AuthenticationException异常
AuthenticationException异常进到this.handleAuthenticationException(request, response, chain, (AuthenticationException)exception)方法,代码如下。
private void handleAuthenticationException(HttpServletRequest request, HttpServletResponse response, FilterChain chain, AuthenticationException exception) throws ServletException, IOException {
this.logger.trace("Sending to authentication entry point since authentication failed", exception);
this.sendStartAuthentication(request, response, chain, exception);
}
上面方法中将AuthenticationException异常交由this.sendStartAuthentication(request, response, chain, exception)方法进行处理,this.sendStartAuthentication(request, response, chain, exception)方法代码如下。
protected void sendStartAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, AuthenticationException reason) throws ServletException, IOException {
SecurityContext context = this.securityContextHolderStrategy.createEmptyContext();
this.securityContextHolderStrategy.setContext(context);
this.requestCache.saveRequest(request, response);
this.authenticationEntryPoint.commence(request, response, reason);
}
进到this.authenticationEntryPoint.commence(request, response, reason)方法查看代码,发现是个接口。
public interface AuthenticationEntryPoint {
void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException;
}
也就是说,AuthenticationException异常信息由AuthenticationEntryPoint的实现类进行输出。
框架默认由DelegatingAuthenticationEntryPoint类进行处理,DelegatingAuthenticationEntryPoint的处理方法代码如下。
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
Iterator var4 = this.entryPoints.keySet().iterator();
RequestMatcher requestMatcher;
do {
if (!var4.hasNext()) {
logger.debug(LogMessage.format("No match found. Using default entry point %s", this.defaultEntryPoint));
this.defaultEntryPoint.commence(request, response, authException);
return;
}
requestMatcher = (RequestMatcher)var4.next();
logger.debug(LogMessage.format("Trying to match using %s", requestMatcher));
} while(!requestMatcher.matches(request));
AuthenticationEntryPoint entryPoint = (AuthenticationEntryPoint)this.entryPoints.get(requestMatcher);
logger.debug(LogMessage.format("Match found! Executing %s", entryPoint));
entryPoint.commence(request, response, authException);
}
上面代码中,最后是从this.entryPoints链表中取出一个匹配的AuthenticationEntryPoint实例,然后交由它处理。我们可以打个断点,输入错误的密码进行调试一下。
从上面的断点中可以看到,从this.entryPoints链表中链表中取出的是LoginUrlAuthenticationEntryPoint,LoginUrlAuthenticationEntryPoint处理异常的代码如下。
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
String redirectUrl;
if (!this.useForward) {
redirectUrl = this.buildRedirectUrlToLoginPage(request, response, authException);
this.redirectStrategy.sendRedirect(request, response, redirectUrl);
} else {
redirectUrl = null;
if (this.forceHttps && "http".equals(request.getScheme())) {
redirectUrl = this.buildHttpsRedirectUrlForRequest(request);
}
if (redirectUrl != null) {
this.redirectStrategy.sendRedirect(request, response, redirectUrl);
} else {
String loginForm = this.determineUrlToUseForThisRequest(request, response, authException);
logger.debug(LogMessage.format("Server side forward to: %s", loginForm));
RequestDispatcher dispatcher = request.getRequestDispatcher(loginForm);
dispatcher.forward(request, response);
}
}
}
从上面的代码中可以看到,最后是由dispatcher.forward(request, response)方法将请求转发到“login”页面。
为什么是LoginUrlAuthenticationEntryPoint呢?
我们打开AuthorizationServerConfig配置类,看看我们的异常配置,我们配置的正是LoginUrlAuthenticationEntryPoint。
AccessDeniedException异常
AccessDeniedException异常进到this.handleAccessDeniedException(request, response, chain, (AccessDeniedException)exception)方法,代码如下。
private void handleAccessDeniedException(HttpServletRequest request, HttpServletResponse response, FilterChain chain, AccessDeniedException exception) throws ServletException, IOException {
Authentication authentication = this.securityContextHolderStrategy.getContext().getAuthentication();
boolean isAnonymous = this.authenticationTrustResolver.isAnonymous(authentication);
if (!isAnonymous && !this.authenticationTrustResolver.isRememberMe(authentication)) {
if (this.logger.isTraceEnabled()) {
this.logger.trace(LogMessage.format("Sending %s to access denied handler since access is denied", authentication), exception);
}
this.accessDeniedHandler.handle(request, response, exception);
} else {
if (this.logger.isTraceEnabled()) {
this.logger.trace(LogMessage.format("Sending %s to authentication entry point since access is denied", authentication), exception);
}
this.sendStartAuthentication(request, response, chain, new InsufficientAuthenticationException(this.messages.getMessage("ExceptionTranslationFilter.insufficientAuthentication", "Full authentication is required to access this resource")));
}
}
上面方法的代码中,先是判断请求是否有带上令牌(token),如果没有,则交由sendStartAuthentication方法进行处理,方法代码如下。
protected void sendStartAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, AuthenticationException reason) throws ServletException, IOException {
SecurityContext context = this.securityContextHolderStrategy.createEmptyContext();
this.securityContextHolderStrategy.setContext(context);
this.requestCache.saveRequest(request, response);
this.authenticationEntryPoint.commence(request, response, reason);
}
this.authenticationEntryPoint.commence(request, response, reason)就是上面讲到的AuthenticationEntryPoint接口。
public interface AuthenticationEntryPoint {
void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException;
}
但这里框架交由BearerTokenAuthenticationEntryPoint类进行处理,主要是响应头信息返回401状态,告诉客户端需要带上有效的令牌(token)进行访问(Full authentication is required to access this resource),方法代码如下。
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) {
HttpStatus status = HttpStatus.UNAUTHORIZED;
Map<String, String> parameters = new LinkedHashMap();
if (this.realmName != null) {
parameters.put("realm", this.realmName);
}
if (authException instanceof OAuth2AuthenticationException) {
OAuth2Error error = ((OAuth2AuthenticationException)authException).getError();
parameters.put("error", error.getErrorCode());
if (StringUtils.hasText(error.getDescription())) {
parameters.put("error_description", error.getDescription());
}
if (StringUtils.hasText(error.getUri())) {
parameters.put("error_uri", error.getUri());
}
if (error instanceof BearerTokenError) {
BearerTokenError bearerTokenError = (BearerTokenError)error;
if (StringUtils.hasText(bearerTokenError.getScope())) {
parameters.put("scope", bearerTokenError.getScope());
}
status = ((BearerTokenError)error).getHttpStatus();
}
}
String wwwAuthenticate = computeWWWAuthenticateHeaderValue(parameters);
response.addHeader("WWW-Authenticate", wwwAuthenticate);
response.setStatus(status.value());
}
如果客户端请求有带上令牌(token),但令牌(token)权限不足,则交由DelegatingAccessDeniedHandler类进行处理,代码如下。
public final class DelegatingAccessDeniedHandler implements AccessDeniedHandler {
private final LinkedHashMap<Class<? extends AccessDeniedException>, AccessDeniedHandler> handlers;
private final AccessDeniedHandler defaultHandler;
public DelegatingAccessDeniedHandler(LinkedHashMap<Class<? extends AccessDeniedException>, AccessDeniedHandler> handlers, AccessDeniedHandler defaultHandler) {
Assert.notEmpty(handlers, "handlers cannot be null or empty");
Assert.notNull(defaultHandler, "defaultHandler cannot be null");
this.handlers = handlers;
this.defaultHandler = defaultHandler;
}
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
Iterator var4 = this.handlers.entrySet().iterator();
Map.Entry entry;
Class handlerClass;
do {
if (!var4.hasNext()) {
this.defaultHandler.handle(request, response, accessDeniedException);
return;
}
entry = (Map.Entry)var4.next();
handlerClass = (Class)entry.getKey();
} while(!handlerClass.isAssignableFrom(accessDeniedException.getClass()));
AccessDeniedHandler handler = (AccessDeniedHandler)entry.getValue();
handler.handle(request, response, accessDeniedException);
}
}
上面代码中,handler.handle(request, response, accessDeniedException)是AccessDeniedHandler接口,代码如下。
public interface AccessDeniedHandler {
void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException;
}
这里框架交由BearerTokenAccessDeniedHandler类进行处理,主要是响应头信息返回403状态,告诉客户端权限不足(Bearer error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token."),代码如下。
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) {
Map<String, String> parameters = new LinkedHashMap();
if (this.realmName != null) {
parameters.put("realm", this.realmName);
}
if (request.getUserPrincipal() instanceof AbstractOAuth2TokenAuthenticationToken) {
parameters.put("error", "insufficient_scope");
parameters.put("error_description", "The request requires higher privileges than provided by the access token.");
parameters.put("error_uri", "https://tools.ietf.org/html/rfc6750#section-3.1");
}
String wwwAuthenticate = computeWWWAuthenticateHeaderValue(parameters);
response.addHeader("WWW-Authenticate", wwwAuthenticate);
response.setStatus(HttpStatus.FORBIDDEN.value());
}
框架异常处理总结
通过上面关于AuthenticationException、AccessDeniedException异常处理的讲解,如果我们要自定义对AuthenticationException、AccessDeniedException异常的处理,那么我们就需自定义AuthenticationEntryPoint、AccessDeniedException的实现类,然后将自定义的异常实现类设置到配置中去。我们可以通过accessDeniedHandler(AccessDeniedHandler accessDeniedHandler)、authenticationEntryPoint(AuthenticationEntryPoint authenticationEntryPoint)方法,将自定义的异常设置到配置中去。
需要注意的是,只是处理AuthenticationException、AccessDeniedException异常还不够,其他的异常还得额外进行处理,例如:ArithmeticException、RuntimeException、IoException。
实现自定义异常
下面我们来开始添加自定义异常的代码。
添加响应结果工具类
为了让捕获到的异常按照固定的json格式输出,我们需要添加状态枚举类和结果响应类。
状态枚举类代码如下。
@Getter
public enum ResultCodeEnum {
SUCCESS(200,"成功"),
FAIL(201, "失败");
private Integer code;
private String message;
private ResultCodeEnum(Integer code, String message) {
this.code = code;
this.message = message;
}
}
结果响应类代码如下。
@Data
public class ResponseResult<T> {
/**
* 状态码
*/
private Integer code;
/**
* 返回信息
*/
private String message;
/**
* 数据
*/
private T data;
private ResponseResult() {}
/**
*
* @param body
* @param resultCodeEnum
* @return
* @param <T>
* @author Rommel
* @date 2023/7/31-10:46
* @version 1.0
* @description 构造返回结果
*/
public static <T> ResponseResult<T> build(T body, ResultCodeEnum resultCodeEnum) {
ResponseResult<T> result = new ResponseResult<>();
//封装数据
if(body != null) {
result.setData(body);
}
//状态码
result.setCode(resultCodeEnum.getCode());
//返回信息
result.setMessage(resultCodeEnum.getMessage());
return result;
}
/**
*
* @return
* @param <T>
* @author Rommel
* @date 2023/7/31-10:45
* @version 1.0
* @description 成功-无参
*/
public static<T> ResponseResult<T> ok() {
return build(null,ResultCodeEnum.SUCCESS);
}
/**
*
* @param data
* @return
* @param <T>
* @author Rommel
* @date 2023/7/31-10:45
* @version 1.0
* @description 成功-有参
*/
public static<T> ResponseResult<T> ok(T data) {
return build(data,ResultCodeEnum.SUCCESS);
}
/**
*
* @return
* @param <T>
* @author Rommel
* @date 2023/7/31-10:45
* @version 1.0
* @description 失败-无参
*/
public static<T> ResponseResult<T> fail() {
return build(null,ResultCodeEnum.FAIL);
}
/**
*
* @param data
* @return
* @param <T>
* @author Rommel
* @date 2023/7/31-10:45
* @version 1.0
* @description 失败-有参
*/
public static<T> ResponseResult<T> fail(T data) {
return build(data,ResultCodeEnum.FAIL);
}
public ResponseResult<T> message(String msg){
this.setMessage(msg);
return this;
}
public ResponseResult<T> code(Integer code){
this.setCode(code);
return this;
}
/**
*
* @param response
* @param e
* @throws IOException
* @author Rommel
* @date 2023/7/31-10:45
* @version 1.0
* @description 异常响应
*/
public static void exceptionResponse(HttpServletResponse response, Exception e) throws AccessDeniedException, AuthenticationException,IOException {
String message = null;
if(e instanceof OAuth2AuthenticationException o){
message = o.getError().getDescription();
}else{
message = e.getMessage();
}
exceptionResponse(response,message);
}
/**
*
* @param response
* @param message
* @throws AccessDeniedException
* @throws AuthenticationException
* @throws IOException
* @author Rommel
* @date 2023/8/1-16:18
* @version 1.0
* @description 异常响应
*/
public static void exceptionResponse(HttpServletResponse response,String message) throws AccessDeniedException, AuthenticationException,IOException {
ResponseResult responseResult = ResponseResult.fail(message);
Gson gson = new Gson();
String jsonResult = gson.toJson(responseResult);
response.setStatus(HttpServletResponse.SC_OK);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding(Charsets.UTF_8.name());
response.getWriter().print(jsonResult);
}
自定义异常类
当我们需要对一些判断逻辑进行异常抛出时,为了让抛出的异常能被捕获,我们新建一个自己的异常类,继承AuthenticationException,代码如下。
public class MyAuthenticationException extends AuthenticationException {
public MyAuthenticationException(String msg) {
super(msg);
}
}
将自己的判断逻辑异常抛出都改成抛出MyAuthenticationException。
自定义AuthenticationEntryPoint
新建AuthenticationEntryPoint的实现类,取名MyAuthenticationEntryPoint,代码如下。
public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
if(authException instanceof InsufficientAuthenticationException){
String accept = request.getHeader("accept");
if(accept.contains(MediaType.TEXT_HTML_VALUE)){
//如果是html请求类型,则返回登录页
LoginUrlAuthenticationEntryPoint loginUrlAuthenticationEntryPoint = new LoginUrlAuthenticationEntryPoint(OAuth2Constant.LOGIN_URL);
loginUrlAuthenticationEntryPoint.commence(request,response,authException);
}else {
//如果是api请求类型,则返回json
ResponseResult.exceptionResponse(response,"需要带上令牌进行访问");
}
}else if(authException instanceof InvalidBearerTokenException){
ResponseResult.exceptionResponse(response,"令牌无效或已过期");
}else{
ResponseResult.exceptionResponse(response,authException);
}
}
}
上面MyAuthenticationEntryPoint实现类,重写AuthenticationEntryPoint接口的方法。如果异常是InsufficientAuthenticationException类型,则说明是访问了受保护的资源,但没带上令牌(token),然后根据请求头的accept信息判断请求来源,如果accept是"text/html",则通过LoginUrlAuthenticationEntryPoint将请求跳转到“login”登录页,否则通过ResponseResult提示“需要带上令牌进行访问”。如果异常是InvalidBearerTokenException类型,则通过ResponseResult提示“令牌无效或已过期”。如果是其他异常类型,通过ResponseResult将原始异常信息输出。
将MyAuthenticationEntryPoint分别添加到AuthorizationServerConfig、DefaultSecurityConfig类中,
AuthorizationServerConfig类中异常配置。
//设置登录地址,需要进行认证的请求被重定向到该地址
http
.exceptionHandling((exceptions) -> exceptions
.defaultAuthenticationEntryPointFor(
new LoginUrlAuthenticationEntryPoint("/login"),
new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
)
.authenticationEntryPoint(new MyAuthenticationEntryPoint())
)
.oauth2ResourceServer(oauth2ResourceServer ->
oauth2ResourceServer.jwt(Customizer.withDefaults()));
DefaultSecurityConfig类中异常配置。
http
.oauth2ResourceServer((resourceServer) -> resourceServer
.jwt(Customizer.withDefaults())
.authenticationEntryPoint(new MyAuthenticationEntryPoint())
);
自定义AuthenticationEntryPoint测试
测试用户名不存在
postman输入不存在的用户名请求获取令牌(token),结果如下。
测试密码不正确
postman输入错误的密码请求获取令牌(token),结果如下。
测试请求受保护的资源未带令牌
postman访问http://localhost:9000/messages3接口,不带令牌,结果如下。
测试令牌已过期
postman访问http://localhost:9000/messages3接口,带上已过期的令牌,结果如下。
自定义AccessDeniedHandler
新建AccessDeniedHandler的实现类,取名MyAccessDeniedHandler,代码如下。
public class MyAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
if(request.getUserPrincipal() instanceof AbstractOAuth2TokenAuthenticationToken){
ResponseResult.exceptionResponse(response,"权限不足");
}else {
ResponseResult.exceptionResponse(response,accessDeniedException);
}
}
}
上面MyAccessDeniedHandler实现类,重写AccessDeniedHandler接口的方法,如果用户凭据是AbstractOAuth2TokenAuthenticationToken类型,则异常信息提示“权限不足”,否则输出原始提示信息。
将MyAccessDeniedHandler分别添加到AuthorizationServerConfig、DefaultSecurityConfig类中,
AuthorizationServerConfig类中异常配置。
//设置登录地址,需要进行认证的请求被重定向到该地址
http
.exceptionHandling((exceptions) -> exceptions
.defaultAuthenticationEntryPointFor(
new LoginUrlAuthenticationEntryPoint("/login"),
new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
)
.authenticationEntryPoint(new MyAuthenticationEntryPoint())
.accessDeniedHandler(new MyAccessDeniedHandler())
)
.oauth2ResourceServer(oauth2ResourceServer ->
oauth2ResourceServer.jwt(Customizer.withDefaults()));
DefaultSecurityConfig类中异常配置。
http
.oauth2ResourceServer((resourceServer) -> resourceServer
.jwt(Customizer.withDefaults())
.authenticationEntryPoint(new MyAuthenticationEntryPoint())
.accessDeniedHandler(new MyAccessDeniedHandler())
);
自定义AccessDeniedHandler测试
测试请求受保护的资源令牌权限不足
postman获取token如下。
将access_token使用jwt解析如下。
通过jwt解析可以看到,access_token拥有的权限为address email phone USER,而访问“http://spring-oauth-server:9000/messages3”接口需要Message权限,将access_token拿去访问http://spring-oauth-server:9000/messages3接口,结果如下。
自定义AuthenticationFailureHandler
上面的 AuthenticationEntryPoint、AccessDeniedHandler在认证和授权过程中,足以捕获大多数的异常,但是对于有些异常还是鞭长莫及,例如:OAuth2AuthenticationException。我们把密码校验的异常抛出改成OAuth2AuthenticationException,如下所示。
postman输入错误的密码请求获取令牌(token),结果如下。
此时响应到Body的异常信息格式,并不是我们ResponseResult指定的json格式。此时,我们需要通过自定义实现AuthenticationFailureHandler来处理捕获的异常。
新建AuthenticationFailureHandler的实现类,取名为MyAuthenticationFailureHandler,代码如下。
public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
ResponseResult.exceptionResponse(response,exception);
}
}
将MyAuthenticationFailureHandler设置到AuthorizationServerConfig类中,设置如下。
.tokenEndpoint(tokenEndpoint->{
tokenEndpoint.errorResponseHandler(new MyAuthenticationFailureHandler());
})
postman输入错误的密码重新请求获取令牌(token),结果如下。
MyAuthenticationFailureHandler的使命到此还未结束,postman输入错误的客户端id,重新请求获取令牌(token),结果如下。
还要对客户端的校验异常进行捕获处理,AuthorizationServerConfig配置类加入如下配置。
.clientAuthentication(clientAuthentication->{
clientAuthentication.errorResponseHandler(new MyAuthenticationFailureHandler());
})
postman输入错误的客户端id,重新请求获取令牌(token),结果如下。
到此,MyAuthenticationFailureHandler才完成他的使命。
自定义ExceptionTranslationFilter
ExceptionTranslationFilter只有处理AuthenticationException、AccessDeniedException的能力,但如果认证过程中出现ArithmeticException异常,会有两次经过ExceptionTranslationFilter,ArithmeticException异常首次经过ExceptionTranslationFilter时,由于不是ExceptionTranslationFilter的处理范围,因此ArithmeticException会被抛出去,丢给其他过滤器处理,当ExceptionTranslationFilter再次接到异常时,ArithmeticException已经变成了AccessDeniedException异常,最后ArithmeticException异常被当作AccessDeniedException输出。
我们可以在AuthorizationServerConfig类中下面处理权限的地方加上“int a= 1/0;”的代码,进行调试一下。
postman向http://spring-oauth-server:9000/oauth2/token发起请求获取token。
首次进来是“ java.lang.ArithmeticException: / by zero”异常,这个异常是正确的,然后放行,ExceptionTranslationFilter通过this.rethrow(var8)将异常抛出去。
接着ExceptionTranslationFilter又捕获到了异常,此时,异常已经变成“org.springframework.security.access.AccessDeniedException: Access Denied”了,这个异常就进入了ExceptionTranslationFilter的处理范围,最后这个异常被输出给客户端。但这个异常不是真正的异常,“ java.lang.ArithmeticException: / by zero”才是正在的异常,客户端要输出“ java.lang.ArithmeticException: / by zero”才正确,上演了一出狸猫换太子。
为了解决这个问题,我们可以自定义一个异常过滤器叫MyExceptionTranslationFilter,然后添加到过滤链中。
新建MyExceptionTranslationFilter过滤器,代码如下。
public class MyExceptionTranslationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
try {
filterChain.doFilter(request, response);
}catch (Exception e) {
if (e instanceof AuthenticationException|| e instanceof AccessDeniedException) {
throw e;
}
//非AuthenticationException、AccessDeniedException异常,则直接响应
ResponseResult.exceptionResponse(response,e);
}
}
}
MyExceptionTranslationFilter过滤器的作用是将捕获到的非AuthenticationException、AccessDeniedException异常直接按我们自定义的格式输出。
将MyExceptionTranslationFilter过滤器在AuthorizationServerConfig配置类中插入过滤链。
//设置登录地址,需要进行认证的请求被重定向到该地址
http
.addFilterBefore(new MyExceptionTranslationFilter(), ExceptionTranslationFilter.class)
.exceptionHandling((exceptions) -> exceptions
.defaultAuthenticationEntryPointFor(
new LoginUrlAuthenticationEntryPoint("/login"),
new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
)
.authenticationEntryPoint(new MyAuthenticationEntryPoint())
.accessDeniedHandler(new MyAccessDeniedHandler())
)
.oauth2ResourceServer(oauth2ResourceServer ->
oauth2ResourceServer.jwt(Customizer.withDefaults()));
postman向http://spring-oauth-server:9000/oauth2/token发起请求获取token,结果如下。
配置文件类代码
上面的几种情况都涉及到了配置类的修改,下面把AuthorizationServerConfig、DefaultSecurityConfig完整代码粘贴上来。
AuthorizationServerConfig类代码。
package org.oauth.server.config;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.SecurityContext;
import jakarta.annotation.Resource;
import org.apache.catalina.util.StandardSessionIdGenerator;
import org.oauth.server.authentication.device.DeviceClientAuthenticationConverter;
import org.oauth.server.authentication.device.DeviceClientAuthenticationProvider;
import org.oauth.server.authentication.mobile.MobileGrantAuthenticationConverter;
import org.oauth.server.authentication.mobile.MobileGrantAuthenticationProvider;
import org.oauth.server.authentication.oidc.MyOidcUserInfoAuthenticationConverter;
import org.oauth.server.authentication.oidc.MyOidcUserInfoAuthenticationProvider;
import org.oauth.server.authentication.oidc.MyOidcUserInfoService;
import org.oauth.server.authentication.password.PasswordGrantAuthenticationConverter;
import org.oauth.server.authentication.password.PasswordGrantAuthenticationProvider;
import org.oauth.server.filter.MyExceptionTranslationFilter;
import org.oauth.server.handler.MyAccessDeniedHandler;
import org.oauth.server.handler.MyAuthenticationEntryPoint;
import org.oauth.server.handler.MyAuthenticationFailureHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames;
import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames;
import org.springframework.security.oauth2.jwt.JwsHeader;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
import org.springframework.security.oauth2.server.authorization.*;
import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer;
import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
import org.springframework.security.oauth2.server.authorization.token.*;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.ExceptionTranslationFilter;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.time.Instant;
import java.util.Collection;
import java.util.Date;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* @author Rommel
* @version 1.0
* @date 2023/7/10-16:34
* @description TODO
*/
@Configuration
public class AuthorizationServerConfig {
private static final String CUSTOM_CONSENT_PAGE_URI = "/oauth2/consent";
@Resource
private UserDetailsService userDetailsService;
@Resource
private MyOidcUserInfoService myOidcUserInfoService;
/**
* Spring Authorization Server 相关配置
* 此处方法与下面defaultSecurityFilterChain都是SecurityFilterChain配置,配置的内容有点区别,
* 因为Spring Authorization Server是建立在Spring Security 基础上的,defaultSecurityFilterChain方法主要
* 配置Spring Security相关的东西,而此处authorizationServerSecurityFilterChain方法主要配置OAuth 2.1和OpenID Connect 1.0相关的东西
*/
@Bean
@Order(1)
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http,RegisteredClientRepository registeredClientRepository,
AuthorizationServerSettings authorizationServerSettings,
OAuth2AuthorizationService authorizationService,
OAuth2TokenGenerator<?> tokenGenerator)
throws Exception {
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
//AuthenticationConverter(预处理器),尝试从HttpServletRequest提取客户端凭据,用以构建OAuth2ClientAuthenticationToken实例。
DeviceClientAuthenticationConverter deviceClientAuthenticationConverter =
new DeviceClientAuthenticationConverter(
authorizationServerSettings.getDeviceAuthorizationEndpoint());
//AuthenticationProvider(主处理器),用于验证OAuth2ClientAuthenticationToken。
DeviceClientAuthenticationProvider deviceClientAuthenticationProvider =
new DeviceClientAuthenticationProvider(registeredClientRepository);
http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
.deviceAuthorizationEndpoint(deviceAuthorizationEndpoint ->
//设置用户码校验地址
deviceAuthorizationEndpoint.verificationUri("/activate")
)
.deviceVerificationEndpoint(deviceVerificationEndpoint ->
//设置授权页地址
deviceVerificationEndpoint.consentPage(CUSTOM_CONSENT_PAGE_URI)
)
.clientAuthentication(clientAuthentication ->
//设置AuthenticationConverter(预处理器)和AuthenticationProvider(主处理器)
clientAuthentication
.authenticationConverter(deviceClientAuthenticationConverter)
.authenticationProvider(deviceClientAuthenticationProvider)
)
.authorizationEndpoint(authorizationEndpoint ->
authorizationEndpoint.consentPage(CUSTOM_CONSENT_PAGE_URI))
//设置自定义密码模式
.tokenEndpoint(tokenEndpoint ->
tokenEndpoint
.accessTokenRequestConverter(
new PasswordGrantAuthenticationConverter())
.authenticationProvider(
new PasswordGrantAuthenticationProvider(
authorizationService, tokenGenerator)))
//设置自定义手机验证码模式
.tokenEndpoint(tokenEndpoint ->
tokenEndpoint
.accessTokenRequestConverter(
new MobileGrantAuthenticationConverter())
.authenticationProvider(
new MobileGrantAuthenticationProvider(
authorizationService, tokenGenerator)))
.tokenEndpoint(tokenEndpoint->{
tokenEndpoint.errorResponseHandler(new MyAuthenticationFailureHandler());
})
.clientAuthentication(clientAuthentication->{
clientAuthentication.errorResponseHandler(new MyAuthenticationFailureHandler());
})
//开启OpenID Connect 1.0(其中oidc为OpenID Connect的缩写)。
//.oidc(Customizer.withDefaults());
//自定义oidc
.oidc(oidcCustomizer->{
oidcCustomizer.userInfoEndpoint(userInfoEndpointCustomizer->{
userInfoEndpointCustomizer.userInfoRequestConverter(new MyOidcUserInfoAuthenticationConverter(myOidcUserInfoService));
userInfoEndpointCustomizer.authenticationProvider(new MyOidcUserInfoAuthenticationProvider(authorizationService));
});
});
//设置登录地址,需要进行认证的请求被重定向到该地址
http
.addFilterBefore(new MyExceptionTranslationFilter(), ExceptionTranslationFilter.class)
.exceptionHandling((exceptions) -> exceptions
.defaultAuthenticationEntryPointFor(
new LoginUrlAuthenticationEntryPoint("/login"),
new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
)
.authenticationEntryPoint(new MyAuthenticationEntryPoint())
.accessDeniedHandler(new MyAccessDeniedHandler())
)
.oauth2ResourceServer(oauth2ResourceServer ->
oauth2ResourceServer.jwt(Customizer.withDefaults()));
return http.build();
}
/**
* 客户端信息
* 对应表:oauth2_registered_client
*/
@Bean
public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate) {
return new JdbcRegisteredClientRepository(jdbcTemplate);
}
/**
* 授权信息
* 对应表:oauth2_authorization
*/
@Bean
public OAuth2AuthorizationService authorizationService(JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) {
return new JdbcOAuth2AuthorizationService(jdbcTemplate, registeredClientRepository);
}
/**
* 授权确认
*对应表:oauth2_authorization_consent
*/
@Bean
public OAuth2AuthorizationConsentService authorizationConsentService(JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) {
return new JdbcOAuth2AuthorizationConsentService(jdbcTemplate, registeredClientRepository);
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
*配置 JWK,为JWT(id_token)提供加密密钥,用于加密/解密或签名/验签
* JWK详细见:https://datatracker.ietf.org/doc/html/draft-ietf-jose-json-web-key-41
*/
@Bean
public JWKSource<SecurityContext> jwkSource() {
KeyPair keyPair = generateRsaKey();
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
RSAKey rsaKey = new RSAKey.Builder(publicKey)
.privateKey(privateKey)
.keyID(UUID.randomUUID().toString())
.build();
JWKSet jwkSet = new JWKSet(rsaKey);
return new ImmutableJWKSet<>(jwkSet);
}
/**
*生成RSA密钥对,给上面jwkSource() 方法的提供密钥对
*/
private static KeyPair generateRsaKey() {
KeyPair keyPair;
try {
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
keyPairGenerator.initialize(2048);
keyPair = keyPairGenerator.generateKeyPair();
}
catch (Exception ex) {
throw new IllegalStateException(ex);
}
return keyPair;
}
/**
* 配置jwt解析器
*/
@Bean
public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
}
/**
*配置认证服务器请求地址
*/
@Bean
public AuthorizationServerSettings authorizationServerSettings() {
//什么都不配置,则使用默认地址
return AuthorizationServerSettings.builder().build();
}
/**
*配置token生成器
*/
@Bean
OAuth2TokenGenerator<?> tokenGenerator(JWKSource<SecurityContext> jwkSource) {
JwtGenerator jwtGenerator = new JwtGenerator(new NimbusJwtEncoder(jwkSource));
jwtGenerator.setJwtCustomizer(jwtCustomizer(myOidcUserInfoService));
OAuth2AccessTokenGenerator accessTokenGenerator = new OAuth2AccessTokenGenerator();
OAuth2RefreshTokenGenerator refreshTokenGenerator = new OAuth2RefreshTokenGenerator();
return new DelegatingOAuth2TokenGenerator(
jwtGenerator, accessTokenGenerator, refreshTokenGenerator);
}
@Bean
public OAuth2TokenCustomizer<JwtEncodingContext> jwtCustomizer(MyOidcUserInfoService myOidcUserInfoService) {
return context -> {
JwsHeader.Builder headers = context.getJwsHeader();
JwtClaimsSet.Builder claims = context.getClaims();
if (context.getTokenType().equals(OAuth2TokenType.ACCESS_TOKEN)) {
// Customize headers/claims for access_token
claims.claims(claimsConsumer->{
UserDetails userDetails = userDetailsService.loadUserByUsername(context.getPrincipal().getName());
claimsConsumer.merge("scope",userDetails.getAuthorities(),(scope,authorities)->{
Set<String> scopeSet = (Set<String>)scope;
Set<String> cloneSet = scopeSet.stream().map(String::new).collect(Collectors.toSet());
Collection<SimpleGrantedAuthority> simpleGrantedAuthorities = ( Collection<SimpleGrantedAuthority>)authorities;
simpleGrantedAuthorities.stream().forEach(simpleGrantedAuthority -> {
if(!cloneSet.contains(simpleGrantedAuthority.getAuthority())){
cloneSet.add(simpleGrantedAuthority.getAuthority());
}
});
return cloneSet;
});
});
} else if (context.getTokenType().getValue().equals(OidcParameterNames.ID_TOKEN)) {
// Customize headers/claims for id_token
claims.claim(IdTokenClaimNames.AUTH_TIME, Date.from(Instant.now()));
StandardSessionIdGenerator standardSessionIdGenerator = new StandardSessionIdGenerator();
claims.claim("sid", standardSessionIdGenerator.generateSessionId());
}
};
}
}
DefaultSecurityConfig类代码。
package org.oauth.server.config;
import org.oauth.server.handler.MyAccessDeniedHandler;
import org.oauth.server.handler.MyAuthenticationEntryPoint;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
/**
* @author Rommel
* @version 1.0
* @date 2023/7/15-23:21
* @description TODO
*/
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(jsr250Enabled = true, securedEnabled = true)
public class DefaultSecurityConfig {
/**
*Spring Security 过滤链配置(此处是纯Spring Security相关配置)
*/
@Bean
@Order(2)
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http)
throws Exception {
// http
// //设置所有请求都需要认证,未认证的请求都被重定向到login页面进行登录
// .authorizeHttpRequests((authorize) -> authorize
// .anyRequest().authenticated()
// )
// // 由Spring Security过滤链中UsernamePasswordAuthenticationFilter过滤器拦截处理“login”页面提交的登录信息。
// .formLogin(Customizer.withDefaults());
http
.authorizeHttpRequests(authorize ->
authorize
.requestMatchers("/assets/**", "/webjars/**", "/login").permitAll()
.anyRequest().authenticated()
)
.formLogin(formLogin ->
formLogin
.loginPage("/login")
);
http
.oauth2ResourceServer((resourceServer) -> resourceServer
.jwt(Customizer.withDefaults())
.authenticationEntryPoint(new MyAuthenticationEntryPoint())
.accessDeniedHandler(new MyAccessDeniedHandler())
);
return http.build();
}
/**
*设置用户信息,校验用户名、密码
* 这里或许有人会有疑问,不是说OAuth 2.1已经移除了密码模式了码?怎么这里还有用户名、密码登录?
* 例如:某平台app支持微信登录,用户想使用微信账号登录登录该平台app,则用户需先登录微信app,
* 此处代码的操作就类似于某平台app跳到微信登录界面让用户先登录微信,然后微信校验用户提交的用户名、密码,
* 登录了微信才对某平台app进行授权,对于微信平台来说,某平台的app就是OAuth 2.1中的客户端。
* 其实,这一步是Spring Security的操作,纯碎是认证平台的操作,是脱离客户端(第三方平台)的。
*/
// @Bean
// public UserDetailsService userDetailsService() {
// UserDetails userDetails = User.withDefaultPasswordEncoder()
// .username("user")
// .password("password")
// .roles("USER")
// .build();
// //基于内存的用户数据校验
// return new InMemoryUserDetailsManager(userDetails);
// }
}
授权码模式测试
上面对异常的处理都是使用postman通过api进行操作的,现在测试一下授权码流程是否正常。
在浏览器上输入http://localhost:9000/oauth2/authorize?response_type=code&client_id=oidc-client&scope=profile openid&redirect_uri=http://www.baidu.com地址,进入到用户登录界面。
输入用户名:user,密码:123456,进入到授权页面。
勾选授权范围,提交授权确认,则成功返回授权码。
使用授权码获取token,返回如下成功结果。
总结
本篇先举例演示了用户名不存在、密码不正确、请求受保护的资源未带令牌、请求受保护的资源令牌权限不足等框架默认的异常输出情况。接着介绍了框架中ExceptionTranslationFilter过滤器对AuthenticationException、AccessDeniedException的处理逻辑。然后实现了自定义的AuthenticationException、AccessDeniedException、AuthenticationFailureHandler、ExceptionTranslationFilter,并一一给出了相应的测试以验证,最后测试了一下授权码模式的整个流程是因为本篇章所增加的自定义异常而收到影响。
本篇代码在spring-oauth-pkce-server目录下:链接地址
标签:自定义,Spring,request,new,public,org,import,response,Authorization From: https://blog.51cto.com/u_15268610/6948348