大家好,我是加洛斯,是一名全栈工程师👨💻,这里是我的知识笔记与分享,旨在把复杂的东西讲明白。如果发现有误🔍,万分欢迎你帮我指出来!废话不多说,正文开始 👇
一、基础概念
1.1 什么是JWT?
JWT,全称是JSON Web Token是一种开放标准(RFC 7519),它用于在各方之间作为JSON对象安全地传输信息。也通常用于身份验证和授权。
1.2 为什么要用JWT?
- 服务器不需要存储会话信息:传统的session需要在服务器端存储会话数据,而JWT将所有必要信息包含在token中
- 易于水平扩展:不需要会话复制或共享存储,适合分布式系统和微服务架构
| 特性 | JWT | 传统Session |
|---|---|---|
| 状态 | 无状态 | 有状态 |
| 存储 | Token自包含 | 服务器存储 |
| 扩展性 | 容易扩展 | 需要会话共享 |
| 跨域 | 原生支持 | 需要额外配置 |
| 性能 | 减少数据库查询 | 每次验证需查库 |
1.3 JWT的结构
JWT 由三部分组成,用点号分隔:
xxxxx.yyyyy.zzzzz
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30
1.3.1 Header
Header(头部)包含令牌类型和签名算法,JWT的头部通常由两部分组成:
- typ:令牌类型,固定为"JWT"
- alg:签名算法,如HS256、RS256等
示例:Base64编码前:
{
"alg": "HS256",
"typ": "JWT"
}
Base64编码后: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
1.3.2 Payload
Payload(负载):包含声明,即要传输的数据,它有三种类型的声明:
- 标准声明(建议但不强制):
- iss (issuer):签发者
- sub (subject):主题
- aud (audience):接收方
- exp (expiration time):过期时间
- nbf (not before):生效时间
- iat (issued at):签发时间
- jti (JWT ID):唯一标识
- 公共声明
可以定义公开的声明,但应避免冲突
- 私有声明
自定义的业务数据,我们可以不使用官方定义的任何字段,我们可以使用任何字段来传输我们想要的数据。
示例:Base64编码前:
{
"sub": "1234567890",
"name": "John Doe",
"admin": true,
"iat": 1516239022
}
Base64编码后: eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0
1.3.3 Signature
Signature:验证令牌完整性的签名,用于验证消息在传输过程中没有被篡改。
首先,需要指定一个密钥,这个密钥只有服务器才知道,不能泄露给用户,然后,使用Header里面指定的签名算法(默认是HMACSHA256),按照下面的公式产生签名:
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret
)
示例签名: SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
二、生成JWT
我们有很多的工具可以帮我们生成JWT,我们只需要简单配置一下就能使用,本章讲述第一个工具java-jwt。
2.1 生成jwt
首先我们先引入依赖:
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>4.4.0</version>
</dependency>
然后我们创建一个JWTUtil工具类,并创建一个生成JWT的方法creatToken,这个代码很简单也很固定,一般情况下我们只需要配置SECRET与参数,也就是负载中的信息就可以。
public class JWTUtil {
private static final String SECRET = "6a5w5*;d5656z/54aw1d2*z55cc";
public static void main(String[] args) {
LoginUser user = new LoginUser();
user.setName("zhangsan");
String token = creatToken(user);
System.out.println(token);
}
/**
* 生成JWT
*/
private static String creatToken(LoginUser user){
Map<String, Object> header = new HashMap<>();
header.put("alg", "HS256");
header.put("typ", "JWT");
return JWT.create()
.withHeader(header) // 设置头部信息
.withClaim("name", user.getName()) // 添加负载
.sign(Algorithm.HMAC256(SECRET)); // 设置签名
}
}
我们还写了个main方法打印一下jwt:eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoiemhhbmdzYW4ifQ.AmjB3ghyybghULaEqkkNJiaMtZ-Zlqb3U8i0ZLrW2v4
2.2 验证jwt
我们使用JWTVerifier对象中的.verify方法来验证jwt是否正确且没被篡改过。
public class JWTUtil {
private static final String SECRET = "6a5w5*;d5656z/54aw1d2*z55cc";
public static void main(String[] args) {
LoginUser user = new LoginUser();
user.setName("zhangsan");
String token = creatToken(user);
boolean verify = verify(token);
System.out.println("无改变验证:"+verify);
boolean verify2 = verify(token+123);
System.out.println("有改变验证:"+verify2);
}
/**
* 生成JWT
*/
private static String creatToken(LoginUser user){
Map<String, Object> header = new HashMap<>();
header.put("alg", "HS256");
header.put("typ", "JWT");
return JWT.create()
.withHeader(header) // 设置头部信息
.withClaim("name", user.getName()) // 添加负载
.sign(Algorithm.HMAC256(SECRET)); // 设置签名
}
/**
* 验证JWT
*/
@SuppressWarnings("CallToPrintStackTrace")
public static boolean verify(String token){
try {
// 使用秘钥创建验证器
JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256(SECRET)).build();
// 验证JWT对象,如果没有抛出异常,则验证成功
jwtVerifier.verify(token);
return true;
} catch (Exception e) {
e.printStackTrace();
}
return false;
}
}
结果如下,我们看到如果改变token它会抛异常出来
无改变验证:true
有改变验证:false
com.auth0.jwt.exceptions.SignatureVerificationException: The Token's Signature resulted invalid when verified using the Algorithm: HmacSHA256
at com.auth0.jwt.algorithms.HMACAlgorithm.verify(HMACAlgorithm.java:57)
at com.auth0.jwt.JWTVerifier.verify(JWTVerifier.java:463)
at com.auth0.jwt.JWTVerifier.verify(JWTVerifier.java:445)
at com.example.util.JWTUtil.verify(JWTUtil.java:46)
at com.example.util.JWTUtil.main(JWTUtil.java:20)
2.3 解析jwt
public class JWTUtil {
private static final String SECRET = "6a5w5*;d5656z/54aw1d2*z55cc";
public static void main(String[] args) {
LoginUser user = new LoginUser();
user.setName("zhangsan");
String token = creatToken(user);
// 解析
System.out.println(getPayload(token));
}
/**
* 生成JWT
*/
private static String creatToken(LoginUser user){
Map<String, Object> header = new HashMap<>();
header.put("alg", "HS256");
header.put("typ", "JWT");
return JWT.create()
.withHeader(header) // 设置头部信息
.withClaim("name", user.getName()) // 添加负载
.sign(Algorithm.HMAC256(SECRET)); // 设置签名
}
/**
* 获取JWT中的负载(解析jwt)
*/
public static String getPayload(String token){
try {
// 使用秘钥创建验证器
JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256(SECRET)).build();
DecodedJWT decodedJWT = jwtVerifier.verify(token);
Claim name = decodedJWT.getClaim("name");
return name.asString();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
/**
* 验证JWT
*/
@SuppressWarnings("CallToPrintStackTrace")
public static boolean verify(String token){
try {
// 使用秘钥创建验证器
JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256(SECRET)).build();
// 验证JWT对象,如果没有抛出异常,则验证成功
jwtVerifier.verify(token);
return true;
} catch (Exception e) {
e.printStackTrace();
}
return false;
}
}
看输出,一点问题没有,至此一个简单的jwt生成案例就算是成功了。
三、存储JTW
我们先看下面图片,它简单的展示了我们jwt的生成与存储过程,我们将基于此来完成我们的代码编写。
3.1 配置redis
首先我们需要先准备一下redis的环境,这块是一个大知识点所以肯定并不能详细的讲,具体如何下载如何配置请移步到其他详细讲redis的文章
- 准备依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
- 修改配置
data:
redis:
host: 127.0.0.1(改成你自己的)
port: 6379
database: 1
timeout: 2000ms
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 0
max-wait: -1ms
- 准备RedisConfig
这是一个固定代码,用于给redis存储序列化用的,否则存到库里面是乱码。
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
// 使用Jackson序列化Value
ObjectMapper om = new ObjectMapper();
om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
om.activateDefaultTyping(LaissezFaireSubTypeValidator.instance,
ObjectMapper.DefaultTyping.NON_FINAL);
GenericJackson2JsonRedisSerializer serializer =
new GenericJackson2JsonRedisSerializer(om);
// Key使用String序列化
template.setKeySerializer(RedisSerializer.string());
template.setHashKeySerializer(RedisSerializer.string());
// Value使用JSON序列化
template.setValueSerializer(serializer);
template.setHashValueSerializer(serializer);
template.afterPropertiesSet();
return template;
}
}
3.2 Constant常量接口
public interface Constant {
// JWT秘钥
String SECRET = "6a5w5*;d5656z/54aw1d2*z55cc";
// redis的key的命名规范:项目名:模块名:功能名[:唯一业务参数]
String REDIS_TOKEN_KEY = "security:user:login";
// 登录接口
String LOGIN_URL = "/login";
// 登出接口
String LOGOUT_URL = "/logout";
// 后端请求头中token的名称
String HEADER_TOKEN_NAME = "token";
}
3.3 JWTUtil
/**
* JWT工具类,用于生成、解析和验证JWT令牌
*
* JWT (JSON Web Token) 是一种开放标准(RFC 7519),用于在网络应用环境间安全地传递声明。
* JWT通常由三部分组成:Header(头部)、Payload(载荷)、Signature(签名)
*/
@Component
public class JWTUtil {
/**
* 生成JWT令牌
* @param user 用户信息对象,包含用户身份相关数据
* @return 加密后的JWT令牌字符串
*/
public String creatToken(LoginUser user) {
// 创建JWT头部信息
Map<String, Object> header = new HashMap<>();
header.put("alg", "HS256"); // 指定签名算法
header.put("typ", "JWT"); // 指定令牌类型
// 生成令牌
return JWT.create()
.withHeader(header) // 设置头部信息
.withClaim("loginUser", JSONUtil.toJsonStr(user)) // 设置用户信息载荷
.withIssuedAt(new Date()) // 设置签发时间
.withExpiresAt(new Date(System.currentTimeMillis() + 24 * 60 * 60 * 1000)) // 设置过期时间为24小时
.sign(Algorithm.HMAC256(Constant.SECRET)); // 使用指定密钥进行签名
}
/**
* 从JWT令牌中获取载荷信息
*
* @param token JWT令牌字符串
* @return 存储在令牌中的用户信息JSON字符串
* @throws RuntimeException 当令牌无效或解析失败时抛出异常
*/
public String getPayload(String token) {
try {
// 使用密钥创建验证器
JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256(Constant.SECRET)).build();
// 验证并解码JWT令牌
DecodedJWT decodedJWT = jwtVerifier.verify(token);
// 获取用户信息载荷
Claim loginUser = decodedJWT.getClaim("loginUser");
return loginUser.asString();
} catch (JWTVerificationException e) {
throw new RuntimeException("JWT解析失败", e);
}
}
/**
* 验证JWT令牌的有效性
*
* @param token JWT令牌字符串
* @return 如果令牌有效返回true,否则返回false
*/
public boolean verify(String token) {
try {
// 使用密钥创建验证器
JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256(Constant.SECRET)).build();
// 验证JWT令牌,如果验证失败会抛出异常
jwtVerifier.verify(token);
// 如果没有抛出异常,说明验证成功
return true;
} catch (JWTVerificationException | IllegalArgumentException e) {
return false;
}
}
}
3.4 登录写入流程
我们之前学习过登录成功后的handle,那么生成jwt令牌和写入redis的逻辑写在这个handle里面正好,下面看代码,这个代码是认证成功后调用的方法,此方法会在用户成功登录后被调用,执行以下操作:
- 生成JWT令牌
- 将令牌存储到Redis中(用于后续的令牌验证)
- 构建并返回成功响应给客户端
/**
* 自定义登录成功处理器
* 当用户认证成功后,此处理器会生成JWT令牌,将其保存到Redis中,
* 并向客户端返回包含令牌的成功响应。
* 实现了Spring Security的AuthenticationSuccessHandler接口。
* 请引入hutool-all这个依赖
*/
@Component
public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
@Resource
private JWTUtil jwtUtil;
@Resource
private RedisTemplate<String, Object> redisTemplate;
/**
* 认证成功后调用的方法
* @param request HTTP请求对象,提供客户端请求的请求信息
* @param response HTTP响应对象,用于向客户端发送响应
* @param authentication 包含认证信息的对象,包含已认证的用户信息
* @throws IOException 当写入响应时发生IO错误
*/
@Override
public void onAuthenticationSuccess(HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) throws IOException {
// 设置响应的内容类型为JSON格式,字符编码为UTF-8
response.setContentType("application/json;charset=UTF-8");
// 从认证对象中获取已认证的用户信息
LoginUser principal = (LoginUser) authentication.getPrincipal();
// 1. 根据用户信息生成JWT令牌
String token = jwtUtil.creatToken(principal);
// 2. 将生成的令牌存储到Redis中,用于后续验证
// Redis的键格式为: security:user:login (定义在Constant中)
// Redis的字段为: 用户ID
// Redis的值为: JWT令牌
redisTemplate.opsForHash().put(Constant.REDIS_TOKEN_KEY,String.valueOf(principal.getId()), token);
// 3. 构建成功响应结果
R result = R.builder()
.code(200) // 成功状态码
.msg("登录成功!") // 成功提示信息
.data(token) // 返回JWT令牌
.build();
// 4. 将响应对象转换为JSON字符串并写入响应体
String jsonStr = JSONUtil.toJsonStr(result);
response.getWriter().write(jsonStr);
}
}
3.5 验证
打开登录页面,点击登录,我们查看一下数据库,可以看到我们成功存储了这个id为1的用户的登录token
四、请求验证
除了我们配置的无需验证的URL以外的其他请求,springsecurity都会为我们验证该请求的发起者是否登录,因此每次请求中我们都需要在请求头携带我们的token,然后对比token,当所有步骤都通过后,我们在security上下文存入一个登录对象即可验证通过完成请求。
至于在哪验证,自然是手写一个过滤器来验证,来看下面环节。
4.1 过滤器
该过滤器继承自OncePerRequestFilter,确保每次请求只被执行一次。
过滤器的主要职责包括:
- 检查请求是否为登录请求,如果是则直接放行
- 验证请求中的JWT令牌是否合法
- 检查Redis中存储的令牌与请求令牌是否一致
- 将已验证的用户信息设置到Spring Security上下文中
/**
* JWT令牌过滤器,负责验证请求中的JWT令牌
*/
@Component
public class TokenFilter extends OncePerRequestFilter {
@Resource
private JWTUtil jwtUtil;
@Resource
private RedisTemplate<String, Object> redisTemplate;
/**
* 执行过滤器的核心方法
*
* @param request HttpServletRequest对象
* @param response HttpServletResponse对象
* @param filterChain FilterChain对象,用于执行后续过滤器
* @throws ServletException Servlet异常
* @throws IOException IO异常
*/
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// 设置响应内容类型为JSON格式
response.setContentType("application/json;charset=UTF-8");
// 1. 检查是否为登录请求,如果是则直接放行,让后续过滤器链处理
if (request.getRequestURI().equals(Constant.LOGIN_URL)) {
filterChain.doFilter(request, response);
return; // 确保在匹配登录URL时直接返回
}
// 2. 从请求头中获取JWT令牌
String token = request.getHeader(Constant.HEADER_TOKEN_NAME);
// 3. 检查令牌是否存在
if (!StringUtils.hasText(token)) {
// 令牌为空,返回错误响应
sendErrorResponse(response, 901, "请求token为空");
return;
}
// 4. 验证JWT令牌是否合法
if (!jwtUtil.verify(token)) {
// 令牌非法,返回错误响应
sendErrorResponse(response, 902, "请求token非法");
return;
}
// 5. 从JWT中提取用户信息
LoginUser loginUser;
try {
String payload = jwtUtil.getPayload(token);
loginUser = JSONUtil.toBean(payload, LoginUser.class);
} catch (Exception e) {
// 解析用户信息失败,返回错误响应
sendErrorResponse(response, 904, "用户信息解析失败");
return;
}
// 6. 验证Redis中的令牌是否与当前令牌一致(双重验证,防止令牌盗用)
String redisToken = (String) redisTemplate.opsForHash().get(
Constant.REDIS_TOKEN_KEY, String.valueOf(loginUser.getId()));
if (redisToken == null || !token.equals(redisToken)) {
// Redis中不存在对应的令牌或令牌不匹配,返回错误响应
sendErrorResponse(response, 903, "请求token错误");
return;
}
// 7. 令牌验证通过,将用户信息设置到Spring Security上下文中
// 创建认证令牌对象,其中包含用户信息和权限信息
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(loginUser, null,
AuthorityUtils.NO_AUTHORITIES);
// 将认证对象设置到Security上下文中,以便后续组件使用
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
// 8. 放行请求,继续执行后续过滤器链
filterChain.doFilter(request, response);
}
/**
* 发送错误响应
*
* @param response HttpServletResponse对象
* @param code 错误代码
* @param message 错误消息
* @throws IOException IO异常
*/
private void sendErrorResponse(HttpServletResponse response, int code, String message) throws IOException {
R result = R.builder().code(code).msg(message).build();
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 设置HTTP状态码为401
response.getWriter().write(JSONUtil.toJsonStr(result));
}
}
4.2 SecurityConfig
我们之前写过这个config,现在我们需要将我们刚刚写的filter添加到security过滤链里面去
/**
* 创建一个 SecurityFilterChain 对象,用于配置 Spring Security 的安全过滤链
*/
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity,CorsConfigurationSource configurationSource) throws Exception {
httpSecurity
.formLogin((formLogin) -> formLogin.loginProcessingUrl(Constant.LOGIN_URL)
.successHandler(myAuthenticationSuccessHandler) // 登录成功处理器 如果登录成功,则会执行里面的代码
.failureHandler(failAuthenticationFailureHandler) // 登录失败处理器 如果登录失败,则会执行里面的代码
)
.logout(logout->
logout.logoutUrl(Constant.LOGOUT_URL).logoutSuccessHandler(myLogoutSuccessHandler)
)
.authorizeHttpRequests((authorize) ->
authorize.requestMatchers(Constant.LOGIN_URL).permitAll().anyRequest().authenticated())
.csrf(AbstractHttpConfigurer::disable)
//支持跨域请求
.cors( (cors) -> cors.configurationSource(configurationSource))
.sessionManagement(
// SESSION创建策略
session->session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
// 在登录filter后添加token过滤器
.addFilterAfter(tokenFilter, UsernamePasswordAuthenticationFilter.class);
return httpSecurity.build();
}
4.3 前端
我们获取到的token需要存储在浏览器当中的localStorage中,他是浏览器持久化数据的一个空间,即使关闭浏览器也不会清除数据。
我们先看登录函数
const handleSubmit = () => {
const formData = new FormData()
formData.append('username', loginForm.value.username)
formData.append('password', loginForm.value.password)
let token = window.localStorage.getItem('token')
axios({
url: 'http://localhost:8080/login',
method: 'post',
data: formData,
responseType: 'json',
})
.then((res) => {
if (res.data.code === 200) {
console.log(res.data.data)
window.localStorage.setItem('token', res.data.data)
// 跳转到首页
router.push('/home')
} else {
console.log(res.data.msg)
}
})
.catch((err) => {
console.log(err)
})
}
home页当中写一个函数请求一些固定数据,携带token
onMounted(() => {
// 获取用户信息
axios
.get('http://localhost:8080/user', {
headers: {
token: window.localStorage.getItem('token'),
},
})
.then((res) => {
console.log(res.data)
})
})
4.4 验证
其实请求只会获得一个固定的string
而前端登录成功后确实自动跳转到home页面并且成功获取到了数据
我们可以看一下删除请求头中的token会返回什么