欢迎您访问 最编程 本站为您分享编程语言代码,编程技术文章!
您现在的位置是: 首页

用 Spring Boot、JWT 和 Vue 开发一个简单易用的基于令牌的身份验证登录系统

最编程 2024-07-28 10:49:44
...

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

基于 session 认证所显露的问题

session: 每个用户经过我们的应用认证之后,我们的应用都要在服务端做一次记录,以方便用户下次请求的鉴别,通常而言 session 都是保存在内存中,而随着认证用户的增多,服务端的开销会明显增大。

扩展性: 用户认证之后,服务端做认证记录,如果认证的记录被保存在内存中的话,这意味着用户下次请求还必须要请求在这台服务器上,这样才能拿到授权的资源,这样在分布式的应用上,相应的限制了负载均衡器的能力。这也意味着限制了应用的扩展能力。

CSRF: 因为是基于 cookie 来进行用户识别的, cookie 如果被截获,用户就会很容易受到跨站请求伪造的攻击。

基于 token 的鉴权机制

基于 token 的鉴权机制类似于 http 协议也是无状态的,它不需要在服务端去保留用户的认证信息或者会话信息。这就意味着基于 token 认证机制的应用不需要去考虑用户在哪一台服务器登录了,这就为应用的扩展提供了便利。

流程:

  • 用户使用用户名密码来请求服务器
  • 服务器进行验证用户的信息
  • 服务器通过验证发送给用户一个 token
  • 客户端存储 token,并在每次请求时附送上这个 token 值
  • 服务端验证 token 值,并返回数据

本文详细整理总结了使用 Spring Boot + JWT + Vue 实现前后端分离登录并基于 token 认证的功能。

JWT (Json Web Token)

JWT 介绍

JWT (JSON Web Token) 是目前最流行的跨域认证解决方案,是一种基于 Token 的认证授权机制。 从 JWT 的全称可以看出,JWT 本身也是 Token,一种规范化之后的 JSON 结构的 Token。

JWT 自身包含了身份验证所需要的所有信息,因此,我们的服务器不需要存储 Session 信息。这显然增加了系统的可用性和伸缩性,大大减轻了服务端的压力。可以看出,JWT 更符合设计 RESTful API 时的「Stateless(无状态)」原则 。并且, 使用 JWT 认证可以有效避免 CSRF 攻击,因为 JWT 一般是存在于 localStorage 中,使用 JWT 进行身份验证的过程中是不会涉及到 Cookie 的。

JWT 由哪些部分组成?

oauth-vs-jwt

JWT 本质上就是一组字串,通过(.)切分成三个为 Base64 编码的部分:

  • Header : 描述 JWT 的元数据,定义了生成签名的算法以及 Token 的类型。
  • Payload : 用来存放实际需要传递的数据
  • Signature (签名) :服务器通过 Payload、Header 和一个密钥 (Secret) 使用 Header 里面指定的签名算法(默认是 HMAC SHA256)生成。

JWT 通常是这样的:xxxxx.yyyyy.zzzzz

示例:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

可以在 jwt.io 这个网站上对其 JWT 进行解码,解码之后得到的就是 Header、Payload、Signature 这三部分。

Header 和 Payload 都是 JSON 格式的数据,Signature 由 Payload、Header 和 Secret (密钥) 通过特定的计算公式和加密算法得到。

生成 JWT

1、Maven 依赖 pom.xml

<!-- JWT依赖 -->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.7.0</version>
</dependency>
<dependency>
    <groupId>com.auth0</groupId>
    <artifactId>java-jwt</artifactId>
    <version>3.4.0</version>
</dependency>

2、编写 jwt 工具类 JwtUtil.java,实现生成和解析 token:

// Jwt 工具类
public class JwtUtil {

    //private static long time = 1000*10;        // token 有效期为10秒
    private static long time = 1000*60*60*24;   // token 有效期为一天
    private static String signature = "admin";

    // 生成token ,三个参数是我实体类的字段,可根据自身需求来传,一般只需要用户id即可
    public static String createJwtToken(String operNo, String operName, String organNo){
        JwtBuilder builder = Jwts.builder();
        String jwtToken = builder
            // header
            .setHeaderParam("typ","JWT")
            .setHeaderParam("alg","HS256")
            // payload 载荷
            .claim("username", "admin")
            .claim("role", "admin")
            .claim("date", new Date())
            .setSubject("admin-test")
            .setExpiration(new Date(System.currentTimeMillis() + time))
            .setId(UUID.randomUUID().toString())
            // signature 签名信息
            .signWith(SignatureAlgorithm.HS256, signature)
            // 用.拼接
            .compact();
        return jwtToken;
    }

    // 验证 token 是否还有效,返回具体内容
    public static Claims checkToken(String token){
        if (token == null){
            return null;
        }
        JwtParser parser = Jwts.parser();
        try {
            Jws<Claims> claimsJws = parser.setSigningKey(signature).parseClaimsJws(token);
            Claims claims = claimsJws.getBody();
            System.out.println(claims.get("username"));
            System.out.println(claims.get("role"));
            System.out.println(claims.getId());
            System.out.println(claims.getSubject()); // 签名
            System.out.println(claims.getExpiration()); // 有效期
            // 如果解析 token 正常,返回 claims
            return claims;
        } catch (Exception e) {
            // 如果解析 token 抛出异常,返回 null
            return null;
        }
    }
}

在请求处理之前,切面的给每个请求做一个校验,校验请求头中的 token 信息是否有效且信息正确:

编写自定义请求拦截器 Interceptor.java

@Component // @Component注解一定要加上
public class Interceptor implements HandlerInterceptor {
    // 注入redis
    @Autowired
    StringRedisTemplate redisTemplate;

    // 处理请求之前执行
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 取出请求头中Authorization的信息,就是token内容,接下来就是各种判断
        String requestToken = request.getHeader("Authorization");
        if(!StringUtils.isEmpty(requestToken)){
            Claims claims = JwtUtil.checkToken(request.getHeader("Authorization"));
            if (claims != null) {
                String token = redisTemplate.opsForValue().get("operToken"+claims.get("operNo"));
                if(Boolean.TRUE.equals(redisTemplate.hasKey("operToken" + claims.get("operNo")))){
                    if(requestToken.equals(token)){
                        // token正确
                        return true;
                    }else {
                        // token错误,判为并发登录,挤下线
                        // 对应的修改响应头的状态,用于前端判断做出相应的策略
                        response.setStatus(411);
                        return false;
                    }
                }else {
                    // token不存在于redis中,已过期
                    response.setStatus(410);
                    return false;
                }
            }
            // 解析token中的用户信息claims为null
            response.setStatus(409);
            return false;
        }
        // requestToken为空
        response.setStatus(409);
        return false;
    }

    // 处理请求之后执行
    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        System.out.println("处理请求之后执行");
    }

}

实现 WebMvcConfigurer 接口,重写实现其添加拦截器方法:

@Component
public class InterceptorConfig  implements WebMvcConfigurer {
    // 注入自定义拦截器
    @Autowired
    private Interceptor interceptor;

    // 重写添加拦截器方法
    @Override
    public void addInterceptors(InterceptorRegistry registry){  // InterceptorRegistry 为拦截器注册对象
        registry.addInterceptor(interceptor)  // 注册自定义拦截器
            .addPathPatterns("/sys/basic-api/**")// 拦截的路径
            .excludePathPatterns(); // 不拦截的路径
    }
}

Spring Boot

简单实现控制层 UserController.java

public class UserController {
    private final String USERNAME = "admin";
    private final String PASSWORD = "123456";
    
    @GetMapping("/login")
    public User login(User user) {
        if (USERNAME.equals(user.getUsername()) && PASSWORD.equals(user.getPassword())) {
            // 添加 token
            user.setToken(JwtUtil.createJwtToken());
            return user;
        }
        return null;
    }
    
    @GetMapping("/checkToken")
    public Claims checkToken(HttpServletRequest request) {
        String token = request.getHeader("token");
        return JwtUtil.checkToken(token);
    }
}

Tips: 注意跨域问题

Vue

验证 token 合法性:

router.beforeEach((to, from, next) => {
    if (to.path.startsWith('/login')) {
        window.localstorage.removeItem('access-admin')
        next()
    } else {
        let admin = JSON.parse(window.localstorage.getItem('access-admin'))
        if (!admin) {
            next({path:'/login'})
        } else {
            // 校验 token 合法性
            axios({
                url: 'http://localhost:8080/checkToken',
                method: 'get',
                headers: {
                    token: admin.token
                }
            }).then(res => {
                if (!res.data) {
                    console.log('校验失败')
                    next({path: '/error'})
                }
            })
            next()
        }
    }
})

实现登录请求响应:

axios.get('http://localhost:8080/login', {params: _this.formData}).then(res => {
    if (res.data != null) {
        localStorage.setItem('access-admin', JSON.stringify(res.data))
        _this.$router.replace({path: '/home'})
    }
})