JWT+RSA非对称加密实现无状态登录

JWT+RSA非对称加密实现无状态登录

1. 问题

以前我们的登录都是将当前登录的用户信息放在session中,但是session是保存在服务器的,在分布式环境下服务器不只有一台,那就容易找不到登录的用户信息,所以应该是保存在客户端也就是cookie中的,但是存在cookie中有个问题,就是要进行一些操作时需要获取当前登录的用户信息,那么要将cookie中的user发送过去,这一来二去老是携带着明文的用户名和密码,就很不安全,会被人用一些技术手段拦截,那么就获取了你的账号密码信息。

随后就产生了一些加密技术,也就是说cookie中存的user信息是密文(此时称为token),就算被拦截了没有破解方法(称为密钥)也没有用。


1. RSA非对称加密技术

看大佬的讲解

https://www.zhihu.com/question/304030251

当然,后面还涉及到一些更深入的东西(中间人攻击,CA认证中心等),有时间再补笔记(●’◡’●)计网的笔记先埋个坑慢慢填

追更:计网的笔记点击这里


2. JWT

是token的一种,全称是Json Web Token, 是JSON风格轻量级的授权和身份认证规范。格式是这样的(头部:我是JWT格式的token,载荷:用户信息,签名:头部和载荷合在一起再加密)

我们来看一下登录的流程

  • 我们首先利用RSA生成公钥和私钥。私钥保存在授权中心,公钥保存在Zuul和各个信任的微服务
  • 用户请求登录
  • 授权中心校验,通过后用私钥对用户信息进行签名加密生成jwt
  • 返回jwt给用户并保存在cookie中
  • 用户携带JWT访问
  • Zuul直接通过公钥解密JWT,进行验证,验证通过则放行
  • 请求到达微服务,微服务直接用公钥解析JWT,获取用户信息,无需访问授权中心

3. 具体实现及登录功能细节

3.1 加密生成jwt部分实现

  1. 创建载荷对象,也就是jwt中的载荷部分,无需包含密码和盐(使用了rsa以后,只要公钥能解析不出错就说明认证成功了)

  1. 会用到的依赖
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.3.2</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
<dependency>
<groupId>joda-time</groupId>
<artifactId>joda-time</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>
  1. 四个工具类

  • RsaUtils:生成rsa密钥(一个公钥一个私钥)方法,读取公钥密钥方法
  • JwtUtils:将用户信息使用私钥加密成jwt格式的token方法,使用公钥解析token获得用户信息方法

代码太多了,反正是工具类,这里给个下载链接,直接下载粘贴即可食用

http://39.106.175.70/owncloud/index.php/s/xcW47RaFFiSjfQ0


  1. 运行测试类,直接复制即可食用

要替换的地方已用注释标明

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public class JwtTest {
//rsa公钥私钥位置
private static final String pubKeyPath = "C:\\Users\\wjw\\.leyoursa\\rsa.pub";

private static final String priKeyPath = "C:\\Users\\wjw\\.leyoursa\\rsa.pri";

private PublicKey publicKey;

private PrivateKey privateKey;

@Test
public void testRsa() throws Exception {
RsaUtils.generateKey(pubKeyPath, priKeyPath, "234"); //生成rsa密钥,最后一个是盐,随便给,越复杂越好
}

//第一次执行时没有文件所以注释掉,执行完上面那个函数后放开before注释
//@Before
public void testGetRsa() throws Exception {
this.publicKey = RsaUtils.getPublicKey(pubKeyPath);
this.privateKey = RsaUtils.getPrivateKey(priKeyPath);
}

@Test
public void testGenerateToken() throws Exception {
// 用私钥加密user信息生成token
String token = JwtUtils.generateToken(new UserInfo(20L, "jack"), privateKey, 5);
System.out.println("token = " + token);
}

@Test
public void testParseToken() throws Exception {
//使用公钥解密token
//改成上面那个函数生成的token试试解密
String token = "eyJhbGciOiJSUzI1NiJ9.eyJpZCI6MjAsInVzZXJuYW1lIjoiamFjayIsImV4cCI6MTU5NTIzMzI4OX0.IhrxQJ9vmdME2EaOiGl-IEI6JybKDo6y7UlCp0pBWwfusUHbY3xjMFnGnnRvQ2keOU7AFetBhMPw03T4pUbUil8tmzc38mk5etYs0kxkoa2_qmeu80qqQUBVvfexYmZkvo3nOjz66IFfX716CWgZ-9Ew8zAib1o0TFkJ4RnjV5w";

// 解析token
UserInfo user = JwtUtils.getInfoFromToken(token, publicKey);
System.out.println("id: " + user.getId());
System.out.println("userName: " + user.getUsername());
}
}

加密解密测试效果:


3.2 cookie部分实现

加密这部分测试成功之后我们注意到登录功能还需要保存到cookie中

也提供一个工具类,直接复制即可食用

http://39.106.175.70/owncloud/index.php/s/Y2tQJoCMUILK0ZY


3.3 登录流程业务逻辑实现

rsa,jwt,cookie都弄好后,就可以实现登录功能了

其实登录就比较简单,controller service那些,跟着之前流程来就行

不过测试用例写死了不优雅,可以使用yml+配置类读取(当然你可以暴力)

yml

配置类

给出代码,省略篇幅,包自己引入一下,getter/setter自己创建一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
@ConfigurationProperties(prefix = "leyou.jwt")
public class JwtProperties {

private String secret; // 密钥

private String pubKeyPath;// 公钥

private String priKeyPath;// 私钥

private int expire;// token过期时间

private String cookieName;

private PublicKey publicKey; // 公钥

private PrivateKey privateKey; // 私钥

private static final Logger logger = LoggerFactory.getLogger(JwtProperties.class);

/**
* @PostContruct:在构造方法执行之后执行该方法
*/
@PostConstruct
public void init(){
try {
File pubKey = new File(pubKeyPath);
File priKey = new File(priKeyPath);
if (!pubKey.exists() || !priKey.exists()) {
// 生成公钥和私钥
RsaUtils.generateKey(pubKeyPath, priKeyPath, secret);
}
// 获取公钥和私钥
this.publicKey = RsaUtils.getPublicKey(pubKeyPath);
this.privateKey = RsaUtils.getPrivateKey(priKeyPath);
} catch (Exception e) {
logger.error("初始化公钥和私钥失败!", e);
throw new RuntimeException();
}
}

//get,set自己生成一下
}

后面的没什么好说了,代码太简单,有手就行,我贴图分析吧

controller

service


3.4 cookie细节的处理

cookie是保存在本地的,那么通过localhost访问就没什么问题,但是通过域名访问就有问题

以下是对比图

localhonst访问

域名访问

先解决方法再分析原因:

nginx配置文件的网关部分添加proxy_set_header Host $host;

zuul的yml

原因:我们通过调试可以看到cookie的domainName是计算机名称而不是leyou.com

这个计算机名是哪来的呢,因为zuul到登录微服务是通过eureka注册中心的

访问流程是nginx=>zuul=>登录微服务,因此我们要让计算机名带着访问路径一起走才对,所以才有了上面的设置,解决后的效果


3.5 token过期处理的思路

token和cookie都有过期时间,如果用户在一段时间内没有操作那么过期了就需要重新登录,没有问题。但是如果一直在操作(比如选购商品选了很久,最后下单的时候发现cookie过期了,拿不到用户登录信息,就很尴尬)。

解决思路比较简单,就是不管进行什么操作,vue可以放在created钩子函数中,只要加载了页面,就调用verify这个方法:验证是否有token,如果还有,则说明用户正在操作,重新更新一遍token和cookie,防止过期

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/**
* 验证是否还在登录状态,不管进行什么操作,比如搜索啊下单啊,浏览啊,都应该调用一下这个方法,以表明用户还在操作
* 只要调用这个方法,就验证是否有token,然后重新更新一遍token,防止过期
*/
@GetMapping("verify")
public ResponseEntity<UserInfo> verify(
@CookieValue("LY_TOKEN") String token,
HttpServletRequest request,
HttpServletResponse response){
try {
//使用公钥解析jwt
UserInfo user = JwtUtils.getInfoFromToken(token, this.jwtProperties.getPublicKey());
if (user == null){
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}

//刷新jwt和cookie中的有效时间,不退出就一直登录
token = JwtUtils.generateToken(user, this.jwtProperties.getPrivateKey(), this.jwtProperties.getExpire());
CookieUtils.setCookie(request, response, jwtProperties.getCookieName(), token, jwtProperties.getExpire() * 60);

return ResponseEntity.ok(user);
} catch (Exception e) {
e.printStackTrace();
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
}

3.6 zuul网关拦截

注册,登录,搜索等功能不需要拦截

下单等功能需要拦截判断是否已登录

网关微服务只需要公钥和cookie名字,以及白名单

1
2
3
4
5
6
7
8
9
10
11
12
leyou:
jwt:
pubKeyPath: C:\Users\wjw\.leyoursa\rsa.pub # 公钥地址
cookieName: LY_TOKEN
filter:
allowPaths: # 白名单
- /api/auth
- /api/search
- /api/user/register
- /api/user/check
- /api/user/code
- /api/item

复制一份读取yml的配置类到网关微服务,需要相应修改,只读取不创建

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@ConfigurationProperties(prefix = "leyou.jwt")
public class JwtProperties {

private String pubKeyPath;// 公钥

private String cookieName;

private PublicKey publicKey; // 公钥

private static final Logger logger = LoggerFactory.getLogger(JwtProperties.class);

/**
* @PostContruct:在构造方法执行之后执行该方法
*/
@PostConstruct
public void init(){
try {
// 获取公钥和私钥
this.publicKey = RsaUtils.getPublicKey(pubKeyPath);
} catch (Exception e) {
logger.error("初始化公钥和私钥失败!", e);
throw new RuntimeException();
}
}

//省略getter,setter
}

读取白名单的配置类单独出来

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* 白名单配置类
*/
@ConfigurationProperties(prefix = "leyou.filter")
public class FilterProperties {

private List<String> allowPaths;

public List<String> getAllowPaths() {
return allowPaths;
}

public void setAllowPaths(List<String> allowPaths) {
this.allowPaths = allowPaths;
}
}

有了这两个配置类就可以读取yml参数了,主要完成下面这个拦截器即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
/**
* 除了注册,登录,发短信,搜索等功能以外,所有的微服务进入前都要判断是否已登录
*/
@Component
@EnableConfigurationProperties({JwtProperties.class, FilterProperties.class})
public class LoginFilter extends ZuulFilter {

@Autowired
private JwtProperties jwtProperties;

@Autowired
private FilterProperties filterProperties;

@Override
public String filterType() {
return "pre";
}

@Override
public int filterOrder() {
return 10;
}

@Override
public boolean shouldFilter() {
//白名单不拦截
List<String> allowPaths = this.filterProperties.getAllowPaths();

//初始化运行上下文获取request和response
RequestContext context = RequestContext.getCurrentContext();
HttpServletRequest request = context.getRequest();
String url = request.getRequestURL().toString();

for (String allowPath : allowPaths) {
if (StringUtils.contains(url, allowPath)) {
return false; //不拦截
}
}
return true;
}

@Override
public Object run() throws ZuulException {
//获取token
//初始化运行上下文获取request和response
RequestContext context = RequestContext.getCurrentContext();
String token = CookieUtils.getCookieValue(context.getRequest(), this.jwtProperties.getCookieName());
if (StringUtils.isBlank(token)){
context.setSendZuulResponse(false);
context.setResponseStatusCode(HttpStatus.UNAUTHORIZED.value());
}
//解析
try {
JwtUtils.getInfoFromToken(token, this.jwtProperties.getPublicKey());
} catch (Exception e) {
e.printStackTrace();
context.setSendZuulResponse(false);
context.setResponseStatusCode(HttpStatus.UNAUTHORIZED.value());
}
return null;
}
}

3.7 最终效果

看看页面上,cookie有了,value是jwt,域名也对了,注册和登录也不拦截了,腰不酸腿不痛了

嗯!是主流登录的实现效果~

#
Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×