实践JWT/Spring Boot技术栈

情境

搭建一个API,要求RESTful,安全性方面stateless授权。

JSON Web Token的原理前文已经介绍过,这里主要介绍如何将JWT与Spring Security进行整合鉴权的方式与时机以及如何设置过期失效

Spring Security基本配置

1
2
3
4
5
6
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
// ...
}

@EnableGlobalMethodSecurity中可以启用更加灵活的鉴权设置如:

  • prePostEnabled:决定是否启用注解@PreAuthorize@PostAuthorize
  • secureEnabled:决定是否启用注解@Secured
  • jsr250Enabled:决定是否启用JSR-250的注解@RolesAllowed

将用户(管理员)账户的Service接口注入:

1
2
3
4
5
6
private final AdminService adminService;
@Autowired
public SecurityConfiguration(AdminService adminService) {
this.adminService = adminService;
}

这一步相当于提供用于鉴权的数据源。

注意这里的接口必须满足UserDetailsService,你可以在org.springframework.security.core.userdetails中找到给接口定义,此接口用于仅根据用户名定位全部的用户信息(即UserDetails类,包含用户名、密码、权限、账户凭证的时效性以及账户本身的有效性等等)。在本例中通过public interface AdminService extends UserDetailsService直接将两者整合在一起。

同样,所需的UserDetails也本例中通过实体类public class Admin implements UserDetails整合在了一起。

1
2
3
4
5
6
7
8
9
10
11
12
@SuppressWarnings("deprecation")
@Bean
public PasswordEncoder md5PasswordEncoder() {
return new Md5PasswordEncoder();
}
@Autowired
public void configureAuthentication(AuthenticationManagerBuilder authenticationManagerBuilder) throws Exception {
authenticationManagerBuilder
.userDetailsService(this.adminService)
.passwordEncoder(new Md5PasswordEncoder());
}

提供密码的散列方式为MD5,完成AuthenticationManager的配置,这样在登录控制器中就能够直接向authenticationManager实例传入请求中的用户名与密码(实际上是经由UsernamePasswordAuthenticationToken类包装的信息),然后由其自动根据数据源中的用户名与密码进行认证,失败则会抛出AuthenticationException异常。

接下来就是配置需要拦截的请求路径:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers(HttpMethod.POST, "/admin/public/**").permitAll()
.anyRequest().authenticated()
.and()
.headers().cacheControl().disable()
.and()
.addFilterBefore(authenticationTokenFilterBean(), UsernamePasswordAuthenticationFilter.class);
}

在RESTful服务中无需对CSRF的防护,同时将Spring Security内部的Session创建策略设置为无状态(stateless)。

由于登录时没有token做凭据,因此关闭对登录接口的保护。

Spring中由于没有替换Filter一说,因此我们将自定义的JwtAuthenticationTokenFilter设在用于检验表单登录的UsernamePasswordAuthenticationFilter之前,表示启用前者而弃用后者,否则直接设置Filter会引起IllegalArgumentException

注入自定义Filter:

1
2
3
4
@Bean
public JwtAuthenticationTokenFilter authenticationTokenFilterBean() throws Exception {
return new JwtAuthenticationTokenFilter();
}

JWT与Spring Security整合

现在直接贴出自定义的Filter:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
@Autowired
private JwtAuthenticationService jwtAuthenticationService;
@Autowired
private UserDetailsService userDetailsService;
@Autowired
public JwtAuthenticationTokenFilter() {
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String username = jwtAuthenticationService.getUsernameFromRequest(request);
if (username != null &&
jwtAuthenticationService.validateAndRefreshTokenExpiration(request, response) &&
SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
userDetails.getUsername(), userDetails.getPassword(), userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
}

可以看见其继承自OncePerRequestFilter,源码部分注释:

Filter base class that aims to guarantee a single execution per request dispatch, on any servlet container. It provides a {@link #doFilterInternal} method with HttpServletRequest and HttpServletResponse arguments.

有了这些方法我们就可以轻松地拦截每一次HTTP请求进行验证并且从Request中取元信息并给Response设置元信息。

使用jwtAuthenticationService我们可以鉴别每一次请求Header中token的合法性,并从中提取需要的信息如用户名(sub)、签发时间(iat)等等,用于给当前请求设置Spring Security Context,以便后续逻辑使用。

在构造UsernamePasswordAuthenticationToken实例时需要注意,此处setAuthentication()时已经是鉴权成功了,将在Spring Security框架内部转换出一个session,并且提供权限集用于请求到达控制器时根据用户语境进行进一步的权限筛选,而我们在登录控制器中使用的构造方法是仅仅是用于签发token,安全性上并不需要也并不保证本次请求的后续逻辑拥有鉴权成功的语境,其源码注释非常清晰:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/**
* This constructor can be safely used by any code that wishes to create a
* <code>UsernamePasswordAuthenticationToken</code>, as the {@link #isAuthenticated()}
* will return <code>false</code>.
*
*/
public UsernamePasswordAuthenticationToken(Object principal, Object credentials) {
super(null);
this.principal = principal;
this.credentials = credentials;
setAuthenticated(false);
}
/**
* This constructor should only be used by <code>AuthenticationManager</code> or
* <code>AuthenticationProvider</code> implementations that are satisfied with
* producing a trusted (i.e. {@link #isAuthenticated()} = <code>true</code>)
* authentication token.
*
* @param principal
* @param credentials
* @param authorities
*/
public UsernamePasswordAuthenticationToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
this.credentials = credentials;
super.setAuthenticated(true); // must use super, as we override
}

登录控制器

代码量不大直接贴:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@RequestMapping(method = RequestMethod.POST)
public GeneralResponse login(@RequestBody AdminLoginRequest ar, HttpServletResponse httpServletResponse) {
GeneralResponse gr = new GeneralResponse();
try {
UsernamePasswordAuthenticationToken upat =
new UsernamePasswordAuthenticationToken(
ar.getUsername(),
ar.getPassword()
);
Authentication authentication = authenticationManager.authenticate(upat);
SecurityContextHolder.getContext().setAuthentication(authentication);
String jwt = jwtAuthenticationService.generateToken(ar.getUsername());
gr.setStatusCode(StatusCode.OK);
httpServletResponse.setHeader(jwtAuthenticationService.getTokenHeader(), jwt);
} catch (Exception e) {
gr.setStatusCode(StatusCode.ER);
gr.setMessage(e.getMessage());
gr.setObject(e);
}
return gr;
}

可以看到这里的UsernamePasswordAuthenticationToken实例化用了与Filter中不同的构造方法,原因已表。

首先包装用户名和密码(生成upat),然后交由authenticationManager认证(生成authentication),最后注入语境(SecurityContextHolder.getContext().setAuthentication(...))。

完成上述逻辑后,签发token,并由本次HttpServletResponse的实例头部(Header)返回。

下次用户执行请求时只需要在请求头部带有此token即可。

Token时限与刷新

在Filter中调用了JwtAuthenticationService中的validateAndRefreshTokenExpiration(...)方法,该方法用于验证合法token是否过期并自动刷新一个token给用户。

为了简洁起见,本例中只要在时限内的请求,token都会被自动刷新。在现实情况下我们可以设置需要刷新的时间周期来减少刷新token的频率。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Override
public boolean validateAndRefreshTokenExpiration(
HttpServletRequest httpServletRequest,
HttpServletResponse httpServletResponse
) {
Jws<Claims> claims = parseToken(httpServletRequest);
if (claims != null) {
Date issuedAtDate = claims.getBody().getIssuedAt();
if (!isTokenExpired(issuedAtDate)) {
httpServletResponse.setHeader(
tokenHeader,
generateToken(claims.getBody().getSubject())
);
return true;
}
}
return false;
}

通过计算时间差可以得出token是否已过期(即客户长时间在页面内没有操作),本例中只要超过10分钟,请求就已经403,需要重新登录,可以用于敏感权限的用户如管理员等。