使用JWT做微服务的登录方案
由于微服务大都是分布式的,需要几台服务器部署,当一个用户在其中一台服务器登录后,传统的方式是session保存其登录信息,然后可以使用共享存储共享,比如redis共享,这种方案的缺点在于共享存储需要一定保护机制,因此需要通过安全链接来访问,这时解决方案的实现就通常具有相当高的复杂性了,所以这里使用基于令牌的方式做登录。JWT简介简介网上都有,下面是摘抄的一部分,做做笔记。。。JWT(J...
由于微服务大都是分布式的,需要几台服务器部署,当一个用户在其中一台服务器登录后,传统的方式是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.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.2.0</version>
</dependency>
然后就是生成jwt的工具类,传入一个claims即可
public class JwtHelper {
//设置发行人
private static final String ISSUER = "user";
public static String genToken(Map<String, String> 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<String, String> 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<String, Claim> map = jwt.getClaims();
Map<String, String> 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<String, String> 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解码出来信息
redis里面也缓存了该JWT
至于通过验签鉴权,调用之前的工具类验签即可
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秘钥不正确
就会抛出异常,验签不成功
更多推荐
所有评论(0)