理解 Spring Boot Security + JWT Token 的简单应用
项目中有用到 Spring Security 来控制 API 的访问权限,但对于配置应用它基本上是照葫芦画瓢。至于为什么要调用方法
早先配置 spring-security-config 是通过继承 WebSecurityConfigurerAdapter, 覆盖它的 configure(HttpSecurity http) 来配置访问规则等。在 spring-security-config 5.7.x 开始不建议用 WebSecurityConfigurerAdapter, 而是借由 SecurityFilterChain 来配置 HttpSecurity 中的规则 ,或者通过 WebSecurityCustomizer 完成定制。
因此,本文就来记述个最简单的 Spring Security 配置,从基本的 Spring boot RestAPI 项目起步,层层加上 Spring Security 依赖,相关配置,最后集成 JWT。旨在理清 Spring Security 的基本实现原理。实例所实现的基本功能是:SpringBoot 项目中创建两个 HTTP API, 一个可任意访问,另一个用 Spring Security 来控制,需要带上 JWT Token 才能访问。
本文所选用的 Spring Boot 版本是 2.7.1, Spring 版本为 5.3.21, spring-security-xxx 的版本是 5.7.2
SpringBoot RestAPI 项目只要一个 Maven 依赖
然后加上一个 WebController 类
main 方法也加在这个类中了,现在启动它,就可以访问该 API 了
然后什么也不做,重新启动应用,再来访问该 /public-api
为了 Spring Security 立马就介入了呢?原来是 spring-boot 核心包中的
而 SecurityAutoConfiguration 是被加了 @AutoConfiguration 注解的,并引入了 SpringBootWebSecurityConfiguration 类
而在 SpringBootWebSecurityConfiguration 中有
所以只要引入了 spring-boot-starter-security 依赖,无论写不写 @EnableWebSecurity, 它都是自动启用的。
我们看到自己写的 API 被保护了起来,但默认时对静态资源如 /css/**, /js/**, /images/**, /webjars/**, /**/favicon.ico 和错误页面是直接放行的。
注[2023-03-16]: 在 Spring Security 5.8 之后,没有了 antMatchers() 方法,代之为 requestMatchers() 方法,所以上面 filterChain() 中的代码要改为
对于
重启 Springboot 应用,再来检查 /public-api
假如我们还有另一个 API
如果在 WebController 方法中加上两行输出 Request, SecurityContext 的语句
这时候它的输出为
SecurityConfig.java
注[2023-03-16]: 同样的在 Spring Security 5.8 后要把 antMatchers() 改为 requestMatchers()
注:我们通常把这个自定义的 Filter 注册到 UsernamePasswordAuthenticationFilter 之前,特别是我们后面在 JwtTokenFilter 中直接创建 UsernamePasswordAuthentication 实例,放到 SecurityContextHolder 中的情况。如
如果不希望 Spring 帮我们管理 Session 的话,在其中再加上
再创建一个 JwtTokenFilter, 这一步只是往 SecurityContextHolder 中放一个 authenticated 为 true 的 Authentication, 随意找一个 Authentication 的实现类就行
下一步,为测试受保护的 API, 在 WebController 中再添加一个
重启 Springboot 应用,访问 /private-api
重启应用再测试 /private-api
然后再回到 JwtTokenFilter 中去按条件构建 JwtAuthentication 实例,设置它的 authenticated 属性,并放到 SecurityContextHolder 中
修改 JwtTokenFilter
注:JWT 的处理库是用的 com.auth0.java-jwt 包,依赖配置
测试(当然别忘了重启应用)
注:为节省篇幅,后面的 curl 命令输出我们其他省略响应头,只标名状态代码
JWT token 可用 https://jwt.io/ 进行生成,PAYLOAD 中
有效 JWT token
还有另一种方式,通过
进行三个测试,看客户端和服务端各自的输出
有效 token 访问保护 API
Spring 启动的时候,在 DefaultSecurityFilterChain 类中有 logger.info 输出当前已注册可用的所有 filter, 类似如下输出(为阅读方便,对输出进行换行显示)
UnauthorizedUnauthenticated), 而 token 有效但无相应角色返回 403(Forbidden)
前面的 SecurityConfig 中配置的 httpSecurity 只是要求 authenticated
除了 authenticated 以外,我们还可以进一步要求 JWT 中应有指定的 role 才能访问,即进行 Authorization 的验证。那么在 SecurityConfig 中的配置为
注[2023-03-16]: 同样的在 Spring Security 5.8 后要把 antMatchers() 改为 requestMatchers()
多个 role 中的一个可调用方法
在 JwtAuthentication 中我们需要覆盖 getAuthorities() 方法
用页面 https://jwt.io/ 在 PAYLOAD 中加上
后生成 JWT token 来访问
再加一个测试,如果转一个已过期,但含有 ROLE_contributors 的 token
如果希望在有 token 但 token 的 authenticated=false 时返回 403 就需要定制 AuthorizationFilter, 或它的 AuthorizationManager(RequestMatcherDelegatingAuthorizationManager), 在 AuthorityAuthorizationManager 类中
当 isAuthenticated() 为 false 时抛出
示例
注[2023-03-16]: 同样的在 Spring Security 5.8 后要把 antMatchers() 改为 requestMatchers()
注[2023-03-16]: 同样的在 Spring Security 5.8 后要把 antMatchers() 改为 requestMatchers()
当前 Spring Security 直接能实现的是对受保护 API 的访问,不含 Token 返回 401, 有 Token 但 authenticated() 为 false, 或无相应的 role/authority 权限时都返回 403。可覆盖 AuthorizationManager 实现或修改 JwtTokenFilter 来获得过期 token 返回 401。
其余剩下的就是 Spring Security 的事情了,能作更多扩展的地方大约就是在自定义的 Filter 中。如果在 Controller 中不需要关心 Authentication 的话就只需上面两步就应用上了 Spring Security。
自定义的 JwtTokenFilter 可以加在 SecurityContextPersistenceFilter 之后
编译器可能会提示 SecurityContextPersistenceFilter 不推荐使用,请用
链接:
补充[2023-03-16]: 当启用了 SpringSecurity 之后,在访问不存在的 API 或资源时不再是返回 404 NotFound, 而是 403 Forbidden 或 401 Unauthorized(如果配置了 httpSecurity.exceptionHandling().authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)); 的话) 错误响应。这也是合理的,反正是没权限访问,没必要明确告诉你 API 或资源是否真的不存在,不想给你看的东西别乱看就对了。
如果仍然想准确区分不存在的 API 或资源返回 404 NotFound, 存在但没权限访问的返回 403 Forbidden(或 401 Unauthorized) 就在 authenticationEntryPoint 和 accessDeniedHandler 中先检查资源不存在 response.sendError(404), 存在的资源执行默认行为。
MyAuthencationEntryPoint 的代码大意是
MyAccessDeniedHandler 的代码大致是
而我们要实现的就是方法 endpointNotExist(request), 如何确定 endpoint 是不存的。参考:Receiving 403 instead of 404 when calling non existing endpoint 永久链接 https://yanbin.blog/springboot-security-jwt-token-how-to-abcs/, 来自 隔叶黄莺 Yanbin's Blog
[版权声明]
本文采用 署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0) 进行许可。
SecurityContextHolder.getContext().setAuthentication()并且能从 HttpServletRequest 中得到 Authentication。还有,只要在 Controller 的方法中添加一个带 @AuthenticationPrincipal 注解的参数
public String sampleApi(@AuthenticationPrincipal DecodedJWT decodedJWT) {...}之后,decodedJWT 便自动有了值,诸如此类的,此前一概模糊不清。
早先配置 spring-security-config 是通过继承 WebSecurityConfigurerAdapter, 覆盖它的 configure(HttpSecurity http) 来配置访问规则等。在 spring-security-config 5.7.x 开始不建议用 WebSecurityConfigurerAdapter, 而是借由 SecurityFilterChain 来配置 HttpSecurity 中的规则 ,或者通过 WebSecurityCustomizer 完成定制。
因此,本文就来记述个最简单的 Spring Security 配置,从基本的 Spring boot RestAPI 项目起步,层层加上 Spring Security 依赖,相关配置,最后集成 JWT。旨在理清 Spring Security 的基本实现原理。实例所实现的基本功能是:SpringBoot 项目中创建两个 HTTP API, 一个可任意访问,另一个用 Spring Security 来控制,需要带上 JWT Token 才能访问。
本文所选用的 Spring Boot 版本是 2.7.1, Spring 版本为 5.3.21, spring-security-xxx 的版本是 5.7.2
SpringBoot RestAPI 项目只要一个 Maven 依赖
1<dependency>
2 <groupId>org.springframework.boot</groupId>
3 <artifactId>spring-boot-starter-web</artifactId>
4 <version>2.7.1</version>
5</dependency>然后加上一个 WebController 类
1package yanbin.blog;
2
3import org.springframework.boot.SpringApplication;
4import org.springframework.boot.autoconfigure.SpringBootApplication;
5import org.springframework.web.bind.annotation.GetMapping;
6import org.springframework.web.bind.annotation.RestController;
7
8
9@SpringBootApplication
10@RestController
11public class WebController {
12
13 public static void main(String[] args) {
14 SpringApplication.run(WebController.class, args);
15 }
16
17 @GetMapping("/public-api")
18 public String publicApi() {
19 return "this is a public api";
20 }
21}main 方法也加在这个类中了,现在启动它,就可以访问该 API 了
$ curl -i http://localhost:8080/public-api
HTTP/1.1 200
Content-Type: text/plain;charset=UTF-8
Content-Length: 20
Date: Fri, 01 Jul 2022 17:47:13 GMT this is a public api
引入 spring-boot-starter-security 依赖
到目前为止,一切都很自然。现在给项目引入 spring-boot-starter-security 依赖1<dependency>
2 <groupId>org.springframework.boot</groupId>
3 <artifactId>spring-boot-starter-security</artifactId>
4</dependency>然后什么也不做,重新启动应用,再来访问该 /public-api
curl -i http://localhost:8080/public-api我们表面上看只不过引入了一个依赖而已,然而事情正在起变化,这才使得现有的 API 都不能访问了
HTTP/1.1 401
Set-Cookie: JSESSIONID=19E76B7D8843CC59D11B3E9764176CB8; Path=/; HttpOnly
WWW-Authenticate: Basic realm="Realm"
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Length: 0
Date: Fri, 01 Jul 2022 17:47:58 GMT
为了 Spring Security 立马就介入了呢?原来是 spring-boot 核心包中的
org.springframework.boot.context.annotation.ImportCandidates (更早的源头可追溯到 @EnableAutoConfiguration 中引入的 AutoConfigurationImportSelector)读取到了 spring-boot-autoconfigure 包中文件 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports, 其中有关于 security 的 AutoConfiguration 条目1org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration
2org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration
3org.springframework.boot.autoconfigure.security.servlet.SecurityFilterAutoConfiguration
4....而 SecurityAutoConfiguration 是被加了 @AutoConfiguration 注解的,并引入了 SpringBootWebSecurityConfiguration 类
1@AutoConfiguration
2@ConditionalOnClass(DefaultAuthenticationEventPublisher.class)
3@EnableConfigurationProperties(SecurityProperties.class)
4@Import({ SpringBootWebSecurityConfiguration.class, SecurityDataConfiguration.class })
5public class SecurityAutoConfiguration {
6......而在 SpringBootWebSecurityConfiguration 中有
1@EnableWebSecurity
2static class WebSecurityEnablerConfiguration {
3}所以只要引入了 spring-boot-starter-security 依赖,无论写不写 @EnableWebSecurity, 它都是自动启用的。
我们看到自己写的 API 被保护了起来,但默认时对静态资源如 /css/**, /js/**, /images/**, /webjars/**, /**/favicon.ico 和错误页面是直接放行的。
加上 Spring Security 配置文件
要想让现有的 /public-api 能被自由的访问,需要添加一个配置来告诉 Spring Security,所以创建一个 Java 类 SecurityConfig 1package yanbin.blog;
2
3import org.springframework.context.annotation.Bean;
4import org.springframework.context.annotation.Configuration;
5import org.springframework.security.config.annotation.web.builders.HttpSecurity;
6import org.springframework.security.web.SecurityFilterChain;
7
8
9@Configuration
10public class SecurityConfig {
11
12 @Bean
13 public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception{
14 httpSecurity.authorizeHttpRequests().antMatchers("/public-api").permitAll()
15 .anyRequest().authenticated();
16 return httpSecurity.build();
17 }
18} 注[2023-03-16]: 在 Spring Security 5.8 之后,没有了 antMatchers() 方法,代之为 requestMatchers() 方法,所以上面 filterChain() 中的代码要改为
1httpSecurity.authorizeHttpRequests().requestMatchers("/public-api").permitAll()
2 .anyRequest().authenticated();
3return httpSecurity.build();对于
/public-api 允许任意的访问,其余的 API 需要验证, 这里只要求 Authentication 是 authenticated。重启 Springboot 应用,再来检查 /public-api
$ curl -i http://localhost:8080/public-api现在可以访问了,同时也注意到与没有引入 Spring Security 依赖前相比,响应头里还是被塞进了不少东西
HTTP/1.1 200
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: text/plain;charset=UTF-8
Content-Length: 20
Date: Fri, 01 Jul 2022 18:41:38 GMT this is a public api
假如我们还有另一个 API
/private-api, 那么访问它时也会得到 401 Unauthorized 的响应如果在 WebController 方法中加上两行输出 Request, SecurityContext 的语句
1@GetMapping("/public-api")
2public String publicApi(HttpServletRequest request) {
3 System.out.println("publicApi: " + request);
4 System.out.println("publicApi: " + SecurityContextHolder.getContext().getAuthentication());
5 return "this is a public api";
6}这时候它的输出为
publicApi: SecurityContextHolderAwareRequestWrapper[ org.springframework.security.web.header.HeaderWriterFilter$HeaderWriterRequest@a90747a]Spring Security 会把 Request 的实例换了. 对比未引入 Spring Security 依赖前的 request
publicApi: AnonymousAuthenticationToken [Principal=anonymousUser, Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=0:0:0:0:0:0:0:1, SessionId=null], Granted Authorities=[ROLE_ANONYMOUS]]
publicApi: org.apache.catalina.connector.RequestFacade@2658d732SecurityContextHolder 是来自 Spring Security 依赖中的类,所以只有引入了才有。对于 permitAll() 的 API, 在 SecurityContextHolder 中会包含一个 Authenticated=true 的 AnonymousAuthenticationToken。这时候我们有两种方式可获得 AuthenticationToken
- SecurityContextHolderAwareRequestWrapper.getAuthentication()
- SecurityContextHolder.getContext().getAuthentication()
访问受 Spring Security 保护的 API
我们一旦引入 Spring Security 依赖后不在 permitAll() 中的 API 就不能正常访问了,那我们怎么才能访问被 Spring Security 保护的 API 呢?先透露 Spring Security 检查的条件是要求在进入受保护 API 前SecurityContextHolder.getContext().getAuthentication().isAuthenticated()是返回 true。这就要求我们在进入到 Controller 方法之前往 SecurityContextHolder 中放一个 authenticated 为 true 的 Authentication。那么怎么才能实现该操作呢,可以在进行验证前的一个 Filter 里,所以我们要加一个 Filter,为注册 Filter 先要改一下 SecurityConfig 类SecurityConfig.java
1@Configuration
2public class SecurityConfig {
3
4 @Bean
5 public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception{
6 httpSecurity.authorizeHttpRequests().antMatchers("/public-api").permitAll()
7 .anyRequest().authenticated();
8
9 // 未提供 token 是返回 401 而不是 403
10 httpSecurity.exceptionHandling().authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED));
11
12 httpSecurity.addFilterBefore(new JwtTokenFilter(), AuthorizationFilter.class);
13 return httpSecurity.build();
14 }
15}注[2023-03-16]: 同样的在 Spring Security 5.8 后要把 antMatchers() 改为 requestMatchers()
注:我们通常把这个自定义的 Filter 注册到 UsernamePasswordAuthenticationFilter 之前,特别是我们后面在 JwtTokenFilter 中直接创建 UsernamePasswordAuthentication 实例,放到 SecurityContextHolder 中的情况。如
1httpSecurity.addFilterBefore(new JwtTokenFilter(), UsernamePasswordAuthenticationFilter.class);如果不希望 Spring 帮我们管理 Session 的话,在其中再加上
1httpSecurity.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);再创建一个 JwtTokenFilter, 这一步只是往 SecurityContextHolder 中放一个 authenticated 为 true 的 Authentication, 随意找一个 Authentication 的实现类就行
1public class JwtTokenFilter extends OncePerRequestFilter {
2
3 @Override
4 protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
5 throws ServletException, IOException {
6
7 Authentication authentication = new TestingAuthenticationToken("principal", "credentials");
8 authentication.setAuthenticated(true);
9 SecurityContextHolder.getContext().setAuthentication(authentication);
10
11 filterChain.doFilter(request, response);
12 }
13}下一步,为测试受保护的 API, 在 WebController 中再添加一个
/private-api, 由于它不在 permitAll() 列表中,也不是默认开放的,所以它要受到 Spring Security 的保护。 1@SpringBootApplication
2@RestController
3public class WebController {
4
5 public static void main(String[] args) {
6 SpringApplication.run(WebController.class, args);
7 }
8
9 @GetMapping("/private-api")
10 public String privateApi(HttpServletRequest request) {
11 System.out.println("privateApi:" + request);
12 System.out.println("privateApi:" + SecurityContextHolder.getContext().getAuthentication());
13 return "this is a private api";
14 }
15
16 @GetMapping("/public-api")
17 public String publicApi(HttpServletRequest request) {
18 System.out.println("publicApi: " + request);
19 System.out.println("publicApi: " + SecurityContextHolder.getContext().getAuthentication().isAuthenticated());
20 return "this is a public api";
21 }
22}重启 Springboot 应用,访问 /private-api
$ curl -i http://localhost:8080/private-api没问题,只要有 authenticated=true 的 Authentication 实例就行。那现在来测试一下 authenticated=false 的 Authentication 实例是怎么样,在 JwtTokenFilter 中调用 setAuthenticated(false)
HTTP/1.1 200
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Set-Cookie: JSESSIONID=AE55AE261A75908E8413CE9B82608FCE; Path=/; HttpOnly
Content-Type: text/plain;charset=UTF-8
Content-Length: 21
Date: Fri, 01 Jul 2022 21:33:29 GMT this is a private api
1Authentication authentication = new TestingAuthenticationToken("principal", "credentials");
2authentication.setAuthenticated(false);
3SecurityContextHolder.getContext().setAuthentication(authentication); 重启应用再测试 /private-api
$ curl -i http://localhost:8080/private-api现在我们找到了控制 API 是否能访问的开关,就是 SecurityContextHolder 中的 Authentication.isAuthenticated() 是否为 true, true 则允许,返之则禁止访问。当然对当前 API 的具体权限还要借助于 Authentication 的 List<GrantedAuthority> authorities 来控制,这是另一个话题。
HTTP/1.1 401
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Set-Cookie: JSESSIONID=DFCC7E18ADE4C8D105FEF73AD1B3E25C; Path=/; HttpOnly
Content-Length: 0
Date: Fri, 01 Jul 2022 21:36:37 GMT
集成 JWT token 验证
前面的 JwtTokenFilter 中我们用了 TestingAuthenticationToken 来示范 authenticated 的 true/false 对请求的影响,我们可以使用其他的实现了 Authentication 的子类,如 UsernamePasswordAuthenticationToken。但作为 JWT token 专用,我们创建一个自己的 JwtAuthentication 类 1package yanbin.blog;
2
3import org.springframework.security.authentication.AbstractAuthenticationToken;
4
5public class JwtAuthentication extends AbstractAuthenticationToken {
6
7 private final DecodedJWT decodedJWT;
8
9 public JwtAuthentication(DecodedJWT decodedJWT) {
10 super(null);
11 this.decodedJWT = decodedJWT;
12 setAuthenticated(decodedJWT.getExpiresAt().after(new Date()));
13 }
14
15 @Override
16 public Object getCredentials() {
17 return null;
18 }
19
20 @Override
21 public Object getPrincipal() {
22 return decodedJWT;
23 }
24}然后再回到 JwtTokenFilter 中去按条件构建 JwtAuthentication 实例,设置它的 authenticated 属性,并放到 SecurityContextHolder 中
修改 JwtTokenFilter
1public class JwtTokenFilter extends OncePerRequestFilter {
2
3 @Override
4 protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
5 throws ServletException, IOException {
6 Optional<DecodedJWT> optionalDecodedJWT = Optional.ofNullable(request.getHeader("authorization"))
7 .filter(s -> s.startsWith("Bearer ")).map(s -> s.substring(7)).map(s -> {
8 try {
9 return JWT.decode(s);
10 } catch (JWTDecodeException ex) {
11 return null;
12 }
13 });
14
15 if (optionalDecodedJWT.isPresent()) {
16 Authentication authentication = new JwtAuthentication(optionalDecodedJWT.get());
17
18 // 这里可以检查 JWT token 是否过期,issuer 等来设置 setAuthenticated(true/false)
19
20 SecurityContextHolder.getContext().setAuthentication(authentication);
21 } else {
22 SecurityContextHolder.clearContext();
23 }
24
25 filterChain.doFilter(request, response);
26 }
27}注:JWT 的处理库是用的 com.auth0.java-jwt 包,依赖配置
1<dependency>
2 <groupId>com.auth0</groupId>
3 <artifactId>java-jwt</artifactId>
4 <version>3.19.2</version>
5</dependency>测试(当然别忘了重启应用)
注:为节省篇幅,后面的 curl 命令输出我们其他省略响应头,只标名状态代码
JWT token 可用 https://jwt.io/ 进行生成,PAYLOAD 中
exp 为 token 过期日期有效 JWT token
$ curl -i -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiSm9obiBEb2UiLCJleHAiOjI1MTYyMzkwMjJ9.nxLE4nlE-HatyNWNsGkXWbBTpXo0pYUsfJvzeePTDR8" -i http://localhost:8080/private-api无效 JWT token(已过期)
HTTP/1.1 200 this is a private api
$ curl -i -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiSm9obiBEb2UiLCJleHAiOjEwMTYyMzkwMjJ9._SH20hSVSJoc8HZcbjAABoajiULb6-3taaMp6oHc2Dk" -i http://localhost:8080/private-api或者无 token
HTTP/1.1 403
$ curl -i http://localhost:8080/private-api
HTTP/1.1 401
在 Controller 方法中获 Authentication
由前面知道,在 Controller 方法有两种方法能获得当前的 Authentication- SecurityContextHolderAwareRequestWrapper.getAuthentication()
- SecurityContextHolder.getContext().getAuthentication()
还有另一种方式,通过
@AuthenticationPrincipal DecodedJWT decodedJWT 注解的方法参数,能够直接得到 Authentication 中的 principal 信息。我们再次改造 WebController 中的 API 方法 1@SpringBootApplication(exclude = UserDetailsServiceAutoConfiguration.class)
2@RestController
3public class WebController {
4
5 public static void main(String[] args) {
6 SpringApplication.run(WebController.class, args);
7 }
8
9 @GetMapping("/private-api")
10 public String privateApi(HttpServletRequest request, @AuthenticationPrincipal DecodedJWT decodedJWT) {
11 System.out.println("privateApi decodeJWT:" + decodedJWT);
12 System.out.println("privateApi request:" + request);
13 System.out.println("privateApi authentication:" + SecurityContextHolder.getContext().getAuthentication());
14 return "this is a private api";
15 }
16
17 @GetMapping("/public-api")
18 public String publicApi(HttpServletRequest request, @AuthenticationPrincipal DecodedJWT decodedJWT) {
19 System.out.println("publicApi decodedJWT:" + decodedJWT);
20 System.out.println("publicApi request: " + request);
21 System.out.println("publicApi authentication: " + SecurityContextHolder.getContext().getAuthentication());
22 return "this is a public api";
23 }
24}进行三个测试,看客户端和服务端各自的输出
有效 token 访问保护 API
$ curl -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiSm9obiBEb2UiLCJpYXQiOjE1MTYyMzkwMjJ9.hqWGSaFpvbrXkOWc6lrnffhNWR19W_S1YKFBx2arWBka234" -i http://localhost:8080/private-api服务端输出
HTTP/1.1 200 this is a private api
privateApi decodeJWT:com.auth0.jwt.JWTDecoder@130c8ea9有效 token 访问 public API
privateApi request:SecurityContextHolderAwareRequestWrapper[ org.springframework.security.web.header.HeaderWriterFilter$HeaderWriterRequest@17331810]
privateApi authentication:JwtAuthentication [Principal=com.auth0.jwt.JWTDecoder@130c8ea9, Credentials=[PROTECTED], Authenticated=true, Details=null, Granted Authorities=[]]
$ curl -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiSm9obiBEb2UiLCJpYXQiOjE1MTYyMzkwMjJ9.hqWGSaFpvbrXkOWc6lrnffhNWR19W_S1YKFBx2arWBka234" -i http://localhost:8080/public-api服务端输出
HTTP/1.1 200 this is a public api
publicApi decodedJWT:com.auth0.jwt.JWTDecoder@641811fe无效 token 访问 public API
publicApi request: SecurityContextHolderAwareRequestWrapper[ org.springframework.security.web.header.HeaderWriterFilter$HeaderWriterRequest@6adfe30d]
publicApi authentication: JwtAuthentication [Principal=com.auth0.jwt.JWTDecoder@641811fe, Credentials=[PROTECTED], Authenticated=true, Details=null, Granted Authorities=[]]
$ curl -H "Authorization: Bearer invalid.jwt.token" -i http://localhost:8080/public-api服务端输出
HTTP/1.1 200 this is a public api
publicApi decodedJWT:null不带 token 访问 public API
publicApi request: SecurityContextHolderAwareRequestWrapper[ org.springframework.security.web.header.HeaderWriterFilter$HeaderWriterRequest@6e396cf8]
publicApi authentication: null
$ curl -i http://localhost:8080/public-api服务端输出
HTTP/1.1 200 this is a public api
publicApi decodedJWT:null从上面看出,只要 JwtTokerFiler 中往 SecurityContextHolder 放了什么就能直接取到,而不管 API 是否受 Spring Security 的保护。
publicApi request: SecurityContextHolderAwareRequestWrapper[ org.springframework.security.web.header.HeaderWriterFilter$HeaderWriterRequest@2e31962f]
publicApi authentication: null
更深入理解 Spring Security 的工作原理
当 spring-boot-starter-security 引入后- 在 spring-boot-autoconfigure 包中的 SecurityAutoConfiguration(@AutoConfiguration 注解的类) import 了 SpringBootWebSecurityConfiguration(同样是 spring-boot-autoconfigure 包中的类)
- 在 SpringBootWebSecurityConfiguration 中,因为发现存在 EnableWebSecurity 类且 Spring Bean "springSecurityFilterChain" 还 没有,就会启用 @EnableWebSecurity这就是为什么只要引入 spring-boot-autoconfigure, 而无须手工加上 @EnableWebSecurity
1@Configuration(proxyBeanMethods = false) 2@ConditionalOnMissingBean(name = BeanIds.SPRING_SECURITY_FILTER_CHAIN) 3@ConditionalOnClass(EnableWebSecurity.class) 4@EnableWebSecurity 5static class WebSecurityEnablerConfiguration { 6 7} - 在 @EnableWebSecurity 中会 import 类 { WebSecurityConfiguration.class, SpringWebMvcImportSelector.class, OAuth2ImportSelector.class, HttpSecurityConfiguration.class }) 和 @EnableGlobalAuthentication
- 在 WebSecurityConfiguration 会注册上 springSecurityFilterChain
- 在 HttpSecurityConfiguration 中会配置基本的 httpSecurity, 如, AuthenticationManager, csrf, exceptionHandling, headers, sessionManagement, login, logout 等的配置
- 在 Spring 配置中获得 Spring Bean
HttpSecurity实例,并配置哪些 API 受保护,哪些不用。然后把自己的 Filter(如上面的 JwtTokenFilter) 实例加到 springSecurityFilterChain 中 AuthorizationFilter 前面。关于 Spring Security 可能注册的 Filter 及顺序请参考类FilterOrderRegistration - 在自己的 Filter 类实现(如上面的 JwtTokenFilter), 因请求状况是否往 SecurityContextHolder 放一个 Authentication(authenticated=true) 的实例
- 在 AuthorizationFilter 的 AuthorizationManager 会综合当前的配置,SecurityContextHolder 中的 Authentication 进行裁决当前 API 是否准许当前请求访问。这一步也包括验证 Authentication 所携带的 Collection<GrantedAuthority> 是否可访问当前 API。也就是说包含 Authentication 和 Authorization 两个校验。
- 最后由 ExceptionTranslationFilter 依照 AuthorizationFilter 的鉴权结果,针对 Authentication 和 Authorization 的失败分别抛出 AuthenticationException 和 AccessDeniedException, 对应为 401(Unauthorized -- 通常为没有或无效的 Authentication), 403(Forbidden -- 有 Authentication, 但 Authentication 无相应的权限)
401 状态码更应该是 Unauthenticated, 403 状态码叫做 Unauthorized 才更匹配
Spring 启动的时候,在 DefaultSecurityFilterChain 类中有 logger.info 输出当前已注册可用的所有 filter, 类似如下输出(为阅读方便,对输出进行换行显示)
Will secure any request with [
org.springframework.security.web.session.DisableEncodeUrlFilter@3f5f79d8,
org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@5dc1597f,
org.springframework.security.web.context.SecurityContextPersistenceFilter@28b4b10e,
org.springframework.security.web.header.HeaderWriterFilter@3ecbfba1,
org.springframework.security.web.authentication.logout.LogoutFilter@628f0936,
org.springframework.security.web.savedrequest.RequestCacheAwareFilter@5c945ee7,
org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@3ed87b6e,
org.springframework.security.web.authentication.AnonymousAuthenticationFilter@11896af6,
org.springframework.security.web.session.SessionManagementFilter@1185b0b7,
org.springframework.security.web.access.ExceptionTranslationFilter@da4c5cb,
com.mstar.ade.wfeweb.config.JwtTokenFilter@6b6e2c83,
org.springframework.security.web.access.intercept.AuthorizationFilter@530d40b4]
Authentication + Authorization
Authentication 和 Authorization 分别代表着用户有效用户和权限鉴定。就个人理解而言,无 token, authenticated=false 的 token 都应该返回 401(前面的 SecurityConfig 中配置的 httpSecurity 只是要求 authenticated
1httpSecurity.authorizeHttpRequests().antMatchers("/public-api").permitAll()
2 .anyRequest().authenticated();除了 authenticated 以外,我们还可以进一步要求 JWT 中应有指定的 role 才能访问,即进行 Authorization 的验证。那么在 SecurityConfig 中的配置为
1@Configuration
2public class SecurityConfig {
3
4 @Bean
5 public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception{
6 httpSecurity.authorizeHttpRequests().antMatchers("/public-api").permitAll()
7 .anyRequest().hasRole("contributors");
8
9 // 未提供 token 是返回 401 而不是 403
10 httpSecurity.exceptionHandling().authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED));
11
12 httpSecurity.addFilterBefore(new JwtTokenFilter(), AuthorizationFilter.class);
13 return httpSecurity.build();
14 }
15}注[2023-03-16]: 同样的在 Spring Security 5.8 后要把 antMatchers() 改为 requestMatchers()
hasRole("contributor") 其实是检验的 hashAuthority("ROLE_contributor")。上面也可以用 hashAuthority("xxx") 方式来配置1anyRequest().hasAuthority("xxx")多个 role 中的一个可调用方法
hasAnyRole("role1", "role2", ...) 或 hashAnyAuthority("role1", "role2", ...)在 JwtAuthentication 中我们需要覆盖 getAuthorities() 方法
1@Override
2public Collection<GrantedAuthority> getAuthorities() {
3 return Optional.ofNullable(decodedJWT.getClaim("roles").asList(String.class))
4 .map(roles -> roles.stream().map(role -> (GrantedAuthority) new SimpleGrantedAuthority(role))
5 .collect(toList()))
6 .orElse(emptyList());
7} 用页面 https://jwt.io/ 在 PAYLOAD 中加上
1"roles" ["ROLE_contributors"]后生成 JWT token 来访问
$ curl -i -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjIwMTYyMzkwMjIsInJvbGVzIjpbIlJPTEVfY29udHJpYnV0b3JzIl19.uLGZ8UoLpMjCaVFUYEMpK1gFbSLyF5hEEBKTgYEJpos" -i http://localhost:8080/private-api换一个 roles
HTTP/1.1 200
1"roles": ["ROLES_users"]$ curl -i -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjIwMTYyMzkwMjIsInJvbGVzIjpbIlJPTEVfdXNlcnMiXX0.hv7GmocUdqvLJc7t42MPDCPSELcv3fMpQU5lm-W4csg" -i http://localhost:8080/private-api以上产生的两个 Authentication 的 authenticated 都是 true, 但后一个无相应的 role
HTTP/1.1 403
ROLE_contributors, 所以是返回的是 403。再加一个测试,如果转一个已过期,但含有 ROLE_contributors 的 token
$ curl -i -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjEwMTYyMzkwMjIsInJvbGVzIjpbIlJPTEVfY29udHJpYnV0b3JzIl19.j7ZrmNykVmWX1n0SXJiY7Y7HkCv0pjWa1ju3mxYQWFk" -i http://localhost:8080/private-api也是 403
HTTP/1.1 403
如果希望在有 token 但 token 的 authenticated=false 时返回 403 就需要定制 AuthorizationFilter, 或它的 AuthorizationManager(RequestMatcherDelegatingAuthorizationManager), 在 AuthorityAuthorizationManager 类中
1 @Override
2 public AuthorizationDecision check(Supplier<Authentication> authentication, T object) {
3 boolean granted = isGranted(authentication.get());
4 return new AuthorityAuthorizationDecision(granted, this.authorities);
5 }
6
7 private boolean isGranted(Authentication authentication) {
8 return authentication != null && authentication.isAuthenticated() && isAuthorized(authentication);
9 }当 isAuthenticated() 为 false 时抛出
AuthenticationException, 继而在 ExceptionTranslationFilter 中能送出 401 状态码而不是 403。或者在 JwtTokenFilter 中也可检验 Token 是否过期,来源 issuer 是否合法,是否合法的客户端等来决定是否往 SecurityContextHolder 中放置 Authentication 实例,而不是设置它的 authenticated 为 false,如此也能获得预期的 401。几种配置 Spring Security 的方式
WebSecurityConfigurerAdapter(spring-security 5.7.x 开始不推荐使用)
建议使用后面两种方式示例
1@Configuration
2public class AuthConfig extends WebSecurityConfigurerAdapter {
3
4 @Override
5 protected void configure(HttpSecurity http) throws Exception {
6 http.csrf().disable();
7 http
8 .authorizeRequests()
9 .antMatchers("/callback", "/login", "/").permitAll()
10 .anyRequest().authenticated()
11 .and()
12 .formLogin()
13 .loginPage("/login")
14 .and()
15 .logout().logoutSuccessHandler(logoutSuccessHandler()).permitAll();
16 }
17}注[2023-03-16]: 同样的在 Spring Security 5.8 后要把 antMatchers() 改为 requestMatchers()
SecurityFilterBean
1@Bean
2public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception{
3 httpSecurity.authorizeHttpRequests().antMatchers("/public-api").permitAll()
4 .anyRequest().authenticated();
5
6 httpSecurity.addFilterBefore(new JwtTokenFilter(), AuthorizationFilter.class);
7 return httpSecurity.build();
8}注[2023-03-16]: 同样的在 Spring Security 5.8 后要把 antMatchers() 改为 requestMatchers()
WebSecurityCustomizer
1@Bean
2public WebSecurityCustomizer ignoringCustomizer() {
3 return (webSecurity) -> webSecurity.ignoring().antMatchers("/ignore1", "/ignore2");
4}最后总结
完整演示项目代码已上传至 https://github.com/yabqiu/spring-security-jwt. 实现中的三个步骤- Spring Security config 配置访问规则,并加上一个 JwtTokenFilter 放到正确的位置上
- JwtTokenFilter 根据请求头决定往 SecurityContextHolder 中放一个 Authentication 对象。如果是 authenticated() 留心 isAuthenticate() 是否为 true; 如果是 hasRole(), hashAuthority() 等就要看 getAuthorities() 的值是否包含相应的 ROLE_<role_name> 或 authority 名称。
- 在 Controller 方法中通过 @AuthenticationPrincipal 注解的参数可以获得 Authentication 对象中的 principal 备用
当前 Spring Security 直接能实现的是对受保护 API 的访问,不含 Token 返回 401, 有 Token 但 authenticated() 为 false, 或无相应的 role/authority 权限时都返回 403。可覆盖 AuthorizationManager 实现或修改 JwtTokenFilter 来获得过期 token 返回 401。
其余剩下的就是 Spring Security 的事情了,能作更多扩展的地方大约就是在自定义的 Filter 中。如果在 Controller 中不需要关心 Authentication 的话就只需上面两步就应用上了 Spring Security。
自定义的 JwtTokenFilter 可以加在 SecurityContextPersistenceFilter 之后
1httpSecurity.addFilterAfter(new JwtTokenFilter(), SecurityContextPersistenceFilter.class);编译器可能会提示 SecurityContextPersistenceFilter 不推荐使用,请用
SecurityContextHolderFilter, 别理它。如果你真老老实实听它的话的话,上面换成 SecurityContextHolderFilter, 事情就坏了,有效有权限的 token 都会得到 401 了。Spring Security 只是建议你不要用 SecurityContextPersistenceFilter, 但它自己还在偷偷的使用。链接:
- Spring Boot Security Auto-Configuration
- Spring Security With Auth0
- Spring Boot 2 JWT Authentication with Spring Security
- Securing a Web Application
补充[2023-03-16]: 当启用了 SpringSecurity 之后,在访问不存在的 API 或资源时不再是返回 404 NotFound, 而是 403 Forbidden 或 401 Unauthorized(如果配置了 httpSecurity.exceptionHandling().authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)); 的话) 错误响应。这也是合理的,反正是没权限访问,没必要明确告诉你 API 或资源是否真的不存在,不想给你看的东西别乱看就对了。
如果仍然想准确区分不存在的 API 或资源返回 404 NotFound, 存在但没权限访问的返回 403 Forbidden(或 401 Unauthorized) 就在 authenticationEntryPoint 和 accessDeniedHandler 中先检查资源不存在 response.sendError(404), 存在的资源执行默认行为。
1httpSecurity.exceptionHandling()
2 .authenticationEntryPoint(new MyAuthenticationEntryPoint())
3 .accessDeniedHandler(new MyAccessDeniedHandler());MyAuthencationEntryPoint 的代码大意是
1public class MyAuthenticationEntryPoint extends Http403ForbiddenEntryPoint {
2
3 @Override
4 public void commence(HttpServletRequest request, HttpServletResponse response,
5 AuthenticationException authException) throws IOException {
6 if (endpointNotExist(request)) {
7 response.sendError(HttpServletResponse.SC_NOT_FOUND, "Resource not found");
8 } else {
9 super.commence(request, response, authException);
10 }
11 }
12}MyAccessDeniedHandler 的代码大致是
1public class MyAccessDeniedHandler extends AccessDeniedHandlerImpl {
2
3 @Override
4 public void handle(HttpServletRequest request, HttpServletResponse response,
5 AccessDeniedException accessDeniedException) throws IOException, ServletException {
6
7 if (endpointNotExist(request)) {
8 response.sendError(HttpServletResponse.SC_NOT_FOUND, "Resource not found");
9 } else {
10 super.handle(request, response, accessDeniedException);
11 }
12 }
13}而我们要实现的就是方法 endpointNotExist(request), 如何确定 endpoint 是不存的。参考:Receiving 403 instead of 404 when calling non existing endpoint 永久链接 https://yanbin.blog/springboot-security-jwt-token-how-to-abcs/, 来自 隔叶黄莺 Yanbin's Blog
[版权声明]
本文采用 署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0) 进行许可。