理解 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 依赖

然后加上一个 WebController 类

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 依赖

然后什么也不做,重新启动应用,再来访问该 /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 条目

而 SecurityAutoConfiguration 是被加了 @AutoConfiguration 注解的,并引入了 SpringBootWebSecurityConfiguration 类

而在 SpringBootWebSecurityConfiguration 中有

所以只要引入了 spring-boot-starter-security 依赖,无论写不写 @EnableWebSecurity, 它都是自动启用的。

我们看到自己写的 API 被保护了起来,但默认时对静态资源如 /css/**, /js/**, /images/**, /webjars/**, /**/favicon.ico 和错误页面是直接放行的。

加上 Spring Security 配置文件

要想让现有的 /public-api 能被自由的访问,需要添加一个配置来告诉 Spring Security,所以创建一个 Java 类 SecurityConfig

对于 /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 的语句

这时候它的输出为

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

注:我们通常把这个自定义的 Filter 注册到 UsernamePasswordAuthenticationFilter 之前,特别是我们后面在 JwtTokenFilter 中直接创建 UsernamePasswordAuthentication 实例,放到 SecurityContextHolder 中的情况。如

如果不希望 Spring 帮我们管理 Session 的话,在其中再加上

再创建一个 JwtTokenFilter, 这一步只是往 SecurityContextHolder 中放一个 authenticated 为 true 的 Authentication, 随意找一个 Authentication 的实现类就行

下一步,为测试受保护的 API, 在 WebController 中再添加一个 /private-api, 由于它不在 permitAll() 列表中,也不是默认开放的,所以它要受到 Spring Security 的保护。

重启 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)

重启应用再测试 /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 类

然后再回到 JwtTokenFilter 中去按条件构建 JwtAuthentication 实例,设置它的 authenticated 属性,并放到  SecurityContextHolder 中

修改 JwtTokenFilter

注:JWT 的处理库是用的 com.auth0.java-jwt 包,依赖配置

测试(当然别忘了重启应用)

注:为节省篇幅,后面的 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 方法

进行三个测试,看客户端和服务端各自的输出

有效 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

    这就是为什么只要引入 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

除了 authenticated 以外,我们还可以进一步要求 JWT 中应有指定的 role 才能访问,即进行 Authorization 的验证。那么在 SecurityConfig 中的配置为

hasRole("contributor") 其实是检验的 hashAuthority("ROLE_contributor")。上面也可以用 hashAuthority("xxx") 方式来配置

多个 role 中的一个可调用方法 hasAnyRole("role1", "role2", ...)hashAnyAuthority("role1", "role2", ...)

在 JwtAuthentication 中我们需要覆盖 getAuthorities() 方法

用页面 https://jwt.io/ 在 PAYLOAD 中加上

后生成 JWT token 来访问

$ curl -i -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjIwMTYyMzkwMjIsInJvbGVzIjpbIlJPTEVfY29udHJpYnV0b3JzIl19.uLGZ8UoLpMjCaVFUYEMpK1gFbSLyF5hEEBKTgYEJpos" -i http://localhost:8080/private-api
HTTP/1.1 200

换一个 roles

$ 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 类中

当 isAuthenticated() 为 false 时抛出 AuthenticationException, 继而在 ExceptionTranslationFilter 中能送出 401 状态码而不是 403。或者在 JwtTokenFilter 中也可检验 Token 是否过期,来源 issuer 是否合法,是否合法的客户端等来决定是否往 SecurityContextHolder 中放置 Authentication 实例,而不是设置它的 authenticated 为 false,如此也能获得预期的 401。

几种配置 Spring Security 的方式

WebSecurityConfigurerAdapter(spring-security 5.7.x 开始不推荐使用)

建议使用后面两种方式

示例

SecurityFilterBean

WebSecurityCustomizer

最后总结

完整演示项目代码已上传至 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 之后

编译器可能会提示 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

本文链接 https://yanbin.blog/springboot-security-jwt-token-how-to-abcs/, 来自 隔叶黄莺 Yanbin Blog

[版权声明] Creative Commons License 本文采用 署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0) 进行许可。

guest

1 Comment
Inline Feedbacks
View all comments
trackback

[…] 它来从 queryString 或 requestHeader 中获得 productId。写作本文的起因是在上一篇 理解 Spring Boot Security + JWT Token 的简单应用 里, JwtTokenFilter 住 SecurityContextFilter 放一个 Authentication 实例, 在 Controller […]