1. 什么是 JWT? JWT(JSON Web Token)是一种用于在不同应用之间安全传输信息的开放标准(RFC 7519)。它是一种基于 JSON 的轻量级令牌,由三部分组成:头部(Header)、载荷(Payload)和签名(Signature)。JWT 被广泛用于实现身份验证和授权,特别适用于前后端分离的应用程序。
令牌类似下面这一大长串:
eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJxdWFueGlhb2hhIiwiaXNzIjoicXVhbnhpYW9oYSIsImlhdCI6MTY5Mjk1OTY2MSwiZXhwIjoxNjkyOTYzMjYxfQ.wbqbn23C9vAe5sQZRCBzrIM4SiN1eNl55NIONmHoiPHPHSSu0QJGgPGUin80hA4XgMHEqN1Wm5KJlmKKucUyGQ
可以看到,由 header.payload.signature 三部分组成,你可以在此网站: https://jwt.io/ 上获得解析结果:
2. 为什么要使用 JWT? JWT 提供了一种在客户端和服务器之间传输安全信息的简单方法,具有以下优点:
无状态性(Stateless):JWT 本身包含了所有必要的信息,无需在服务器端存储会话信息,每个请求都可以独立验证。
灵活性:JWT 可以存储任意格式的数据,使其成为传递用户信息、权限、角色等的理想选择。
安全性:JWT 使用签名进行验证,确保信息在传输过程中不被篡改。
跨平台和跨语言:由于 JWT 使用 JSON 格式,它在不同的编程语言和平台之间都可以轻松传递。
3. 开始动手 3.1 添加 JWT 依赖 这里我们选择 Java JWT : JSON Web Token for Java and Android (简称 JJWT) 库。首先,在父模块中的 pom.xml 中声明版本号:
<properties > // 省略... <jjwt.version > 0.11.2</jjwt.version > </properties > <dependencyManagement > <dependencies > <dependency > <groupId > io.jsonwebtoken</groupId > <artifactId > jjwt-api</artifactId > <version > ${jjwt.version}</version > </dependency > <dependency > <groupId > io.jsonwebtoken</groupId > <artifactId > jjwt-impl</artifactId > <version > ${jjwt.version}</version > </dependency > <dependency > <groupId > io.jsonwebtoken</groupId > <artifactId > jjwt-jackson</artifactId > <version > ${jjwt.version}</version > </dependency > </dependencies > </dependencyManagement >
然后,在jwt模块中的 pom.xml 文件中,引入该依赖,添加内容如下:
<dependency > <groupId > io.jsonwebtoken</groupId > <artifactId > jjwt-api</artifactId > </dependency > <dependency > <groupId > io.jsonwebtoken</groupId > <artifactId > jjwt-impl</artifactId > </dependency > <dependency > <groupId > io.jsonwebtoken</groupId > <artifactId > jjwt-jackson</artifactId > </dependency >
3.2 JwtTokenHelper 工具类 @Component public class JwtTokenHelper implements InitializingBean { @Value("${jwt.issuer}") private String issuer; private Key key; private JwtParser jwtParser; @Value("${jwt.secret}") public void setBase64Key (String base64Key) { key = Keys.hmacShaKeyFor(Base64.getDecoder().decode(base64Key)); } @Override public void afterPropertiesSet () throws Exception { jwtParser = Jwts.parserBuilder().requireIssuer(issuer) .setSigningKey(key).setAllowedClockSkewSeconds(10 ) .build(); } public String generateToken (String username) { LocalDateTime now = LocalDateTime.now(); LocalDateTime expireTime = now.plusHours(1 ); return Jwts.builder().setSubject(username) .setIssuer(issuer) .setIssuedAt(Date.from(now.atZone(ZoneId.systemDefault()).toInstant())) .setExpiration(Date.from(expireTime.atZone(ZoneId.systemDefault()).toInstant())) .signWith(key) .compact(); } public Jws<Claims> parseToken (String token) { try { return jwtParser.parseClaimsJws(token); } catch (SignatureException | MalformedJwtException | UnsupportedJwtException | IllegalArgumentException e) { throw new BadCredentialsException ("Token 不可用" , e); } catch (ExpiredJwtException e) { throw new CredentialsExpiredException ("Token 失效" , e); } } private static String generateBase64Key () { Key secretKey = Keys.secretKeyFor(SignatureAlgorithm.HS512); String base64Key = Base64.getEncoder().encodeToString(secretKey.getEncoded()); return base64Key; } public static void main (String[] args) { String key = generateBase64Key(); System.out.println("key: " + key); } }
上述代码中,Token 令牌的初始化工作在 generateToken() 方法中完成,主要是通过 Jwts.builder 返回的 JwtBuilder 来做的。令牌的解析工作交给了 JwtParser 类,在 parseToken() 方法中完成。
与之对应的,工具类中注入的一些参数,如 jwt 的签发人、秘钥,需要在 applicaiton.yml 中配置好:
jwt: issuer: chengzi secret: jElxcSUj38+Bnh73T68lNs0DfBSit6U3whQlcGO2XwnI+Bo3g4xsiCIPg8PV/L0fQMis08iupNwhe2PzYLB9Xg==
3.3 如何生成安全的秘钥? 在 JwtTokenHelper 中,已经定义好了一个 generateBase64Key() 方法,它专门用于生成一个 Base64 的安全秘钥,执行 main() 方法即可,然后将生成好的秘钥配置到 yml 文件中。
3.4 PasswordEncoder 密码加密 在系统中,安全存储用户密码是至关重要的。使用明文存储密码容易受到攻击,相信大家都看过某些网站用户账户被黑,密码都是明文保存的新闻,因此使用密码加密技术来保护用户密码是必不可少的。
在 jwt 模块中新建 config 包,并创建 PasswordEncoderConfig 配置类,代码如下:
@Component public class PasswordEncoderConfig { @Bean public PasswordEncoder passwordEncoder () { return new BCryptPasswordEncoder (); } public static void main (String[] args) { BCryptPasswordEncoder encoder = new BCryptPasswordEncoder (); System.out.println(encoder.encode("chengzi" )); } }
PasswordEncoder 接口是 Spring Security 提供的密码加密接口,它定义了密码加密和密码验证的方法。通过实现这个接口,您可以将密码加密为不可逆的哈希值,以及在验证密码时对比哈希值。
4. 实现 UserDetailsService:Spring Security 用户详情服务 4.1 什么是 UserDetailsService? UserDetailsService 是 Spring Security 提供的接口,用于从应用程序的数据源(如数据库、LDAP、内存等)中加载用户信息。它是一个用于将用户详情加载到 Spring Security 的中心机制。UserDetailsService 主要负责两项工作:
加载用户信息: 从数据源中加载用户的用户名、密码和角色等信息。
创建 UserDetails 对象: 根据加载的用户信息,创建一个 Spring Security 所需的 UserDetails 对象,包含用户名、密码、角色和权限等。
4.2 自定义实现类 新建 service 包,并创建 UserDetailServiceImpl 实现类:
@Service @Slf4j public class UserDetailServiceImpl implements UserDetailsService { @Override public UserDetails loadUserByUsername (String username) throws UsernameNotFoundException { return User.withUsername("chengzi" ) .password("$2a$10$n7RJ1q.RnXx5M3O6B0i0he04fZOPjIJpyWcKuicW1bFyFHWhlGose" ) .authorities("ADMIN" ) .build(); } }
上述代码中,我们实现了 UserDetailsService 接口,并重写了 loadUserByUsername() 方法,该方法用于根据用户名加载用户信息的逻辑 ,正常需要从数据库中查询,这里我们先写死,继续开发后面的功能,后续再回过头来改造。
5. 自定义认证过滤器 接下来,我们自定义一个用于认证的过滤器,新建 /filter 包,并创建JwtAuthenticationFilter 过滤器,代码如下:
public class JwtAuthenticationFilter extends AbstractAuthenticationProcessingFilter { public JwtAuthenticationFilter () { super (new AntPathRequestMatcher ("/login" , "POST" )); } @Override public Authentication attemptAuthentication (HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException { ObjectMapper mapper = new ObjectMapper (); JsonNode jsonNode = mapper.readTree(request.getInputStream()); JsonNode usernameNode = jsonNode.get("username" ); JsonNode passwordNode = jsonNode.get("password" ); if (Objects.isNull(usernameNode) || Objects.isNull(passwordNode) || StringUtils.isBlank(usernameNode.textValue()) || StringUtils.isBlank(passwordNode.textValue())) { throw new UsernameOrPasswordNullException ("用户名或密码不能为空" ); } String username = usernameNode.textValue(); String password = passwordNode.textValue(); UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken (username, password); return getAuthenticationManager().authenticate(usernamePasswordAuthenticationToken); } }
此过滤器继承了 AbstractAuthenticationProcessingFilter,用于处理 JWT(JSON Web Token)的用户身份验证过程。在构造函数中,调用了父类 AbstractAuthenticationProcessingFilter 的构造函数,通过 AntPathRequestMatcher 指定了处理用户登录的访问地址。这意味着当请求路径匹配 /login 并且请求方法为 POST 时,该过滤器将被触发。
attemptAuthentication() 方法用于实现用户身份验证的具体逻辑。首先,我们解析了提交的 JSON 数据,并获取了用户名、密码,校验是否为空,若不为空,则将它们封装到 UsernamePasswordAuthenticationToken 中。最后,使用 getAuthenticationManager().authenticate() 来触发 Spring Security 的身份验证管理器执行实际的身份验证过程,然后返回身份验证结果。
6. 自定义用户名或密码不能为空异常 上面过滤器代码中,有个动作是校验用户名、密码是否为空,为空则抛出 UsernameOrPasswordNullException 异常,此类是自定义的得来的。新建包 /exception, 在此包中创建该类:
public class UsernameOrPasswordNullException extends AuthenticationException { public UsernameOrPasswordNullException (String msg, Throwable cause) { super (msg, cause); } public UsernameOrPasswordNullException (String msg) { super (msg); } }
注意,需继承自 AuthenticationException,只有该类型异常,才能被后续自定义的认证失败处理器捕获到。
7. 自定义处理器 用户登录后,我们还需要处理其对应的结果,如登录成功,则返回 Token 令牌,登录失败,则返回对应的提示信息。在 Spring Security 中,AuthenticationFailureHandler 和 AuthenticationSuccessHandler 是用于处理身份验证失败和成功的接口。它们允许您在用户身份验证过程中自定义响应,以便更好地控制和定制用户体验。
7.1 自定义认证成功处理器 RestAuthenticationSuccessHandler 新建 /handler 包,并创建 RestAuthenticationSuccessHandler 类:
@Component @Slf4j public class RestAuthenticationSuccessHandler implements AuthenticationSuccessHandler { @Autowired private JwtTokenHelper jwtTokenHelper; @Override public void onAuthenticationSuccess (HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { UserDetails userDetails = (UserDetails) authentication.getPrincipal(); String username = userDetails.getUsername(); String token = jwtTokenHelper.generateToken(username); LoginRspVO loginRspVO = LoginRspVO.builder().token(token).build(); ResultUtil.ok(response, Response.success(loginRspVO)); } }
此类实现了 Spring Security 的 AuthenticationSuccessHandler 接口,用于处理身份验证成功后的逻辑。首先,从 authentication 对象中获取用户的 UserDetails 实例,这里是主要是获取用户的用户名,然后通过用户名生成 Token 令牌,最后返回数据。
7.2 自定义认证失败处理器 在 /handler 包下,创建 RestAuthenticationFailureHandler 认证失败处理器:
@Component @Slf4j public class RestAuthenticationFailureHandler implements AuthenticationFailureHandler { @Override public void onAuthenticationFailure (HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { log.warn("AuthenticationException: " , exception); if (exception instanceof UsernameOrPasswordNullException) { ResultUtil.fail(response, Response.fail(exception.getMessage())); } else if (exception instanceof BadCredentialsException) { ResultUtil.fail(response, Response.fail(ResponseCodeEnum.USERNAME_OR_PWD_ERROR)); } ResultUtil.fail(response, Response.fail(ResponseCodeEnum.LOGIN_FAIL)); } }
通过自定义了一个实现了 Spring Security 的 AuthenticationFailureHandler 接口类,用于在用户身份验证失败后执行一些逻辑。首先,我们打印了异常日志,方便后续定位问题,然后对异常的类型进行判断,通过 ResultUtil 工具类,返回不同的错误信息,如用户名或者密码为空、用户名或密码错误等,若未判断出异常是什么类型,则统一提示为 登录失败。
8. 自定义 JWT 认证功能配置 完成了以上前置工作后,我们开始配置 JWT 认证相关的配置。在 /config 包下新建 JwtAuthenticationSecurityConfig, 代码如下:
@Configuration public class JwtAuthenticationSecurityConfig extends SecurityConfigurerAdapter <DefaultSecurityFilterChain, HttpSecurity> { @Autowired private RestAuthenticationSuccessHandler restAuthenticationSuccessHandler; @Autowired private RestAuthenticationFailureHandler restAuthenticationFailureHandler; @Autowired private PasswordEncoder passwordEncoder; @Autowired private UserDetailsService userDetailsService; @Override public void configure (HttpSecurity httpSecurity) throws Exception { JwtAuthenticationFilter filter = new JwtAuthenticationFilter (); filter.setAuthenticationManager(httpSecurity.getSharedObject(AuthenticationManager.class)); filter.setAuthenticationSuccessHandler(restAuthenticationSuccessHandler); filter.setAuthenticationFailureHandler(restAuthenticationFailureHandler); DaoAuthenticationProvider provider = new DaoAuthenticationProvider (); provider.setUserDetailsService(userDetailsService); provider.setPasswordEncoder(passwordEncoder); httpSecurity.authenticationProvider(provider); httpSecurity.addFilterBefore(filter, UsernamePasswordAuthenticationFilter.class); } }
上述代码是一个 Spring Security 配置类,用于配置 JWT(JSON Web Token)的身份验证机制。它继承了 Spring Security 的 SecurityConfigurerAdapter 类,用于在 Spring Security 配置中添加自定义的认证过滤器和提供者。通过重写 configure() 方法,我们将之前写好过滤器、认证成功、失败处理器,以及加密算法整合到了 httpSecurity 中。
9. 应用 JWT 认证功能配置 接下来,我们编辑验证模块中的 Spring Security 配置 WebSecurityConfig 类,修改内容如下:
@Configuration @EnableWebSecurity public class WebSecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private JwtAuthenticationSecurityConfig jwtAuthenticationSecurityConfig; @Override protected void configure (HttpSecurity http) throws Exception { http.csrf().disable(). formLogin().disable() .apply(jwtAuthenticationSecurityConfig) .and() .authorizeHttpRequests() .mvcMatchers("/admin/**" ).authenticated() .anyRequest().permitAll() .and() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS); } }
上述代码中,在 configure() 方法中,首先禁用了 CSRF(Cross-Site Request Forgery)攻击防护。在前后端分离的情况下,通常不需要启用 CSRF 防护。同时,还禁用了表单登录,并应用了 JWT 相关的配置类 JwtAuthenticationSecurityConfig。最后,配置会话管理这块,将会话策略设置为无状态(STATELESS),适用于前后端分离的情况,无需创建会话。
10. 结语 Spring Security JWT 提供了一种安全且灵活的方式来实现身份验证和授权,适用于前后端分离的应用程序。通过使用 JWT,您可以实现无状态的身份验证机制,提高应用程序的安全性和可维护性。