之所以想写这一系列,是因为之前工作过程中使用Spring Security,但当时基于spring-boot 2.3.x,其默认的Spring Security是5.3.x。之后新项目升级到了spring-boot 3.3.0,结果一看Spring Security也升级为6.3.0,关键是其风格和内部一些关键Filter大改,导致在配置同样功能时,多费了些手脚,因此花费了些时间研究新版本的底层原理,这里将一些学习经验分享给大家。
注意:由于框架不同版本改造会有些使用的不同,因此本次系列中使用基本框架是 spring-boo-3.3.0(默认引入的Spring Security是6.3.0),JDK版本使用的是19,所有代码都在spring-security-study项目上:https://github.com/forever1986/spring-security-study.git
目录
- 1 Spring Security的异常类型
- 1.1 认证异常
- 1.2 授权异常
- 2 异常处理原理
- 3 代码示例
前面我们已经对Spring Security认证和授权做了叫深入了解,还了解了Session相关知识。但在现在的业务技术架构上面,更多使用微服务和无状态模式。这一章我们先来讲一下自定义异常处理及其前后端分离的方式。
有时候我们需要自定义异常页面或者异常返回方式(前后端分离),那么这种情况下,我们该如何做呢?我们之前在系列三中讨论过异常过滤器ExceptionTranslationFilter。这里我们先了解Spring Security的一些常见的异常,并回顾和更为深入了解其工作原理,这样我们就能清楚知道如何自定义异常处理。
1 Spring Security的异常类型
1.1 认证异常
Spring Security的认证异常接口AuthenticationException,后面的异常都是继承这个异常为主。以下列举常见的异常:
- UsernameNotFoundException:用户名找不到,在AbstractUserDetailsAuthenticationProvider抛出,但是会被转换为BadCredentialsException异常,因为正常网站并不想泄露哪些用户名是有效的,因此不会提示用户名不存在的错误。
- AccountStatusException:用户状态异常
- AccountExpiredException:用户过期异常
- BadCredentialsException:用户认证异常
1.2 授权异常
Spring Security的认证异常接口AccessDeniedException,后面的异常都是继承这个异常为主。以下列举常见的异常:
- AuthorizationServiceException:授权服务器异常
- AuthorizationDeniedException:认证拒绝异常
- CsrfException:csrf异常
2 异常处理原理
在系列三中,我们初步了解到Spring Security的异常处理是由ExceptionTranslationFilter来负责。我们也对源码进行了解析,如下图所示代码
在ExceptionTranslationFilter的doFilter方法,前面一些操作都是在识别AuthenticationException(认证异常)和AccessDeniedException(授权异常),如果不是这两个异常,则全部往外抛出。那我们看到handleSpringSecurityException方法是如何处理这2个异常的,如下图,我们可以看到是通过处理AccessDeniedException异常通过一个AccessDeniedHandler,而处理AuthenticationException是通过AuthenticationEntryPoint(这里可以看sendStartAuthentication方法具体实现)
那么从这里我们就知道如果要自定义异常,那么我们需要自定义AuthenticationEntryPoint和AccessDeniedHandler的实现类。我们也知道每个Filter过滤器都是有一个Configurer将其加入过滤器链,而ExceptionTranslationFilter就是通过ExceptionHandlingConfigurer,下图代码就是可以看到ExceptionHandlingConfigurer如何加入处理异常的Point和Handler:
至此,我们就知道处理Spring Security异常需要做2步:
- 自定义AuthenticationEntryPoint和AccessDeniedHandler
- 通过SecurityConfig中设置这自定义的AuthenticationEntryPoint和AccessDeniedHandler
3 代码示例
通过上面对其异常处理原理的分析之后,我们来使用一个代码示例演示一遍,演示内容就是将认证和授权错误信息返回自定义json格式:
代码参考lesson07子模块
1)新建lesson07子模块,其pom引入以下依赖(增加fastjson做JSON转换):
<dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><!--Spring Boot 提供的 Security 启动器 --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security</artifactId></dependency><!-- 使用fastjson来转换为json--><dependency><groupId>com.alibaba</groupId><artifactId>fastjson</artifactId></dependency>
</dependencies>
2)增加result包,引入以下类作为统一返回工具
public interface IResultCode {String getCode();String getMsg();
}
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Result<T> implements Serializable {private String code;private T data;private String msg;private long total;public static <T> Result<T> success() {return success((T)null);}public static <T> Result<T> success(T data) {ResultCode rce = ResultCode.SUCCESS;if (data instanceof Boolean && Boolean.FALSE.equals(data)) {rce = ResultCode.SYSTEM_EXECUTION_ERROR;}return result(rce, data);}public static <T> Result<T> success(T data, Long total) {Result<T> result = new Result();result.setCode(ResultCode.SUCCESS.getCode());result.setMsg(ResultCode.SUCCESS.getMsg());result.setData(data);result.setTotal(total);return result;}public static <T> Result<T> failed() {return result(ResultCode.SYSTEM_EXECUTION_ERROR.getCode(), ResultCode.SYSTEM_EXECUTION_ERROR.getMsg(), (T)null);}public static <T> Result<T> failed(String msg) {return result(ResultCode.SYSTEM_EXECUTION_ERROR.getCode(), msg, (T)null);}public static <T> Result<T> judge(boolean status) {return status ? success() : failed();}public static <T> Result<T> failed(IResultCode resultCode) {return result(resultCode.getCode(), resultCode.getMsg(), (T)null);}public static <T> Result<T> failed(IResultCode resultCode, String msg) {return result(resultCode.getCode(), msg, (T)null);}private static <T> Result<T> result(IResultCode resultCode, T data) {return result(resultCode.getCode(), resultCode.getMsg(), data);}private static <T> Result<T> result(String code, String msg, T data) {Result<T> result = new Result();result.setCode(code);result.setData(data);result.setMsg(msg);return result;}public static boolean isSuccess(Result<?> result) {return result != null && ResultCode.SUCCESS.getCode().equals(result.getCode());}public Result() {}public String getCode() {return this.code;}public T getData() {return this.data;}public String getMsg() {return this.msg;}public long getTotal() {return this.total;}public void setCode(String code) {this.code = code;}public void setData(T data) {this.data = data;}public void setMsg(String msg) {this.msg = msg;}public void setTotal(long total) {this.total = total;}public boolean equals(Object o) {if (o == this) {return true;} else if (!(o instanceof Result)) {return false;} else {Result<?> other = (Result)o;if (!other.canEqual(this)) {return false;} else if (this.getTotal() != other.getTotal()) {return false;} else {label49: {Object this$code = this.getCode();Object other$code = other.getCode();if (this$code == null) {if (other$code == null) {break label49;}} else if (this$code.equals(other$code)) {break label49;}return false;}Object this$data = this.getData();Object other$data = other.getData();if (this$data == null) {if (other$data != null) {return false;}} else if (!this$data.equals(other$data)) {return false;}Object this$msg = this.getMsg();Object other$msg = other.getMsg();if (this$msg == null) {if (other$msg != null) {return false;}} else if (!this$msg.equals(other$msg)) {return false;}return true;}}}protected boolean canEqual(Object other) {return other instanceof Result;}public int hashCode() {int result = 1;long $total = this.getTotal();result = result * 59 + (int)($total >>> 32 ^ $total);Object $code = this.getCode();result = result * 59 + ($code == null ? 43 : $code.hashCode());Object $data = this.getData();result = result * 59 + ($data == null ? 43 : $data.hashCode());Object $msg = this.getMsg();result = result * 59 + ($msg == null ? 43 : $msg.hashCode());return result;}public String toString() {return "Result(code=" + this.getCode() + ", data=" + this.getData() + ", msg=" + this.getMsg() + ", total=" + this.getTotal() + ")";}
}
public enum ResultCode implements IResultCode, Serializable {SUCCESS("00000", "ok"),USER_ERROR("A0001", "用户信息为空"),PARAM_IS_NULL("A0410", "请求必填参数为空"),SYSTEM_EXECUTION_ERROR("B0001", "系统执行出错");private String code;private String msg;public String getCode() {return this.code;}public String getMsg() {return this.msg;}public String toString() {return "{\"code\":\"" + this.code + '"' + ", \"msg\":\"" + this.msg + '"' + '}';}public static ResultCode getValue(String code) {ResultCode[] var1 = values();int var2 = var1.length;for(int var3 = 0; var3 < var2; ++var3) {ResultCode value = var1[var3];if (value.getCode().equals(code)) {return value;}}return SYSTEM_EXECUTION_ERROR;}private ResultCode(String code, String msg) {this.code = code;this.msg = msg;}private ResultCode() {}
}
3)在handler包下,新建DemoAuthenticationEntryPoint(实现AuthenticationEntryPoint),用于返回认证异常处理
public class DemoAuthenticationEntryPoint implements AuthenticationEntryPoint {/**** @param request 请求request* @param response 请求response* @param authException 认证的异常*/@Overridepublic void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {// authException.getLocalizedMessage()返回本地化语言的错误信息Result<String> result = Result.failed(authException.getLocalizedMessage());String json = JSON.toJSONString(result);response.setContentType("application/json;charset=utf-8");response.getWriter().println(json);}
}
4)在handler包下,新建DemoAccessDeniedHandler(实现AccessDeniedHandler),用于返回授权异常处理
public class DemoAccessDeniedHandler implements AccessDeniedHandler {@Overridepublic void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {// accessDeniedException.getLocalizedMessage()返回本地化语言的错误信息Result<String> result = Result.failed(accessDeniedException.getLocalizedMessage());String json = JSON.toJSONString(result);response.setContentType("application/json;charset=utf-8");response.getWriter().println(json);}
}
5)在config包下,新建SecurityConfig,配置对应的handler
注意:这里我们返回一个test的内存用户, 并配置user角色,但是我们在配置demo接口使用的是admin角色,用户测试权限
@Configuration
public class SecurityConfig {@Beanpublic SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {http// 所有访问都必须认证.authorizeHttpRequests(auth->auth// demo访问接口需要admin用户.requestMatchers("/demo").hasRole("admin").anyRequest().authenticated())// 默认配置.formLogin(Customizer.withDefaults())// 配置异常处理.exceptionHandling(handling -> handling// 认证异常处理 -- 测试授权异常时,可以注释掉DemoAuthenticationEntryPoint.authenticationEntryPoint(new DemoAuthenticationEntryPoint())// 授权异常处理.accessDeniedHandler(new DemoAccessDeniedHandler()));return http.build();}@Beanpublic UserDetailsService userDetailsService(){return new UserDetailsService(){@Overridepublic UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {return User.withUsername("test").password("{noop}1234").roles("user") // 给与配置用户.build();}};}
}
6)创建一个demo的controller和启动类,并启动项目
7)访问:http://127.0.0.1:8080/demo 这时候就返回我们自定义的认证错误的json
8)注意:这时候我们发现,无论我们访问:http://127.0.0.1:8080/login 或其它地址都无法跳转到登录页面,这是因为当我们设置了AuthenticationEntryPoint时,就会屏蔽了DefaultLoginPageGeneratingFilter过滤器,因此不会跳转到登录界面(一般在前后端分离情况下,我们才自定义这些返回json错误)
9)测试授权异常:注释掉AuthenticationEntryPoint的设置,先访问:http://127.0.0.1:8080/login 做登录,再访问:http://127.0.0.1:8080/demo
结语:本章我们了解了Spring Security常见的一些异常,以及异常处理机制并自定义异常的返回。之所以要做自定义异常以Json方式返回,往往是因为前后端分离的架构,前后端一般都会约定返回Json数据格式。那么下一章,我们看看Spring Security如何做前后端分离