简介
jwt:json web token,是一个开放标准,它定义了一种紧凑且自包含的方式,用于在各方中间安全的传输信息,因为信息经过数字签名,所以可以被验证和信任。
jwt的本质就是一个字符串,它是将用户信息保存到一个json字符串中,然后进行编码并签名后得到一个token,通过传递token来传输数据,由于token带有签名,所以可以校验数据是否被修改。
优缺点:
- 优点:
- 跨语言:jwt token是以json加密形式保存在客户端的,所以jwt是跨语言的
- 简洁快速:jwt token数据量小,传输速度快
- 服务端开销小:用户信息被保存在jwt token中,服务器端服务从token中解析出用户数据,本身不需要维护用户数据,所以服务端开销小
- 缺点:
- jwt不适合传输机密信息,例如用户密码,因为第三方可以获取jwt传输的数据,但是无法进行修改。
jwt生成的token的结构: 它由三部分组成,每部分使用点分隔,格式是:header.payload.sign。
- header:标题,一个描述JWT元数据的JSON对象
- 格式:{"alg": "签名算法", "typ": "JWT"},
- 每个字段的含义:
- alg属性表示签名使用的算法
- typ属性表示令牌的类型,JWT令牌统一写为JWT
- payload: 有效负载,JWT的主体内容部分,也是一个JSON对象,包含需要传递的数据,一般会把包含用户信息的数据放到payload中。默认情况下JWT是未加密的,因为只是采用base64算法,任何人都可以解读其内容,因此不要存储机密信息
- sign: 签名,使用指定算法,对header和payload进行签名,确保数据不会被修改。只要秘钥不丢失,可以认为是安全的,
入门案例
需求: 使用jwt + redis,实现登录功能。
实现步骤:
- 用户登录时,使用jwt算法,生成一个token,把这个token存储到redis中,key是token,值是用户信息,把这个token下发给用户,用户就登录成功
- 用户登录成功后,每次请求都会把token放到请求头中,后端从请求头中获取校验,校验签名,然后根据token,从redis中获取用户信息。
第一步: 添加依赖
xml
<!--jwt-->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
第二步: jwt的工具类,生成token和验证token
java
package org.wyj.blog.utils;
import io.jsonwebtoken.Jwt;
import io.jsonwebtoken.JwtBuilder;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
/**
* jwt的工具类
*/
public class JwtUtil {
// 服务端的秘钥
private static final String JWT_TOKEN = "1qaz!QAZ12#$";
private static final String USER_ID = "userId";
// 1天的毫秒数 = 24小时 * 60分钟 * 60秒 * 1000毫秒
private static final int ONE_DAY_MILLIONS = 24 * 60 * 60 * 1000;
// 创建token,token中的数据就是userId
public static String createToken(Long userId) {
// claim,宣称,表示token中要存储的数据
Map<String, Object> claims = new HashMap<>();
claims.put(USER_ID, userId);
JwtBuilder jwtBuilder = Jwts.builder()
// HS256是一种对称加密算法,用于生成和验证JWT(JSON Web Token)的签名,
.signWith(SignatureAlgorithm.HS256, JWT_TOKEN)
.setClaims(claims)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + ONE_DAY_MILLIONS));
return jwtBuilder.compact();
}
// 解析token
public static Map<String, Object> checkToken(String token) {
try {
Jwt jwt = Jwts.parser().setSigningKey(JWT_TOKEN).parse(token);
return (Map<String, Object>)jwt.getBody();
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
}
使用jwt生成的秘钥案例:eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjE3MTEzMzkzNzgsInVzZXJJZCI6MTUxOTY3OTE5NTI3NjQwMjY5MywiaWF0IjoxNzExMjUyOTc4fQ.A3vvsuEgf9LGIWJhYwwxaw-tVYJzJFWyF5Kx2aDZkVI,可以看到,秘钥分为3个部分,由两个点分割
第三步: 登录接口
java
@RestController
@RequestMapping("/user")
public class LoginController {
@Autowired
private ILoginService loginService;
/**
* 登录接口
* 1、 用户输入账号和密码,后端将用户输入的密码使用md5算法加上特定的盐,转换为一个md5字符串,
* 2、 然后根据账号和密码,从数据库中查找用户,
* 3、 如果有用户,根据用户ID和当前时间戳生成一个token,把token作为键,用户信息作为值,存入到redis中,
* 4、 给用户返回token
*/
@PostMapping("/login")
public String login(@RequestBody String requestBody) {
LoginParam loginParams = JsonUtil.fromJson(requestBody, LoginParam.class);
return loginService.login(loginParams);
}
}
@Service
public class LoginServiceImpl implements ILoginService {
private static final String SALT = "3edc#EDC34%^";
@Autowired
private ISysUserService sysUserService;
@Autowired
private StringRedisTemplate redisTemplate;
@Override
public String login(LoginParam loginParams) {
String account = loginParams.getAccount();
String password = loginParams.getPassword();
if (StringUtils.isBlank(account) || StringUtils.isBlank(password)) {
return ResultVO.fail(ErrorCode.PARAM_ERROR).toJson();
}
password = DigestUtils.md5DigestAsHex((password + SALT).getBytes(StandardCharsets.UTF_8));
SysUser sysUser = sysUserService.findUser(account, password);
if (sysUser == null) {
return ResultVO.fail(ErrorCode.ACCOUNT_NOT_EXISTS).toJson();
}
String token = JwtUtil.createToken(sysUser.getId());
redisTemplate.opsForValue()
.set("TOKEN_" + token, JsonUtil.toJson(sysUser), 1, TimeUnit.DAYS);
return ResultVO.success(token).toJson();
}
}
第四步: 配置拦截器,拦截需要登录后才能操作的接口
java
// 配置登录拦截器要拦截哪些地址
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Autowired
private CrossDomainInterceptor crossDomainInterceptor;
@Autowired
private LoginInterceptor loginInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 自定义跨域配置拦截器,使用默认的跨域配置,无法在拦截器中生效,因为拦截器的调用在跨域配置之前
// 所以自定义一个拦截器处理跨域请求,它在所有的拦截器之前调用,避免在其它拦截器中添加跨域逻辑
registry.addInterceptor(crossDomainInterceptor).addPathPatterns("/**");
registry.addInterceptor(loginInterceptor)
// 正确的配置方式
// .addPathPatterns("/**").excludePathPatterns("/user/**")
.addPathPatterns("/api/v1/comment/create")
.addPathPatterns("/api/v1/article/publish");
}
}
// 登录拦截器
@Slf4j
@Component
public class LoginInterceptor implements HandlerInterceptor {
@Autowired
private ILoginService loginService;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
Object handler) throws Exception {
// 判断当前请求是不是访问控制器的方法,如果是访问静态资源,那么handler是
// RequestResourceHandler,并且它会去classpath下的static目录中寻找
// 静态资源
if (!(handler instanceof HandlerMethod)) {
return true;
}
String token = request.getHeader("Authorization");
SysUser sysUser = loginService.getUserByToken(token);
if (sysUser == null) {
String resultJson = ResultVO.fail(ErrorCode.NO_LOGIN).toJson();
response.setContentType("application/json;charset=utf-8");
response.getWriter().print(resultJson);
return false;
}
log.info("request uri {}, method {}", request.getRequestURI(), request.getMethod());
UserThreadLocalUtil.put(sysUser); // 把用户信息保存到ThreadLocal中
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
Object handler, Exception ex) throws Exception {
UserThreadLocalUtil.remove();
}
}
总结
用户登录和注册的逻辑:
- 第一步:
- 用户登录时,输出用户名和密码,后台校验成功后,返回给前端一个token,
- token中存放了用户的ID,然后前端根据用户的token,获取用户信息,完成登录功能
- 第二步:
- 任何需要登录后才执行的操作,前端的请求都必须携带token,
- 用户的token会被作为键缓存在redis中,值是用户信息
- 如果用户没有上传token,证明用户没有登录
- 后端会根据前端上传的token,去redis中获取用户信息,如果获取不到,证明token错误,
- 第三步:
- 用户注册时,在注册成功后,返回给用户一个token,
- 然后前端依据token获取自己的信息,进行登录,从而实现注册后立刻登录的功能
无论是 JWT 还是 Session ,登录的本质流程都是:
- 用户发送用户名和密码;
- 后端验证成功后,生成认证信息;
- 后端将认证信息返回给客户端;
- 客户端携带认证信息访问受保护接口;
- 后端验证该认证信息是否合法。
这个入门案例中,有一个值得注意的点,通常是不需要把token存入redis中的,因为token中包含过期时间、用户信息,把token存入redis,是为了实现主动踢人。
原理解析
生成jwt的流程:
- 设置加密算法和密钥
- 设置负载数据,包括要用户信息、token签发时间、过期时间等
- 请求头中的数据直接使用base64编码,它记录了加密算法
- 有效负载中的数据使用base64编码
- 使用请求头和请求体中的数据,根据密钥,生成签名
验证jwt的流程:
- 使用base64算法,解码header、body
- 使用密钥,对header和body进行签名,和jwt中的签名对比是否一致,如果一致,证明签名没有问题
案例: 之前生成的token,header和payload部分,可以直接使用base64解码出原始数据。
java
import java.util.Base64;
public class Base64Test {
@Test
public void test1() {
byte[] bytes = Base64.getDecoder().decode("eyJhbGciOiJIUzI1NiJ9");
String s = new String(bytes);
System.out.println("s = " + s); // {"alg":"HS256"}
}
@Test
public void test2() {
byte[] bytes = Base64.getDecoder().decode("eyJleHAiOjE3ODgxNjY3NjIsInVzZXJJZCI6MTc0MTI5MzE2NzE4Mjg4MDc3NSwiaWF0IjoxNzg4MDgwMzYyfQ");
String s = new String(bytes);
System.out.println("s = " + s); // {"exp":1788166762,"userId":1741293167182880775,"iat":1788080362}
}
}
实战案例
密钥轮转
在入门案例中,密钥是直接写死在代码里的,生产环境下,这非常不安全,密钥必须定期更换才可以。
具体的策略: 使用定时任务,每天或每周,定时更新密钥,把更新的密钥存储到数据库中,同时在生成的token中,在header部分,记录当前token使用哪个密钥进行签名的。验签时,从header中获取密钥的id,然后从数据库或者缓存中获取密钥本身,然后验签。 header部分类似于 {"keyId": "123", "alg": "aes"}
参考: https://juejin.cn/post/7560905838074232895
token续期
token中不再存储过期时间,过期时间由redis中key的有效期来决定,在拦截器中,每次拦截一次,就更新key的过期时间。
双token
如果第三方拿到用户的token,那么它将可以直接冒充用户,为了防止token丢失,并且降低丢失后的风险,可以使用双token机制。
token分为两个: refreshToken、accessToken。 refreshToken过期时间较长,例如一周、一个月,accessToken的过期时间较短,例如15分钟,用户日常操作,使用accessToken,如果accessToken过期,使用refreshToken,来获取新的accessToken。如果发现refreshToken将要过期,可以静默刷新,优化用户体验。