使用JWT做微服务的登录方案

由于微服务大都是分布式的,需要几台服务器部署,当一个用户在其中一台服务器登录后,传统的方式是session保存其登录信息,然后可以使用共享存储共享,比如redis共享,这种方案的缺点在于共享存储需要一定保护机制,因此需要通过安全链接来访问,这时解决方案的实现就通常具有相当高的复杂性了,所以这里使用基于令牌的方式做登录。

JWT简介

简介网上都有,下面是摘抄的一部分,做做笔记。。。

JWT(JSON WEB TOKEN)是一个非常轻巧的规范,这个规范允许我们使用jwt在客户端和服务器之间传递安全可靠的信息。
JWT由3个部分组成,分别是头部、载荷、签名。
头部部分
{
“alg”: “HS256”,
“typ”: “JWT”
}
alg描述的是签名算法。

载荷部分
{
“iss”: “发行者”,
“sub”: 主题”,
“aud”: “观众”,
“exp”:”过期时间”,
“iat”:”签发时间”
以下可以添加自定义数据
“id”:”1”,
“nickname”:”昵称”
}
Base64算法是可逆的,不可以在载荷部分保存用户密码等敏感信息。如果业务需要,也可以采用对称密钥加密。

签名部分
HMACSHA256(Base64(Header) + “.” + Base64(Payload), secret)
签名的目的是用来验证头部和载荷是否被非法篡改。
验签过程描述:读取Header部分并Base64解码,得到签名算法。根据以上方法算出签名,如果签名信息不一致,说明是非法的。

为什么使用JWT

前后端分离

以前的传统模式下,后台对应的客户端就是浏览器,就可以使用session+cookies的方式实现登录,但是在前后分离的情况下,后端只负责通过暴露的RestApi提供数据,而页面的渲染、路由都由前端完成。因为rest是无状态的,因此也就不会有session记录到服务器端。

传统方式带来的安全性问题

在前后端分离通过Restful API进行数据交互时,如何验证用户的登录信息及权限。在原来的项目中,使用的是最传统也是最简单的方式,前端登录,后端根据用户信息生成一个token,并保存这个 token 和对应的用户id到数据库或Session中,接着把 token 传给用户,存入浏览器 cookie,之后浏览器请求带上这个cookie,后端根据这个cookie值来查询用户,验证是否过期。

但这样做问题就很多,如果我们的页面出现了 XSS 漏洞,由于 cookie 可以被 JavaScript 读取,XSS 漏洞会导致用户 token 泄露,而作为后端识别用户的标识,cookie 的泄露意味着用户信息不再安全。尽管我们通过转义输出内容,使用 CDN 等可以尽量避免 XSS 注入,但谁也不能保证在大型的项目中不会出现这个问题。

在设置 cookie 的时候,其实你还可以设置 httpOnly 以及 secure 项。设置 httpOnly 后 cookie 将不能被 JS 读取,浏览器会自动的把它加在请求的 header 当中,设置 secure 的话,cookie 就只允许通过 HTTPS 传输。secure 选项可以过滤掉一些使用 HTTP 协议的 XSS 注入,但并不能完全阻止。

httpOnly 选项使得 JS 不能读取到 cookie,那么 XSS 注入的问题也基本不用担心了。但设置 httpOnly 就带来了另一个问题,就是很容易的被 XSRF,即跨站请求伪造。当你浏览器开着这个页面的时候,另一个页面可以很容易的跨站请求这个页面的内容。因为 cookie 默认被发了出去。

性能问题

如果将验证信息保存在数据库中,后端每次都需要根据token查出用户id,这就增加了数据库的查询和存储开销。

Session方式存储用户id的最大弊病在于Session是存储在服务器端的,所以需要占用大量服务器内存,对于较大型应用而言可能还要保存许多的状态,一般还需借助nosql和缓存机制来实现session的存储,如果是分布式应用还需session共享。

兼容问题

在移动端app里,或者是前后端分离的架构中,用户访问的是前端的web server(如 node.js),前端的渲染,ajax请求都是由web server完成的,这里就跟传统的不一样了,用户不是直接访问后台应用服务器的,这时候用cookie+session就比较麻烦,问题在于开发繁琐、安全性和客户体验差、有些前端技术不支持cookie(如微信小程序)

带来的好处

简洁,可以通过URL, POST 参数或者在 HTTP header 发送,因为数据量小,传输速度快。

自包含,负载中包含了所有用户所需要的信息,避免了多次查询数据库,服务端也不需要存储 session 信息,做到了服务端无状态。

JWT方式将用户状态分散到了客户端中,可以明显减轻服务端的内存压力。除了用户id之外,还可以存储其他的和用户相关的信息,例如该用户是否是管理员、用户所在的分组等。

JWT能轻松的实现单点登录,因为用户的状态已经被传送到了客户端。

支持移动设备,支持跨程序调用,Cookie 是不允许垮域访问的,而 Token 则不存在这个问题。

因为有签名,所以JWT可以防止被篡改

实操

前一段时间在微服务中做用户服务的时候使用到了jwt+redis的登录方案构思了一下登录流程,也不知道合不合理。。仅供参考

  • 前端服务器收到用户登录请求,传给后台zuul网关。

  • zuul网关把请求分发到用户服务里进行身份验证。

  • 后台用户服务验证通过,然后从账号信息抽取出id、nickName、login_method、login_time等基本信息(这些信息根据自己的需要定义)组成payload,进而组装一个JWT,把JWT放入redis(因为退出的时候无法使jwt立即作废,所以使用保存在redis中,退出的时候delete掉就可以了,鉴权的时候加一层判断jwt是否在redis里,如果不在则证明jwt已过期作废),然后包装到json数据返回到前端服务器,这就登录成功了。

  • 前端服务器拿到 JWT,进行存储(可以存储在缓存中,也可以存储在数据库中,如果是浏览器,可以存储在 localStorage 中)在后续请求中,在 HTTP 请求头中加上 JWT(前端在每次请求时将JWT放入HTTP Header中的Authorization位,解决XSS和XSRF问题)。

  • 登录后,再访问其他微服务的时候,前端会携带jwt访问后台,后台的zuul网关添加拦截器来校验 JWT,验签通过后,进去相应的服务,然后返回相应资源和数据就可以了。

下面就来写代码。。

首先引入依赖

        <dependency>
            <groupId>com.auth0groupId>
            <artifactId>java-jwtartifactId>
            <version>3.2.0version>
        dependency>

然后就是生成jwt的工具类,传入一个claims即可

public class JwtHelper {
    //设置发行人
    private static final String ISSUER = "user";

    public static String genToken(Map claims){
        try {
        //这里的JwtRsaUtil是自定义的另一个工具类,用于从jks证书文件中提取公钥和私钥,进行RSA加密解密
            JwtRsaUtil jwtRsaUtil = new JwtRsaUtil("/*****.jks", "*****".toCharArray());
            //获取秘钥对
            KeyPair keyPair = jwtRsaUtil.getKeyPair();
            //然后就是设置加密算法了,可以选择许多不同的加密算法,这里使用的RSA256非对称加密,安全性更好。如果为了方便,也可以使用HS256对称加密
            Algorithm algorithm = Algorithm.RSA256((RSAPublicKey) keyPair.getPublic(), (RSAPrivateKey) keyPair.getPrivate());
            //开始构建JWT,这里可以设置很多信息,我只设置了发行人和过期时间
            JWTCreator.Builder builder = JWT.create().withIssuer(ISSUER).withExpiresAt(DateUtils.addDays(new Date(), 1));
            //然后把传入的claims放进builder里面,这里使用了java8的方法引用,也可以说是lambda的简化写法吧,本来写的lambda表达式,然后idea提示这里还可以简化,然后就变成这样子了。。
            claims.forEach(builder::withClaim);
            //签名之后返回
            return builder.sign(algorithm);
        } catch (IllegalArgumentException e) {
            throw new RuntimeException(e);
        }
    }
    //验签方法
    public static Map verifyToken(String token)  {
        Algorithm algorithm = null;
        try {
            KeyPair keyPair = JwtRsaUtil.getInstance().getKeyPair();
            algorithm = Algorithm.RSA256((RSAPublicKey) keyPair.getPublic(), (RSAPrivateKey) keyPair.getPrivate());
        } catch (IllegalArgumentException e) {
            throw new RuntimeException(e);
        }
        JWTVerifier verifier = JWT.require(algorithm).withIssuer(ISSUER).build();
        DecodedJWT jwt =  verifier.verify(token);
        Map map = jwt.getClaims();
        Map resultMap = Maps.newHashMap();
        map.forEach((k,v) -> resultMap.put(k, v.asString()));
        return resultMap;
    }
}

这是解析jks文件的工具类

public class JwtRsaUtil {

    private String keyStoreFile;
    private char[] password;
    private KeyStore store;
    private Object lock = new Object();

    private static JwtRsaUtil instance = null;

    public static JwtRsaUtil getInstance() {
        synchronized (JwtRsaUtil.class) {
            if (instance == null) {
                synchronized (JwtRsaUtil.class) {
                //这里是jks文件路径和密码
                    instance = new JwtRsaUtil("/******.jks", "******".toCharArray());
                }
            }
            return instance;
        }
    }

    public JwtRsaUtil(String _jksFilePath, char[] password) {
        this.keyStoreFile = _jksFilePath;
        this.password = password;
    }

    public KeyPair getKeyPair() {
        return getKeyPair("*******", this.password);
    }

    public KeyPair getKeyPair(String alias, char[] password) {
        try {
            synchronized (this.lock) {
                if (this.store == null) {
                    synchronized (this.lock) {
                        InputStream is = this.getClass().getResourceAsStream(keyStoreFile);
                        try {
                            this.store = KeyStore.getInstance("JKS");
                            this.store.load(is, this.password);
                        } finally {
                            if (is != null) {
                                try {
                                    is.close();
                                } catch (Exception e) {
                                }
                            }
                        }
                    }
                }
            }
            RSAPrivateCrtKey key = (RSAPrivateCrtKey) this.store.getKey(alias, password);
            RSAPublicKeySpec spec = new RSAPublicKeySpec(key.getModulus(), key.getPublicExponent());
            PublicKey publicKey = KeyFactory.getInstance("RSA").generatePublic(spec);
            return new KeyPair(publicKey, key);
        } catch (Exception e) {
            throw new IllegalStateException("Cannot load keys from store: " + this.keyStoreFile, e);
        }
    }
}

至于生成jks文件可以使用keytool,网上很多资料,有个问题就是当初使用的大小写混合的秘钥,然后导到程序里无法解析,所以现在秘钥用的全小写。。

然后在业务逻辑里,从数据库中查出用户基本信息后,调用上面写的工具类,生成一个jwt

        // 生成JWT
        Map claims = new HashMap<>();
        claims.put("id", user.getId()+"");
        claims.put("nickName", user.getNickName());
        claims.put("login_method", userSocial.getLoginMethod());
        claims.put("openId", userSocial.getUnionId());
        claims.put("ts", Instant.now().getEpochSecond()+"");
        String jwtToken = JwtHelper.genToken(claims);
        // 缓存至redis
        renewToken(jwtToken, user.getId());
        return jwtToken;

保存到redis中

    private void renewToken(String token, int id) {
        redisTemplate.opsForValue().set(id, token);
        redisTemplate.expire(id, 30, TimeUnit.MINUTES);
    }

同理,退出登录的时候就直接delete掉就可以了

    public void invalidate(String token) {
        Map<String, String> map = JwtHelper.verifyToken(token);
        redisTemplate.delete(map.get("id"));
    }

运行效果

{“code”:0,”msg”:”登录成功!”,”data”:”eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJuaWNrTmFtZSI6IjEyMyIsIm9wZW5JZCI6Im9pWnFGd1d6ZlU2U05kZXRNakxhdkZMYXpPYmciLCJsb2dpbl9tZXRob2QiOiJ3ZWNoYXQiLCJpc3MiOiJzdW5TcHJpbmdfdXNlciIsImlkIjoiMjQiLCJleHAiOjE1MjA0MjczODEsInRzIjoiMTUyMDM0MDk4MSJ9.K-U9zakvABRTh1LmOPke_7zKH9qCEUC3CkqeSNknBv-6orsT87GVZZMJAYxp2wgyGe5EzObONRWaAde-EK2UGMe7yVGANjD5NaPw05d7gjO-2ZbhTOU1dpiTWH5zXXu6mdJUbjVNFwam5oh0qOgAelSKogQCf3pAnSdPAXG85Yc”}

生成了一段JWT,然后可以base64解码出来信息
使用JWT做微服务的登录方案_第1张图片
redis里面也缓存了该JWT
使用JWT做微服务的登录方案_第2张图片

至于通过验签鉴权,调用之前的工具类验签即可

        KeyPair keyPair = JwtRsaUtil.getInstance().getKeyPair();
        String token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJuaWNrTmFtZSI6IjEyMyIsIm9wZW5JZCI6Im9pWnFGd1d6ZlU2U05kZXRNakxhdkZMYXpPYmciLCJsb2dpbl9tZXRob2QiOiJ3ZWNoYXQiLCJpc3MiOiJzdW5TcHJpbmdfdXNlciIsImlkIjoiMjQiLCJleHAiOjE1MjA0MjczODEsInRzIjoiMTUyMDM0MDk4MSJ9.K-U9zakvABRTh1LmOPke_7zKH9qCEUC3CkqeSNknBv-6orsT87GVZZMJAYxp2wgyGe5EzObONRWaAde-EK2UGMe7yVGANjD5NaPw05d7gjO-2ZbhTOU1dpiTWH5zXXu6mdJUbjVNFwam5oh0qOgAelSKogQCf3pAnSdPAXG85Yc";
        Map<String, String> map = JwtHelper.verifyToken(token);
        System.out.println(map);

然后测试运行,能解析出payload,由于使用了电子证书,所以未泄露秘钥的情况下,payload是可信的、无法篡改的。
这里写图片描述

当然,如果RSA秘钥不正确
这里写图片描述
就会抛出异常,验签不成功

你可能感兴趣的:(java后端)