• 手把手教你,使用JWT实现单点登录


    参考网址:https://zhuanlan.zhihu.com/p/141065758

    JSON Web Token(JWT)是目前最流行的跨域身份验证解决方案之一,今天我们一起来揭开它神秘的面纱!

    一、故事起源

    说起 JWT,我们先来谈一谈基于传统session认证的方案以及瓶颈。

    传统session交互流程,如下图:

     

     

    当浏览器向服务器发送登录请求时,验证通过之后,会将用户信息存入seesion中,然后服务器会生成一个sessionId放入cookie中,随后返回给浏览器。

    当浏览器再次发送请求时,会在请求头部的cookie中放入sessionId,将请求数据一并发送给服务器。

     

     

    服务器就可以再次从seesion获取用户信息,整个流程完毕!

    通常在服务端会设置seesion的时长,例如 30 分钟没有活动,会将已经存放的用户信息从seesion中移除。

    session.setMaxInactiveInterval(30 * 60);//30分钟没活动,自动移除

    同时,在服务端也可以通过seesion来判断当前用户是否已经登录,如果为空表示没有登录,直接跳转到登录页面;如果不为空,可以从session中获取用户信息即可进行后续操作。

     

     

    在单体应用中,这样的交互方式,是没啥问题的。

    但是,假如应用服务器的请求量变得很大,而单台服务器能支撑的请求量是有限的,这个时候就容易出现请求变慢或者OOM。

    解决的办法,要么给单台服务器增加配置,要么增加新的服务器,通过负载均衡来满足业务的需求。

    如果是给单台服务器增加配置,请求量继续变大,依然无法支撑业务处理。

    显而易见,增加新的服务器,可以实现无限的水平扩展。

    但是增加新的服务器之后,不同的服务器之间的sessionId是不一样的,可能在A服务器上已经登录成功了,能从服务器的session中获取用户信息,但是在B服务器上却查不到session信息,此时肯定无比的尴尬,只好退出来继续登录,结果A服务器中的session因为超时失效,登录之后又被强制退出来要求重新登录,想想都挺尴尬~~

    面对这种情况,几位大佬于是合起来商议,想出了一个token方案。

     

     

    将各个应用程序与内存数据库redis相连,对登录成功的用户信息进行一定的算法加密,生成的ID被称为token,将token还有用户的信息存入redis;等用户再次发起请求的时候,将token还有请求数据一并发送给服务器,服务端验证token是否存在redis中,如果存在,表示验证通过,如果不存在,告诉浏览器跳转到登录页面,流程结束。

    token方案保证了服务的无状态,所有的信息都是存在分布式缓存中。基于分布式存储,这样可以水平扩展来支持高并发。

    当然,现在springboot还提供了session共享方案,类似token方案将session存入到redis中,在集群环境下实现一次登录之后,每个服务器都可以获取到用户信息。

    二、JWT是什么

    上文中,我们谈到的session还有token的方案,在集群环境下,他们都是靠第三方缓存数据库redis来实现数据的共享。

    那有没有一种方案,不用缓存数据库redis来实现用户信息的共享,以达到一次登录,处处可见的效果呢?

    答案肯定是有的,就是我们今天要介绍的JWT!

    JWT全称JSON Web Token,实现过程简单的说就是用户登录成功之后,将用户的信息进行加密,然后生成一个token返回给客户端,与传统的session交互没太大区别。

    交互流程如下:

     

     

    唯一的不同点就是:token存放了用户的基本信息,更直观一点就是将原本放入redis中的用户数据,放入到token中去了!

    这样一来,客户端、服务端都可以从token中获取用户的基本信息,既然客户端可以获取,肯定是不能存放敏感信息的,因为浏览器可以直接从token获取用户信息。

    JWT具体长什么样呢?

    JWT是由三段信息构成的,将这三段信息文本用.链接一起就构成了JWT字符串。就像这样:

    eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
    • 第一部分:我们称它为头部(header),用于存放token类型和加密协议,一般都是固定的;
    • 第二部分:我们称其为载荷(payload),用户数据就存放在里面;
    • 第三部分:是签证(signature),主要用于服务端的验证;

    1、header

    JWT的头部承载两部分信息:

    • 声明类型,这里是JWT;
    • 声明加密的算法,通常直接使用 HMAC SHA256;

    完整的头部就像下面这样的JSON:

    • {
    • 'typ': 'JWT',
    • 'alg': 'HS256'
    • }

    使用base64加密,构成了第一部分。

    eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9

    2、playload

    载荷就是存放有效信息的地方,这些有效信息包含三个部分:

    • 标准中注册的声明;
    • 公共的声明;
    • 私有的声明;

    其中,标准中注册的声明 (建议但不强制使用)包括如下几个部分 :

    • iss: jwt签发者;
    • sub: jwt所面向的用户;
    • aud: 接收jwt的一方;
    • exp: jwt的过期时间,这个过期时间必须要大于签发时间;
    • nbf: 定义在什么时间之前,该jwt都是不可用的;
    • iat: jwt的签发时间;
    • jwt的唯一身份标识,主要用来作为一次性token,从而回避重放攻击;

    公共的声明部分:公共的声明可以添加任何的信息,一般添加用户的相关信息或其他业务需要的必要信息,但不建议添加敏感信息,因为该部分在客户端可解密。

    私有的声明部分:私有声明是提供者和消费者所共同定义的声明,一般不建议存放敏感信息,因为base64是对称解密的,意味着该部分信息可以归类为明文信息。

    定义一个payload:

    • {
    • "sub": "1234567890",
    • "name": "John Doe",
    • "admin": true
    • }

    然后将其进行base64加密,得到Jwt的第二部分:

    eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9

    3、signature

    jwt的第三部分是一个签证信息,这个签证信息由三部分组成:

    • header (base64后的);
    • payload (base64后的);
    • secret (密钥);

    这个部分需要base64加密后的header和base64加密后的payload使用.连接组成的字符串,然后通过header中声明的加密方式进行加盐secret组合加密,然后就构成了jwt的第三部分。

    //javascriptvar encodedString = base64UrlEncode(header) + '.' + base64UrlEncode(payload);var signature = HMACSHA256(encodedString, '密钥');

    加密之后,得到signature签名信息。

    TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

    将这三部分用.连接成一个完整的字符串,就构成了最终的jwt:

    //jwt最终格式eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

    这个只是通过javascript实现的一个演示,JWT的签发和密钥的保存都是在服务端来完成。

    secret用来进行jwt的签发和jwt的验证,所以,在任何场景都不应该流露出去。

    三、实战

    介绍了这么多,怎么实现呢?废话不多说,下面我们直接开撸!

    • 创建一个springboot项目,添加JWT依赖库
    <!-- jwt支持 --><dependency>    <groupId>com.auth0</groupId>    <artifactId>java-jwt</artifactId>    <version>3.4.0</version></dependency>
    • 然后,创建一个用户信息类,将会通过加密存放在token中
    @Data@EqualsAndHashCode(callSuper = false)@Accessors(chain = true)public class UserToken implements Serializable {    private static final long serialVersionUID = 1L;    /**     * 用户ID     */    private String userId;    /**     * 用户登录账户     */    private String userNo;    /**     * 用户中文名     */    private String userName;}
    • 接着,创建一个JwtTokenUtil工具类,用于创建token、验证token
    public class JwtTokenUtil {	//定义token返回头部    public static final String AUTH_HEADER_KEY = "Authorization";	//token前缀    public static final String TOKEN_PREFIX = "Bearer ";	//签名密钥    public static final String KEY = "q3t6w9z$C&F)J@NcQfTjWnZr4u7x";		//有效期默认为 2hour    public static final Long EXPIRATION_TIME = 1000L*60*60*2;    /**     * 创建TOKEN     * @param content     * @return     */    public static String createToken(String content){        return TOKEN_PREFIX + JWT.create()                .withSubject(content)                .withExpiresAt(new Date(System.currentTimeMillis() + EXPIRATION_TIME))                .sign(Algorithm.HMAC512(KEY));    }    /**     * 验证token     * @param token     */    public static String verifyToken(String token) throws Exception {        try {            return JWT.require(Algorithm.HMAC512(KEY))                    .build()                    .verify(token.replace(TOKEN_PREFIX, ""))                    .getSubject();        } catch (TokenExpiredException e){            throw new Exception("token已失效,请重新登录",e);        } catch (JWTVerificationException e) {            throw new Exception("token验证失败!",e);        }    }}
    • 编写配置类,允许跨域,并且创建一个权限拦截器
    @Slf4j@Configurationpublic class GlobalWebMvcConfig implements WebMvcConfigurer {	   /**     * 重写父类提供的跨域请求处理的接口     * @param registry     */    @Override    public void addCorsMappings(CorsRegistry registry) {        // 添加映射路径        registry.addMapping("/**")                // 放行哪些原始域                .allowedOrigins("*")                // 是否发送Cookie信息                .allowCredentials(true)                // 放行哪些原始域(请求方式)                .allowedMethods("GET", "POST", "DELETE", "PUT", "OPTIONS", "HEAD")                // 放行哪些原始域(头部信息)                .allowedHeaders("*")                // 暴露哪些头部信息(因为跨域访问默认不能获取全部头部信息)                .exposedHeaders("Server","Content-Length", "Authorization", "Access-Token", "Access-Control-Allow-Origin","Access-Control-Allow-Credentials");    }    /**     * 添加拦截器     * @param registry     */    @Override    public void addInterceptors(InterceptorRegistry registry) {        //添加权限拦截器        registry.addInterceptor(new AuthenticationInterceptor()).addPathPatterns("/**").excludePathPatterns("/static/**");    }}
    • 使用AuthenticationInterceptor拦截器对接口参数进行验证
    @Slf4jpublic class AuthenticationInterceptor implements HandlerInterceptor {    @Override    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {		// 从http请求头中取出token        final String token = request.getHeader(JwtTokenUtil.AUTH_HEADER_KEY);        //如果不是映射到方法,直接通过        if(!(handler instanceof HandlerMethod)){            return true;        }        //如果是方法探测,直接通过        if (HttpMethod.OPTIONS.equals(request.getMethod())) {            response.setStatus(HttpServletResponse.SC_OK);            return true;        }        //如果方法有JwtIgnore注解,直接通过        HandlerMethod handlerMethod = (HandlerMethod) handler;        Method method=handlerMethod.getMethod();        if (method.isAnnotationPresent(JwtIgnore.class)) {            JwtIgnore jwtIgnore = method.getAnnotation(JwtIgnore.class);            if(jwtIgnore.value()){                return true;            }        }        LocalAssert.isStringEmpty(token, "token为空,鉴权失败!");        //验证,并获取token内部信息        String userToken = JwtTokenUtil.verifyToken(token);				//将token放入本地缓存        WebContextUtil.setUserToken(userToken);        return true;    }    @Override    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {        //方法结束后,移除缓存的token        WebContextUtil.removeUserToken();    }}
    • 最后,在controller层用户登录之后,创建一个token,存放在头部即可
    /** * 登录 * @param userDto * @return */@JwtIgnore@RequestMapping(value = "/login", method = RequestMethod.POST, produces = {"application/json;charset=UTF-8"})public UserVo login(@RequestBody UserDto userDto, HttpServletResponse response){    //...参数合法性验证    //从数据库获取用户信息    User dbUser = userService.selectByUserNo(userDto.getUserNo);    //....用户、密码验证    //创建token,并将token放在响应头    UserToken userToken = new UserToken();    BeanUtils.copyProperties(dbUser,userToken);    String token = JwtTokenUtil.createToken(JSONObject.toJSONString(userToken));    response.setHeader(JwtTokenUtil.AUTH_HEADER_KEY, token);    //定义返回结果    UserVo result = new UserVo();    BeanUtils.copyProperties(dbUser,result);    return result;}

    到这里基本就完成了!

    其中AuthenticationInterceptor中用到的JwtIgnore是一个注解,用于不需要验证token的方法上,例如验证码的获取等等。

    @Target({ElementType.METHOD, ElementType.TYPE})@Retention(RetentionPolicy.RUNTIME)public @interface JwtIgnore {    boolean value() default true;}

    而WebContextUtil是一个线程缓存工具类,其他接口通过这个方法即可从token中获取用户信息

    public class WebContextUtil {    //本地线程缓存token    private static ThreadLocal<String> local = new ThreadLocal<>();    /**     * 设置token信息     * @param content     */    public static void setUserToken(String content){        removeUserToken();        local.set(content);    }    /**     * 获取token信息     * @return     */    public static UserToken getUserToken(){        if(local.get() != null){            UserToken userToken = JSONObject.parseObject(local.get() , UserToken.class);            return userToken;        }        return null;    }    /**     * 移除token信息     * @return     */    public static void removeUserToken(){        if(local.get() != null){            local.remove();        }    }}

    最后,启动项目,我们来用postman测试一下,看看头部返回结果。

     

     

    我们把返回的信息提取处理,使用浏览器的base64对前两个部分进行解密。

    • 第一部分,也就是header,结果如下:

     

     

    • 第二部分,也就是playload,结果如下:

     

     

    可以很清晰的看到,头部、载荷的信息都可以通过base64解密出来。

    所以,一定别在token中存放敏感信息!

    当我们需要请求其它服务接口时,只需要在请求头部headers中加入Authorization参数即可。

     

     

    当权限拦截器验证通过之后,在接口方法中只需要通过WebContextUtil工具类就可以获取用户信息。

    //获取用户token信息UserToken userToken = WebContextUtil.getUserToken();

    四、总结

    JWT相比session方案,因为json的通用性,所以JWT是可以进行跨语言支持的,像JAVA、JavaScript、PHP等很多语言都可以使用,而session方案只针对JAVA。

    因为有了payload部分,所以JWT可以存储一些其他业务逻辑所必要的非敏感信息。

    同时,保护好服务端secret私钥非常重要,因为私钥可以对数据进行验证、解密!

    如果可以,请使用https协议!

  • 相关阅读:
    Dispose() C# 优化内存
    C#对图片文件的压缩、裁剪操作初探
    C#如何调用COM
    rtf格式的一些说明,转载的
    IStream 接口
    NET RichTextBox控件如何可以插入图像
    AutoCAD如何打印
    AutoCAD如何编辑块,打散块
    AutoCAD菜单加载失败 找不到文件mnc 怎么办
    AE After Effect 渲染如何输出设置
  • 原文地址:https://www.cnblogs.com/bruce1992/p/15565877.html
Copyright © 2020-2023  润新知