之前写过一次 SpringSecurity JWT 验证相关的博客,但是实现得有些潦草。
于是本篇将对实现方式和代码逻辑进行一些优化。
依赖导入
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
<!--解决 javax.xml.bind.DatatypeConverter (JDK9 之后此包已被删除)-->
<dependency>
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>
<version>2.3.1</version>
</dependency>
准备工作
首先需要创建一个用于生成JWT的工具类:
public final class JwtUtil {
private JwtUtil() {
}
/**
* 构建 Jwt Token
* @param authorities 用户所有的权限
* @param authentication Authentication
* @param expiration Token 过期时间 单位毫秒
* @return Token 字符串
*/
public static String buildJwtToken(String signKey, Collection<? extends GrantedAuthority> authorities, Authentication authentication, long expiration) {
StringBuilder authorityStr = new StringBuilder();
for (GrantedAuthority authority : authorities) {
authorityStr.append(authority).append(",");
}
return Jwts.builder()
.claim("authorities", authorityStr)
.setSubject(authentication.getName())
.setExpiration(new Date(System.currentTimeMillis() + expiration))
.signWith(SignatureAlgorithm.HS512, signKey)
.compact();
}
}
Security 配置
基本的配置没什么好说的,注释里已经写的很清楚了。
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
// 需要放行的接口列表
List<RequestMatcher> requestMatchers = new ArrayList<>();
requestMatchers.add(new AntPathRequestMatcher("/login", HttpMethod.POST.name()));
// 放行接口
http.authorizeHttpRequests(registry -> {
requestMatchers.forEach(e -> registry.requestMatchers(e).permitAll());
registry.anyRequest().authenticated();
});
// JWT 验证无需 Session
http.sessionManagement(it -> it.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
// 禁用 BasicAuth
http.httpBasic(AbstractHttpConfigurer::disable);
// 禁用 CSRF
http.csrf(AbstractHttpConfigurer::disable);
// 禁用跨域
http.cors(AbstractHttpConfigurer::disable);
// TODO: 自定义过滤链配置
return http.build();
}
/**
* 由 AuthenticationConfiguration 得到 AuthenticationManager
* @param authenticationConfiguration AuthenticationConfiguration
* @return AuthenticationManager
*/
@Bean
@SneakyThrows
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) {
return authenticationConfiguration.getAuthenticationManager();
}
/**
* 使用 BCryptPasswordEncoder 作为密码加密方式
* @return BCryptPasswordEncoder
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
登录过滤器
接下来解决登录的问题,这次我们继承 AbstractAuthenticationProcessingFilter
来处理登录请求。
基本思路是:让 SpringSecurity 的 UserDetailsService 来验证用户密码是否正确,然后通过这个过滤器生成一个token令牌,再将结果写回给接口。
public class CustomLoginFilter extends AbstractAuthenticationProcessingFilter {
public CustomLoginFilter(String defaultFilterProcessesUrl, AuthenticationManager authenticationManager) {
super(defaultFilterProcessesUrl, authenticationManager);
}
/**
* 用户验证逻辑
* @param request HttpServletRequest
* @param response HttpServletResponse
* @return Authentication
* @throws AuthenticationException
*/
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
String username = request.getParameter("username");
String password = request.getParameter("password");
return getAuthenticationManager().authenticate(new UsernamePasswordAuthenticationToken(username, password));
}
/**
* 验证成功后的逻辑 成功则返回 jwt token
* @param request HttpServletRequest
* @param response HttpServletResponse
* @param chain FilterChain
* @param authResult Authentication
* @throws IOException
*/
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException {
Collection<? extends GrantedAuthority> authorities = authResult.getAuthorities();
response.setContentType("application/json;charset=utf-8");
String userToken = JwtUtil.buildJwtToken("jwtSignKey", authorities, authResult, 15 * 60 * 1000);
response.getWriter().write(userToken);
response.getWriter().flush();
response.getWriter().close();
}
/**
* 验证失败后的逻辑
* @param request HttpServletRequest
* @param response HttpServletResponse
* @param failed AuthenticationException
* @throws IOException
*/
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException {
response.setContentType("application/json;charset=utf-8");
response.getWriter().write("Username or password incorrect");
response.getWriter().flush();
response.getWriter().close();
}
}
attemptAuthentication
在这个方法中,前端调用 /login
接口传来的用户名密码可以通过 HttpServletRequest 来获取到,然后构建一个 UsernamePasswordAuthenticationToken 交给验证管理器进行验证。
如果经过 UserDetailsService 获取到了用户并且密码正确,则调用下面的 successfulAuthentication()
方法,失败则调用 unsuccessfulAuthentication
() 方法
接口验证过滤器
接口验证很简单,只要拦截每一个请求,并且进行token验证即可,所以使用 GenericFilterBean
即可。
先把基本的框架搭建起来吧:
public class AuthorizationFilter extends GenericFilterBean {
private final Iterable<RequestMatcher> permitAllMatchers;
public AuthorizationFilter(Iterable<RequestMatcher> permitAllMatchers) {
this.permitAllMatchers = permitAllMatchers;
}
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
// 此过滤链在 FilterSecurityInterceptor 之前 因此检查 Token 前需要先排除已放行的路径
for (RequestMatcher matcher : permitAllMatchers) {
if (matcher.matches(request)) {
filterChain.doFilter(request, response);
return;
}
}
}
}
这里我添加了一个构造函数用于接收 RequestMatcher
对象,因为这个过滤链最终会被添加到 FilterSecurityInterceptor
之前,而之前在 Security 配置中放行的接口会失效。
因此只需要经过这个过滤链时再判断一下,如果符合条件就直接放行。
接下来继续把Token验证的逻辑写完:
public class AuthorizationFilter extends GenericFilterBean {
private final Iterable<RequestMatcher> permitAllMatchers;
public AuthorizationFilter(Iterable<RequestMatcher> permitAllMatchers) {
this.permitAllMatchers = permitAllMatchers;
}
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
// 此过滤链在 FilterSecurityInterceptor 之前 因此检查 Token 前需要先排除已放行的路径
for (RequestMatcher matcher : permitAllMatchers) {
if (matcher.matches(request)) {
filterChain.doFilter(request, response);
return;
}
}
String tokenStr = request.getHeader("Authorization");
try {
if (tokenStr == null) {
throw new IllegalStateException("Token invalid");
}
Claims claims = Jwts.parser()
// 必须与 CustomLoginFilter 中的 key 保持一致
.setSigningKey("jwtSignKey")
.parseClaimsJws(tokenStr.replace("Bearer",""))
.getBody();
String username = claims.getSubject();
List<? extends GrantedAuthority> authorities = AuthorityUtils.commaSeparatedStringToAuthorityList((String) claims.get("authorities"));
UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(username, null, authorities);
SecurityContextHolder.getContext().setAuthentication(token);
filterChain.doFilter(request, servletResponse);
} catch (SignatureException | ExpiredJwtException | MalformedJwtException e) {
// 当 Token 无法被解析时抛出 SignatureException 异常
// JWT signature does not match locally computed signature. JWT validity cannot be asserted and should not be trusted.
// 当 Token 格式不正确时抛出 MalformedJwtException 异常
// JWT strings must contain exactly 2 period characters. Found: 0
// e.printStackTrace();
servletResponse.setContentType("application/json;charset=utf-8");
// 将请求失败的信息写回给接口
servletResponse.getWriter().write("Invalid token");
servletResponse.getWriter().flush();
servletResponse.getWriter().close();
}
}
}
添加过滤器
// 由自定义的登录验证过滤器返回 jwt token
http.addFilterBefore(
new CustomLoginFilter("/login", authenticationConfiguration.getAuthenticationManager()),
UsernamePasswordAuthenticationFilter.class
);
// 验证用户 Token 是否有效
http.addFilterBefore(new AuthorizationFilter(requestMatchers), UsernamePasswordAuthenticationFilter.class);
将这段代码添加到上面的 TODO 后面即可。
接口测试
POST http://localhost:8080/login
username: lovelycat
password: lovelycat@2024
这里记得替换成你自己的数据
得到的结果如下 eyJhbGciOiJIUzUxMiJ9.eyJhdXRob3JpdGllcyI6IiIsInVpZCI6MTgxOTI2ODgyOTA3MDkwNTM0Niwic3ViIjoibG92ZWx5Y2F0IiwiZXhwIjoxNzIyNTgzNTUyfQ.8EHPJkyERrXmot_N6F5wgB2-d-u-Sr7m8O0gfLhTDh875vtQX8rtzJVvTOW7CL5HuUrBqF2gIZWW25eF6kj-_w