JWT技术实现系统间的单点登录&验证登录信息

/ 技术 / 2 条站内评论 / 1211浏览

前言

单点登录(single sign on),简称sso。它的定义是多个应用系统间,只需要登录一次就可以访问所有相互信任的应用系统。下面介绍用jwt技术如何来实现单点登录。

Token

什么是token

Token是服务端生成的一串字符串,以作客户端进行请求的一个令牌,服务端根据令牌获取客户端的身份信息。 
举个栗子:

http://www.example.com/demo?token=15qwc87wq336scwWFSC2sc1w

为什么要用token

互联网时代信息安全验证放在首要的地位,对于敏感的信息(如账号密码等等)明文的出现次数越少越好。

我们都知道,HTTP协议是一种无状态的协议,这就意味着当我们向应用服务端提供了用户名和密码进行用户认真后,下次请求还是要再进行用户认证,而且服务端又不知道发起请求是谁。按照这个思维,假如每个请求都带有敏感信息,即使进行加密,但是这就增加暴露频率,并且服务端频繁对每个请求的身份信息进行数据查询验证,这是个很大的开销,显然不是我们想要的结果。

为了我们登录后让服务端“记住”我,下次发出请求服务端识别哪个用户发送的,token令牌能解决http无状态的问题,这时候你会觉得SESSION不也一样吗?别急,下面会说到。token就像我们的身份证,客户端一旦得到服务端响应的token后本地缓存,之后每次请求带上token就行了,重要的是开发者可以在token上自定义信息(如UUID),并且是加密的,服务端就减少数据查询验证身份的开销了。

与传统的SESSION有什么区别

如果您还不了解session,请先自行百度学习,这里我简单介绍下:

session 是一种HTTP存储机制,目的是为无状态的HTTP提供的持久机制。

为什么要告别session?

alt
有这样一个场景,系统的数据量达到千万级,需要几台服务器部署,当一个用户在其中一台服务器登录后,用session保存其登录信息,其他服务器怎么知道该用户登录了?(单点登录),当然解决办法有,可以用spring-session。如果该系统同时为移动端服务呢?移动端通过url向后台要数据,如果用session,通过sessionId识别用户,万一sessionId被截获了,别人可以利用sessionId向后台要数据,就有安全隐患了。所以有必要跟session说拜拜了。服务端不需要存储任何用户的信息,用户的验证应该放在客户端,jwt就是这种方式!

token身份验证流程

服务端中跟Token有关的问题

JWT(Json Web Token)

官网地址

https://jwt.io/

jwt github

https://github.com/jwtk/jjwt

什么是JWT(Json Web Token)

Json web token (JWT), 是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准。该token被设计为紧凑且安全的,特别适用于分布式站点的单点登录(SSO)场景。JWT的声明一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源,也可以增加一些额外的其它业务逻辑所必须的声明信息,该token也可直接被用于认证,也可被加密。也就是说JWT是Token的一种表述性声明规范。

如果你不清楚JSON请自行学习

JWT长什么样子

eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiI3OHNhd2RmZjUiLCJzdWIiOiJ4aWFvdGlhbnRpYW4iLCJpYXQiOjE0OTgwMzE0NDIsImlzcyI6IjEyMi4xMTQuMjE0LjE0NyIsImV4cCI6MTQ5ODAzMjY0Mn0.0h_kDhyZLhnt8TRgbLsOnVT8eOUAqgFTEZP-XgIGuA

上面字符串都是用Base64编码后,发现结构类似:xxx.yyy.zzz

JWT的结构

JWT包含了三个部分,分别用.分割开来,分别是:

JWT签发与验证流程

  1. 服务端根据业务需求声明 Header 和 Playload
  2. 将 Header 和 Playload 分别生成 Json 字符串
  3. Header 和 Playload 分别进行base64编码,用 . 分隔开来,组成 JWT 的第一和第二部分,例如: 
    eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiI3OHNhd2RmZjUiLCJzdWIiOiJ4aWFvdGlhbnRpYW4iLCJpYXQiOjE0OTgwMzE0NDIsImlzcyI6IjEyMi4xMTQuMjE0LjE0NyIsImV4cCI6MTQ5ODAzMjY0Mn0
  4. 得到第3步生成的字符串,根据 Header 里面 alg 指定的签名算法生成出来形成 JWT 的 Signature 部分。算法不同,签名结果不同,常用的值以及对应的算法如下: 
  5. 第4步生成的 Signature 组成 JWT 的第3部分,用 . 分隔组成完整的 JWT: 
    eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiI3OHNhd2RmZjUiLCJzdWIiOiJ4aWFvdGlhbnRpYW4iLCJpYXQiOjE0OTgwMzE0NDIsImlzcyI6IjEyMi4xMTQuMjE0LjE0NyIsImV4cCI6MTQ5ODAzMjY0Mn0.0h_kDhyZLhnt8TRgbLsOnVT8eOUAqgFTEZP-XgIGuA
  6. 到这里服务端签发流程结束
  7. 客户端得到 JWT 后存起来,每次请求带上 JWT 字符串
  8. 服务端收到请求携带的 JWT ,开始进入验证流程
  9. 对 JWT 的完整性进行验证,使用 base64 对 Header 进行解码,知道 JWT 使用什么签名
  10. 重复第4步对 Header 和 Playload 再做一次签名
  11. 比较这个签名是否与 JWT 本身携带的签名完全相同,只要不同,就可以认为该 JWT 是被篡改过的,验证失败,验证流程结束
  12. 如果相同,使用 base64 对 Playload 进行解码,再进行业务逻辑处理,此时验证成功,验证结束。

alt

alt

注意

PS: 

实际的业务流程

1.需要调用者先使用用户名和密码去签定身份

2.鉴定成功,服务器返回一个 Token

3.调用者之后再调用其他 API 时就在 HTTP Authorization Header 中带着这个 Token

目前采用的方案

1.使用 jjwt 生成 Token ,保存在 Redis 中,以用户名作为 Key

2.通过设置 Redis 键的 TTL 来实现 Token 自动过期

3.通过在 Servlet Filter 中拦截请求判断 Token 是否有效

4.由于 Redis 是基于 Key-Value 进行存储,因此可以实现新的 Token 将覆盖旧的 Token ,保证一个用户在一个时间段只有一个可用 Token

代码实现

maven依赖

        <dependency>
            <groupId>com.auth0</groupId>
            <artifactId>java-jwt</artifactId>
            <version>2.2.0</version>
        </dependency>

JWT工具类

import com.auth0.jwt.JWTSigner;
    import com.auth0.jwt.JWTVerifier;
    import com.auth0.jwt.internal.com.fasterxml.jackson.databind.ObjectMapper;

    import java.util.HashMap;
    import java.util.Map;

    public class JWT {

        private static final String SECRET = "XX#$%()(#*!()!KL<><MQLMNQNQJQK sdfkjsdrow32234545fdf>?N<:{LWPW";

        private static final String EXP = "exp";

        private static final String PAYLOAD = "payload";

        public static <T> String sign(T object, long maxAge) {
            try {
                final JWTSigner signer = new JWTSigner(SECRET);
                final Map<String, Object> claims = new HashMap<String, Object>();
                ObjectMapper mapper = new ObjectMapper();
                String jsonString = mapper.writeValueAsString(object);
                claims.put(PAYLOAD, jsonString);
                claims.put(EXP, System.currentTimeMillis() + maxAge);
                return signer.sign(claims);
            } catch(Exception e) {
                return null;
            }
        }

        public static<T> T unsign(String jwt, Class<T> classT) {
            final JWTVerifier verifier = new JWTVerifier(SECRET);
            try {
                final Map<String,Object> claims= verifier.verify(jwt);
                if (claims.containsKey(EXP) && claims.containsKey(PAYLOAD)) {
                    long exp = (Long)claims.get(EXP);
                    long currentTimeMillis = System.currentTimeMillis();
                    if (exp > currentTimeMillis) {
                        String json = (String)claims.get(PAYLOAD);
                        ObjectMapper objectMapper = new ObjectMapper();
                        return objectMapper.readValue(json, classT);
                    }
                }
                return null;
            } catch (Exception e) {
                return null;
            }
        }
    }

用户单点登录

//token三十分钟失效
private static final long TOKEN_MAX_AGE = 30L * 60L * 1000L;
@PostMapping(value = "/login")
        public BaseResponse<Map<String, Object>> login(User user, HttpServletResponse response) {
            BaseResponse<Map<String, Object>> baseResponse = new BaseResponse<>();
            Map<String, Object> map = Maps.newHashMap();
            //校验用户身份
            List<User> userList = userDao.findByName(user.getName());
            if (userList.isEmpty()) {
                baseResponse.setError("用户名不存在!");
            } else {
                User info = userList.get(0);
                if (info.getPwd().equals(user.getPwd())) {
    //                info.setPwd("");//生产环境可以移除密码
                    //生成token
                    String token = JWT.sign(info, TOKEN_MAX_AGE );
                    map.put("user", info);
                    map.put("token", token);
                    baseResponse.setSuccess(map);
                    //token存入redis,30后过期返回null(后面我们会写拦截器进行校验redis中的token是否失效)
                    ValueOperations<String, String> ops = stringRedisTemplate.opsForValue();
                    //使用 jjwt 生成 Token ,保存在 Redis 中,以UUID作为 Key
                    ops.set(info.getUuid(), token, 30, TimeUnit.MINUTES);

                    //token存入Cookie
                    Cookie cookie = new Cookie("token", token);
                    // 设置为30min
                    cookie.setMaxAge(30 * 60);
                    cookie.setPath("/");
                    response.addCookie(cookie);
                    logger.info("token存入Cookie&Redis:{}", token);
                } else {
                    baseResponse.setError("密码有误!");
                }
            }

            return baseResponse;
        }

效果如下
alt
返回的Cookie,当登录成功后,每次请求都会带着这个token
alt

过滤器校验token

@Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        //获取Cookie中的token
        String token = CookieUtils.getCookieValue((HttpServletRequest) servletRequest, "token", true);
        //判断token是否存在
        if (!StringUtils.isBlank(token)) {
            //获取token中的用户信息
            User user = JWT.unsign(token, User.class);
            //通过UUID KEY查redis是否存在token(是否过期)
            String tokenInfo = stringRedisTemplate.opsForValue().get(user.getUuid());
            //如果tokenInfo没有过期
            if (!StringUtils.isBlank(tokenInfo)) {
                //如果token一致
                if (tokenInfo.equals(token)) {
                    //放行
                    filterChain.doFilter(servletRequest, servletResponse);
                    return;
                }
            }
        }
        //如果token不存在,但是请求url是登录,放行去登录
        if (((HttpServletRequest) servletRequest).getServletPath().equals("/user/login")) {
            //放行
            filterChain.doFilter(servletRequest, servletResponse);
            return;
        }
        //如果token不存在 1.身份过期,重新登录2.未登录
        RequestDispatcher dispatcher = servletRequest.getRequestDispatcher("/user/error");
        dispatcher.forward(servletRequest, servletResponse);
    }

如果未登录访问其他接口会拦截并转发给error接口进行处理
alt

退出

    /**
     * @author shouliang.wang
     * @date 2018/5/8 15:01
     * @param request 从Cookie中获取token
     * @return
     * 用户退出
     */
    @GetMapping(value = "/logout")
    public BaseResponse<String> logout(HttpServletRequest request) {
        //获取Cookie中的token
        String token = CookieUtils.getCookieValue(request, "token", true);
        if (!StringUtils.isBlank(token)) {
            //获取token中用户信息
            User user = JWT.unsign(token, User.class);
            //通过UUID key删除Redis中的token
            Boolean delete = stringRedisTemplate.delete(user.getUuid());
            if (delete){
                logger.info("用户已退出登录:{}",user.getName());
            }
        }
        BaseResponse<String> response = new BaseResponse<>();
        response.setSuccess("OK");
        return response;
    }

alt

参考

以上资料部分参考: https://blog.csdn.net/wangcantian/article/details/74199762

https://www.v2ex.com/t/320710

https://www.cnblogs.com/zhengshiqiang47/p/7442020.html

  1. 密码都放在里面了,解密后不就拿到了?

    回复
    1. SAn
      @songls

      所以一般不会存敏感信息

      回复
召唤蕾姆
琼ICP备18000156号

鄂公网安备 42011502000211号