之前写过一次 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