jwt使用简介

简介

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将要过期,可以静默刷新,优化用户体验。

相关推荐
顶点多余1 小时前
那些在算法中适合巩固的知识点---1
java·前端·算法
AI人工智能+电脑小能手1 小时前
大白话说Java设计模式-46-责任链模式(业务实战篇)
java·设计模式·责任链模式·风控校验·servlet filter·spring interceptor·链式处理
jay神1 小时前
【计算机毕业设计】基于SpringBoot的程序教学辅助系统
java·前端·vue.js·spring boot·后端·毕业设计·课程设计
2601_962066492 小时前
【Sql Server】Update中的From语句,以及常见更新操作方式
android·java·数据库
yaoxin5211233 小时前
507. Java 反射 - 在 BeanFactory 中实现依赖注入
java·开发语言
OPEN-F3 小时前
C++模板教程:变参模板、折叠表达式与SFINAE
java·开发语言·c++
2601_962072214 小时前
SpringMVC进阶(自定义拦截器以及异常处理)
java
2601_962073814 小时前
Spring全面详解(基础版)
java·后端·spring
谢亮_vipxieliang5 小时前
ValidX分组验证详解:不同场景使用不同验证规则
java·spring boot·后端·spring cloud·eclipse·hibernate