理解 Spring Boot Security + JWT Token 的简单应用

项目中有用到 Spring Security 来控制 API 的访问权限,但对于配置应用它基本上是照葫芦画瓢。至于为什么要调用方法

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
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
我们表面上看只不过引入了一个依赖而已,然而事情正在起变化,这才使得现有的 API 都不能访问了

为了 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
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
现在可以访问了,同时也注意到与没有引入 Spring Security 依赖前相比,响应头里还是被塞进了不少东西

假如我们还有另一个 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]
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]]
Spring Security 会把 Request 的实例换了. 对比未引入 Spring Security 依赖前的 request
publicApi: org.apache.catalina.connector.RequestFacade@2658d732
SecurityContextHolder 是来自 Spring Security 依赖中的类,所以只有引入了才有。对于 permitAll() 的 API, 在 SecurityContextHolder 中会包含一个 Authenticated=true 的 AnonymousAuthenticationToken。这时候我们有两种方式可获得 AuthenticationToken
  1. SecurityContextHolderAwareRequestWrapper.getAuthentication()
  2. 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
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
没问题,只要有 authenticated=true 的 Authentication 实例就行。那现在来测试一下 authenticated=false 的 Authentication 实例是怎么样,在 JwtTokenFilter 中调用 setAuthenticated(false)
1Authentication authentication = new TestingAuthenticationToken("principal", "credentials");
2authentication.setAuthenticated(false);
3SecurityContextHolder.getContext().setAuthentication(authentication); 

重启应用再测试 /private-api
$ curl -i http://localhost:8080/private-api
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
现在我们找到了控制 API 是否能访问的开关,就是 SecurityContextHolder 中的 Authentication.isAuthenticated() 是否为 true, true 则允许,返之则禁止访问。当然对当前 API 的具体权限还要借助于 Authentication 的 List<GrantedAuthority> authorities 来控制,这是另一个话题。

集成 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
HTTP/1.1 200 this is a private api
无效 JWT token(已过期)
$ curl -i -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiSm9obiBEb2UiLCJleHAiOjEwMTYyMzkwMjJ9._SH20hSVSJoc8HZcbjAABoajiULb6-3taaMp6oHc2Dk" -i http://localhost:8080/private-api
HTTP/1.1 403



或者无 token
$ curl -i http://localhost:8080/private-api
HTTP/1.1 401



在 Controller 方法中获 Authentication

由前面知道,在  Controller 方法有两种方法能获得当前的 Authentication
  1. SecurityContextHolderAwareRequestWrapper.getAuthentication()
  2. 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
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=[]]
有效 token 访问 public API
$ 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
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=[]]
无效 token 访问 public API
$ 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
publicApi request: SecurityContextHolderAwareRequestWrapper[ org.springframework.security.web.header.HeaderWriterFilter$HeaderWriterRequest@6e396cf8]
publicApi authentication: null
不带 token 访问 public API
$ curl -i http://localhost:8080/public-api
HTTP/1.1 200 this is a public api
服务端输出
publicApi decodedJWT:null
publicApi request: SecurityContextHolderAwareRequestWrapper[ org.springframework.security.web.header.HeaderWriterFilter$HeaderWriterRequest@2e31962f]
publicApi authentication: null
从上面看出,只要 JwtTokerFiler 中往 SecurityContextHolder 放了什么就能直接取到,而不管 API 是否受 Spring Security 的保护。

更深入理解 Spring Security 的工作原理

当 spring-boot-starter-security 引入后
  1. 在 spring-boot-autoconfigure 包中的 SecurityAutoConfiguration(@AutoConfiguration 注解的类) import 了  SpringBootWebSecurityConfiguration(同样是 spring-boot-autoconfigure 包中的类)
  2. 在 SpringBootWebSecurityConfiguration 中,因为发现存在 EnableWebSecurity 类且 Spring Bean "springSecurityFilterChain" 还 没有,就会启用 @EnableWebSecurity
    1@Configuration(proxyBeanMethods = false)
    2@ConditionalOnMissingBean(name = BeanIds.SPRING_SECURITY_FILTER_CHAIN)
    3@ConditionalOnClass(EnableWebSecurity.class)
    4@EnableWebSecurity
    5static class WebSecurityEnablerConfiguration {
    6
    7}
    这就是为什么只要引入 spring-boot-autoconfigure, 而无须手工加上 @EnableWebSecurity
  3. 在 @EnableWebSecurity 中会 import 类 { WebSecurityConfiguration.class, SpringWebMvcImportSelector.class, OAuth2ImportSelector.class, HttpSecurityConfiguration.class }) 和 @EnableGlobalAuthentication
  4. 在 WebSecurityConfiguration 会注册上 springSecurityFilterChain
  5. 在 HttpSecurityConfiguration 中会配置基本的 httpSecurity, 如, AuthenticationManager, csrf, exceptionHandling, headers, sessionManagement, login, logout 等的配置
  6. 在 Spring 配置中获得 Spring Bean HttpSecurity 实例,并配置哪些 API 受保护,哪些不用。然后把自己的 Filter(如上面的 JwtTokenFilter) 实例加到 springSecurityFilterChain 中 AuthorizationFilter 前面。关于 Spring Security 可能注册的 Filter 及顺序请参考类FilterOrderRegistration
  7. 在自己的 Filter 类实现(如上面的 JwtTokenFilter), 因请求状况是否往 SecurityContextHolder 放一个 Authentication(authenticated=true) 的实例
  8. 在 AuthorizationFilter 的 AuthorizationManager 会综合当前的配置,SecurityContextHolder 中的 Authentication 进行裁决当前 API 是否准许当前请求访问。这一步也包括验证 Authentication 所携带的 Collection<GrantedAuthority> 是否可访问当前 API。也就是说包含 Authentication 和 Authorization 两个校验。
  9. 最后由 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(UnauthorizedUnauthenticated), 而 token 有效但无相应角色返回 403(Forbidden)

前面的 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
HTTP/1.1 200
换一个 roles
1"roles": ["ROLES_users"]
$ curl -i -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjIwMTYyMzkwMjIsInJvbGVzIjpbIlJPTEVfdXNlcnMiXX0.hv7GmocUdqvLJc7t42MPDCPSELcv3fMpQU5lm-W4csg" -i http://localhost:8080/private-api
HTTP/1.1 403
以上产生的两个 Authentication 的  authenticated 都是 true, 但后一个无相应的 role ROLE_contributors, 所以是返回的是 403。

再加一个测试,如果转一个已过期,但含有 ROLE_contributors 的 token
$ curl -i -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjEwMTYyMzkwMjIsInJvbGVzIjpbIlJPTEVfY29udHJpYnV0b3JzIl19.j7ZrmNykVmWX1n0SXJiY7Y7HkCv0pjWa1ju3mxYQWFk" -i http://localhost:8080/private-api
HTTP/1.1 403
也是 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. 实现中的三个步骤
  1. Spring Security config 配置访问规则,并加上一个 JwtTokenFilter 放到正确的位置上
  2. JwtTokenFilter 根据请求头决定往 SecurityContextHolder 中放一个 Authentication 对象。如果是  authenticated() 留心 isAuthenticate() 是否为 true; 如果是 hasRole(), hashAuthority() 等就要看 getAuthorities() 的值是否包含相应的 ROLE_<role_name> 或 authority 名称。
  3. 在 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, 但它自己还在偷偷的使用。

链接:
  1. Spring Boot Security Auto-Configuration
  2. Spring Security With Auth0
  3. Spring Boot 2 JWT Authentication with Spring Security
  4. 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) 进行许可。