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>
<!-- JWT -->
<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 文件中,引入该依赖,添加内容如下:
<!-- JWT -->
<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;
/**
* JWT 解析
*/
private JwtParser jwtParser;
/**
* 解码配置文件中配置的 Base 64 编码 key 为秘钥
* @param base64Key
*/
@Value("${jwt.secret}")
public void setBase64Key(String base64Key) {
key = Keys.hmacShaKeyFor(Base64.getDecoder().decode(base64Key));
}
/**
* 初始化 JwtParser
* @throws Exception
*/
@Override
public void afterPropertiesSet() throws Exception {
// 考虑到不同服务器之间可能存在时钟偏移,setAllowedClockSkewSeconds 用于设置能够容忍的最大的时钟误差
jwtParser = Jwts.parserBuilder().requireIssuer(issuer)
.setSigningKey(key).setAllowedClockSkewSeconds(10)
.build();
}
/**
* 生成 Token
* @param username
* @return
*/
public String generateToken(String username) {
LocalDateTime now = LocalDateTime.now();
// Token 一个小时后失效
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();
}
/**
* 解析 Token
* @param token
* @return
*/
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);
}
}
/**
* 生成一个 Base64 的安全秘钥
* @return
*/
private static String generateBase64Key() {
// 生成安全秘钥
Key secretKey = Keys.secretKeyFor(SignatureAlgorithm.HS512);
// 将密钥进行 Base64 编码
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() {
// BCrypt 是一种安全且适合密码存储的哈希算法,它在进行哈希时会自动加入“盐”,增加密码的安全性。
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 {
// 从数据库中查询
// ...
// 暂时先写死,密码为 chengzi, 这里填写的密文,数据库中也是存储此种格式
// authorities 用于指定角色,这里写死为 ADMIN 管理员
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();
// 解析提交的 JSON 数据
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();
// 将用户名、密码封装到 Token 中
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 {
// 从 authentication 对象中获取用户的 UserDetails 实例,这里是获取用户的用户名
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
// 通过用户名生成 Token
String username = userDetails.getUsername();
String token = jwtTokenHelper.generateToken(username);
// 返回 Token
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 {
// 自定义的用于 JWT 身份验证的过滤器
JwtAuthenticationFilter filter = new JwtAuthenticationFilter();
filter.setAuthenticationManager(httpSecurity.getSharedObject(AuthenticationManager.class));
// 设置登录认证对应的处理类(成功处理、失败处理)
filter.setAuthenticationSuccessHandler(restAuthenticationSuccessHandler);
filter.setAuthenticationFailureHandler(restAuthenticationFailureHandler);
// 直接使用 DaoAuthenticationProvider, 它是 Spring Security 提供的默认的身份验证提供者之一
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
// 设置 userDetailService,用于获取用户的详细信息
provider.setUserDetailsService(userDetailsService);
// 设置加密算法
provider.setPasswordEncoder(passwordEncoder);
httpSecurity.authenticationProvider(provider);
// 将这个过滤器添加到 UsernamePasswordAuthenticationFilter 之前执行
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(). // 禁用 csrf
formLogin().disable() // 禁用表单登录
.apply(jwtAuthenticationSecurityConfig) // 设置用户登录认证相关配置
.and()
.authorizeHttpRequests()
.mvcMatchers("/admin/**").authenticated() // 认证所有以 /admin 为前缀的 URL 资源
.anyRequest().permitAll() // 其他都需要放行,无需认证
.and()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS); // 前后端分离,无需创建会话
}
}
上述代码中,在 configure() 方法中,首先禁用了 CSRF(Cross-Site Request Forgery)攻击防护。在前后端分离的情况下,通常不需要启用 CSRF 防护。同时,还禁用了表单登录,并应用了 JWT 相关的配置类 JwtAuthenticationSecurityConfig。最后,配置会话管理这块,将会话策略设置为无状态(STATELESS),适用于前后端分离的情况,无需创建会话。
10. 结语
Spring Security JWT 提供了一种安全且灵活的方式来实现身份验证和授权,适用于前后端分离的应用程序。通过使用 JWT,您可以实现无状态的身份验证机制,提高应用程序的安全性和可维护性。