JWT实现单点登录【项目实现】

如果还不了解JWT的,可以看
JWT实现单点登录【JWT介绍】

下面以实际项目中的应用分析
首先看一下大致的数据流图:


JWT SSO

主要有以下三步:

1、项目一开始我先封装了一个JWTHelper工具包,主要提供了生成JWT、解析JWT以及校验JWT的方法,其他还有一些加密相关操作,后面我会以代码的形式介绍下代码。工具包写好后我将打包上传到私服,能够随时依赖下载使用;
2、接下来,我在客户端项目中依赖JWTHelper工具包,并添加Interceptor拦截器,拦截需要校验登录的接口。拦截器中校验JWT有效性,并在response中重新设置JWT的新值;
3、最后在JWT服务端,依赖JWT工具包,在登录方法中,需要在登录校验成功后调用生成JWT方法,生成一个JWT令牌并且设置到response的header中。

下面部分代码介绍
JwtHelper工具类:

package org.vic.common.util;

import java.security.Key;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;

import javax.crypto.spec.SecretKeySpec;
import javax.xml.bind.DatatypeConverter;

import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.vic.common.constant.SecretConstant;
import org.vic.common.web.ValidateLoginInterceptor;

import com.alibaba.fastjson.JSONObject;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtBuilder;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import lombok.extern.slf4j.Slf4j;

/**
 * JWT工具类
 * 
 * @author vic
 *
 */
@Slf4j
@SuppressWarnings("restriction")
public class JwtHelper {

    /**
     * 生成JWT字符串 格式:A.B.C A-header头信息 B-payload 有效负荷 C-signature 签名信息
     * 是将header和payload进行加密生成的
     * 
     * @param userId     用户编号
     * @param userName   用户名
     * @param identities 客户端信息(变长参数),目前包含浏览器信息,用于客户端拦截器校验,防止跨域非法访问
     * @return
     */
    public static String generateJWT(String userId, String userName, String... identities) {
        // 签名算法,选择SHA-256
        SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
        // 获取当前系统时间
        long nowTimeMillis = System.currentTimeMillis();
        Date now = new Date(nowTimeMillis);
        // 将BASE64SECRET常量字符串使用base64解码成字节数组
        byte[] apiKeySecretBytes = DatatypeConverter.parseBase64Binary(SecretConstant.BASE64SECRET);
        // 使用HmacSHA256签名算法生成一个HS256的签名秘钥Key
        Key signingKey = new SecretKeySpec(apiKeySecretBytes, signatureAlgorithm.getJcaName());
        // 添加构成JWT的参数
        Map<String, Object> headMap = new HashMap<String, Object>();
        // Header { "alg": "HS256", "typ": "JWT" }
        headMap.put("alg", SignatureAlgorithm.HS256.getValue());
        headMap.put("typ", "JWT");
        JwtBuilder builder = Jwts.builder().setHeader(headMap)
                // Payload { "userId": "1234567890", "userName": "vic", }
                // 加密后的客户编号
                .claim("userId", AESSecretUtil.encryptToStr(userId, SecretConstant.DATAKEY))
                // 客户名称
                .claim("userName", userName)
                // 客户端浏览器信息
                .claim("userAgent", identities[0])
                // Signature
                .signWith(signatureAlgorithm, signingKey);
        // 添加Token过期时间
        if (SecretConstant.EXPIRESSECOND >= 0) {
            long expMillis = nowTimeMillis + SecretConstant.EXPIRESSECOND;
            Date expDate = new Date(expMillis);
            builder.setExpiration(expDate).setNotBefore(now);
        }
        return builder.compact();
    }

    /**
     * 解析JWT 返回Claims对象
     * 
     * @param jsonWebToken
     * @return
     */
    public static Claims parseJWT(String jsonWebToken) {
        Claims claims = null;
        try {
            if (StringUtils.isNotBlank(jsonWebToken)) {
                // 解析jwt
                claims = Jwts.parser().setSigningKey(DatatypeConverter.parseBase64Binary(SecretConstant.BASE64SECRET))
                        .parseClaimsJws(jsonWebToken).getBody();
            } else {
                log.warn("[JWTHelper]-json web token 为空");
            }
        } catch (Exception e) {
            log.error("[JWTHelper]-JWT解析异常:可能因为token已经超时或非法token");
        }
        return claims;
    }

    /**
     * 校验JWT是否有效 返回json字符串的demo:
     * {"freshToken":"A.B.C","userName":"vic","userId":"123", "userAgent":"xxxx"}
     * freshToken-刷新后的jwt userName-客户名称 userId-客户编号 userAgent-客户端浏览器信息
     * 
     * @param jsonWebToken
     * @return
     */
    public static String validateLogin(String jsonWebToken) {
        Map<String, Object> retMap = null;
        Claims claims = parseJWT(jsonWebToken);
        if (claims != null) {
            // 解密客户编号
            String decryptUserId = AESSecretUtil.decryptToStr((String) claims.get("userId"), SecretConstant.DATAKEY);
            retMap = new HashMap<String, Object>();
            // 加密后的客户编号
            retMap.put("userId", decryptUserId);
            // 客户名称
            retMap.put("userName", claims.get("userName"));
            // 客户端浏览器信息
            retMap.put("userAgent", claims.get("userAgent"));
            // 刷新JWT
            retMap.put("freshToken", generateJWT(decryptUserId, (String) claims.get("userName"),
                    (String) claims.get("userAgent"), (String) claims.get("domainName")));
        } else {
            log.warn("[JWTHelper]-JWT解析出claims为空");
        }
        return retMap != null ? JSONObject.toJSONString(retMap) : null;
    }
}

AES加密工具类:

package org.vic.common.util;

import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
import org.vic.common.constant.SecretConstant;

import javax.crypto.*;
import javax.crypto.spec.SecretKeySpec;
import java.io.UnsupportedEncodingException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;

/**
 * AES加密工具类
 * 
 * @author vic
 *
 */
public class AESSecretUtil {
    /**
     * 秘钥的大小
     */
    private static final int KEYSIZE = 128;

    /**
     * AES加密
     * 
     * @param data 待加密内容
     * @param key  加密秘钥
     * @return
     */
    public static byte[] encrypt(String data, String key) {
        if (StringUtils.isNotBlank(data)) {
            try {
                KeyGenerator keyGenerator = KeyGenerator.getInstance("AES");
                // 选择一种固定算法,为了避免不同java实现的不同算法,生成不同的密钥,而导致解密失败
                SecureRandom random = SecureRandom.getInstance("SHA1PRNG");
                random.setSeed(key.getBytes());
                keyGenerator.init(KEYSIZE, random);
                SecretKey secretKey = keyGenerator.generateKey();
                byte[] enCodeFormat = secretKey.getEncoded();
                SecretKeySpec secretKeySpec = new SecretKeySpec(enCodeFormat, "AES");
                Cipher cipher = Cipher.getInstance("AES");// 创建密码器
                byte[] byteContent = data.getBytes("utf-8");
                cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec);// 初始化
                byte[] result = cipher.doFinal(byteContent);
                return result; // 加密
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
        return null;
    }

    /**
     * AES加密,返回String
     * 
     * @param data 待加密内容
     * @param key  加密秘钥
     * @return
     */
    public static String encryptToStr(String data, String key) {

        return StringUtils.isNotBlank(data) ? parseByte2HexStr(encrypt(data, key)) : null;
    }

    /**
     * AES解密
     * 
     * @param data 待解密字节数组
     * @param key  秘钥
     * @return
     */
    public static byte[] decrypt(byte[] data, String key) {
        if (ArrayUtils.isNotEmpty(data)) {
            try {
                KeyGenerator keyGenerator = KeyGenerator.getInstance("AES");
                // 选择一种固定算法,为了避免不同java实现的不同算法,生成不同的密钥,而导致解密失败
                SecureRandom random = SecureRandom.getInstance("SHA1PRNG");
                random.setSeed(key.getBytes());
                keyGenerator.init(KEYSIZE, random);
                SecretKey secretKey = keyGenerator.generateKey();
                byte[] enCodeFormat = secretKey.getEncoded();
                SecretKeySpec secretKeySpec = new SecretKeySpec(enCodeFormat, "AES");
                Cipher cipher = Cipher.getInstance("AES");// 创建密码器
                cipher.init(Cipher.DECRYPT_MODE, secretKeySpec);// 初始化
                byte[] result = cipher.doFinal(data);
                return result; // 加密
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
        return null;
    }

    /**
     * AES解密,返回String
     * 
     * @param enCryptdata 待解密字节数组
     * @param key         秘钥
     * @return
     */
    public static String decryptToStr(String enCryptdata, String key) {
        return StringUtils.isNotBlank(enCryptdata) ? new String(decrypt(parseHexStr2Byte(enCryptdata), key)) : null;
    }

    /**
     * 将二进制转换成16进制
     * 
     * @param buf 二进制数组
     * 
     */
    public static String parseByte2HexStr(byte buf[]) {
        StringBuffer sb = new StringBuffer();
        for (int i = 0; i < buf.length; i++) {
            String hex = Integer.toHexString(buf[i] & 0xFF);
            if (hex.length() == 1) {
                hex = '0' + hex;
            }
            sb.append(hex.toUpperCase());
        }
        return sb.toString();
    }

    /**
     * 将16进制转换为二进制
     * 
     * @param hexStr 16进制字符串
     * @return
     */
    public static byte[] parseHexStr2Byte(String hexStr) {
        if (hexStr.length() < 1)
            return null;
        byte[] result = new byte[hexStr.length() / 2];
        for (int i = 0; i < hexStr.length() / 2; i++) {
            int high = Integer.parseInt(hexStr.substring(i * 2, i * 2 + 1), 16);
            int low = Integer.parseInt(hexStr.substring(i * 2 + 1, i * 2 + 2), 16);
            result[i] = (byte) (high * 16 + low);
        }
        return result;
    }
}

所需的常量类:

package org.vic.common.constant;

/**
 * JWT常量值
 * 
 * @author vic
 *
 */
public interface SecretConstant {
    // 签名秘钥
    public static final String BASE64SECRET = "ZW]4l5JH[m6Lm)LaQEjpb!4E0lRaG(";

    // 超时毫秒数(默认30分钟)
    public static final int EXPIRESSECOND = 1800000;

    // 用于JWT加密的密匙
    public static final String DATAKEY = "u^3y6SPER41jm*fn";
}

客户端拦截器:

package org.vic.common.web;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.apache.commons.lang3.StringUtils;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import org.vic.common.constant.GlobalConstant;
import org.vic.common.util.JwtHelper;

import com.alibaba.fastjson.JSONObject;

import lombok.extern.slf4j.Slf4j;

/**
 * 校验是否登录拦截器
 * @author vic
 *
 */
@Slf4j
public class ValidateLoginInterceptor implements HandlerInterceptor {


    public boolean preHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o) throws Exception {
        //首先从请求头中获取jwt串,与页面约定好存放jwt值的请求头属性名为User-Token
        String jwt = httpServletRequest.getHeader("User-Token");
        log.info("[登录校验拦截器]-从header中获取的jwt为:{}", jwt);
        //判断jwt是否有效
        if(StringUtils.isNotBlank(jwt)){
            //校验jwt是否有效,有效则返回json信息,无效则返回空
            String retJson = JwtHelper.validateLogin(jwt);
            log.info("[登录校验拦截器]-校验JWT有效性返回结果:{}", retJson);
            //retJSON为空则说明jwt超时或非法
            if(StringUtils.isNotBlank(retJson)){
                JSONObject jsonObject = JSONObject.parseObject(retJson);
                //校验客户端信息
                String userAgent = httpServletRequest.getHeader("User-Agent");
                if (userAgent.equals(jsonObject.getString("userAgent"))) {
                    //获取刷新后的jwt值,设置到响应头中
                    httpServletResponse.setHeader("User-Token", jsonObject.getString("freshToken"));
                    //将客户编号设置到session中
                    httpServletRequest.getSession().setAttribute(GlobalConstant.SESSION_CUSTOMER_NO_KEY, jsonObject.getString("userId"));
                    return true;
                }else{
                    log.warn("[登录校验拦截器]-客户端浏览器信息与JWT中存的浏览器信息不一致,重新登录。当前浏览器信息:{}", userAgent);
                }
            }else {
                log.warn("[登录校验拦截器]-JWT非法或已超时,重新登录");
            }
        }
        //输出响应流
        JSONObject jsonObject = new JSONObject();
        jsonObject.put("hmac", "");
        jsonObject.put("status", "");
        jsonObject.put("code", "100");
        jsonObject.put("msg", "未登录");
        jsonObject.put("data", "");
        httpServletResponse.setCharacterEncoding("UTF-8");
        httpServletResponse.setContentType("application/json; charset=utf-8");
        httpServletResponse.getOutputStream().write(jsonObject.toJSONString().getBytes("UTF-8"));
        return false;
    }

    public void postHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, ModelAndView modelAndView) throws Exception {
    }
    public void afterCompletion(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, Exception e) throws Exception {
    }
}

到此,后台服务的配置已经完成
下一步就需要前端页面将JWT令牌从response响应头中取出,然后存入Localstorage或Cookie中。但是遇到跨域场景,处理起来就会比较复杂,因为一旦在浏览器中跨域将获取不到localstorage中的JWT令牌。例如www.a.com域下的JWT,在www.b.com域下是获取不到的,所以我选择了一种页面跨域的方式进行处理,使用iframe+H5的postMessage,具体我使用代码分享的方式来分析。

    /**CURD本地存储信息 start**/
          (function(doc,win){
              var fn=function(){};
              fn.prototype={
                  /*本地数据存储 t:cookie有效时间,单位s; domain:cookie存储所属的domain域*/
                  setLocalCookie: function (k, v, t,domain) {
                      //如果当前浏览器不支持localStorage将存储在cookie中
                      typeof window.localStorage !== "undefined" ? localStorage.setItem(k, v) :
                          (function () {
                              t = t || 365 * 12 * 60 * 60;
                              domain=domain?domain:".jwtserver.com";
                              document.cookie = k + "=" + v + ";max-age=" + t+";domain="+domain+";path=/";
                          })()
                  },
                  /*获取本地存储数据*/
                  getLocalCookie: function (k) {
                      k = k || "localDataTemp";
                      return typeof window.localStorage !== "undefined" ? localStorage.getItem(k) :
                          (function () {
                              var all = document.cookie.split(";");
                              var cookieData = {};
                              for (var i = 0, l = all.length; i < l; i++) {
                                  var p = all[i].indexOf("=");
                                  var dataName = all[i].substring(0, p).replace(/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,"");
                                  cookieData[dataName] = all[i].substring(p + 1);
                              }
                              return cookieData[k]
                          })();
                  },
                  /*删除本地存储数据*/
                  clearLocalData: function (k) {
                      k = k || "localDataTemp";
                      typeof window.localStorage !== "undefined" ? localStorage.removeItem(k) :
                          (function () {
                              document.cookie = k + "=temp" + ";max-age=0";
                          })()
                  },
                  init:function(){
                      this.bindEvent();
                  },
                  //事件绑定
                  bindEvent:function(){
                      var _this=this;
                      win.addEventListener("message",function(evt){
                          if(win.parent!=evt.source){return}
                          var options=JSON.parse(evt.data);
                          if(options.type=="GET"){
                              var data=tools.getLocalCookie(options.key);
                              win.parent.postMessage(data, "*");
                          }
                          options.type=="SET"&&_this.setLocalCookie(options.key,options.value);
                          options.type=="REM"&&_this.clearLocalData(options.key);
                      },false)
                  }
              };
              var tools=new fn();
              tools.init();
          })(document,window);
          /**CURD本地存储信息 end**/

前端页面js代码(客户端):

        //页面初始化向iframe域名发送消息
        window.onload = function() {
            console.log('get key value......................')
            window.frames[0].postMessage(JSON.stringify({type:"GET",key:"User-Token"}),'*');
        }
        //监听message信息,接收从iframe域下获取到的token信息,然后存储到localstorage或cookie中
        window.addEventListener('message', function(e) {
            console.log('listen.....');
            var data = e.data;
            console.log(data);
            if(data != null){
                localStorage.setItem("User-Token", data);
            }
        }, false);

总结:

优点:在非跨域环境下使用JWT机制是一个非常不错的选择,实现方式简单,操作方便,能够快速实现。由于服务端不存储用户状态信息,因此大用户量,对后台服务也不会造成压力;
缺点:跨域实现相对比较麻烦,安全性也有待探讨。因为JWT令牌返回到页面中,可以使用js获取到,如果遇到XSS攻击令牌可能会被盗取,在JWT还没超时的情况下,就会被获取到敏感数据信息。

注:
JWT 的自包含、防篡改的特点让很多人喜欢,可以省掉最让人烦的集中式的令牌,实现无状态。
可是,这是有场景限制的。比如主动吊销 Token 要如何处理、有效时长如何动态控制、密钥如何动态切换。
如果没有主动吊销 Token 的业务需求,那自包含的特点确实很有用,只是看大家的业务场景了。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,670评论 5 460
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,928评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,926评论 0 320
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,238评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,112评论 4 356
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,138评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,545评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,232评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,496评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,596评论 2 310
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,369评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,226评论 3 313
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,600评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,906评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,185评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,516评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,721评论 2 335

推荐阅读更多精彩内容