在浏览网站的过程中,经常会遇到需要登录的情况,有些页面只有登录之后才可以访问。在登录之后可以连续访问很多次网站,但是有时候过一段时间就需要重新登录。还有一些网站,在打开浏览器时就自动登录了,而且在很长时间内都不会失效,这又是什么情况?其实这里面涉及Cookie、Session以及token等相关知识,现在就来揭开它们的神秘面纱。
1. 会话技术介绍
什么是会话?
-
在我们日常生活当中,会话指的就是谈话、交谈。
-
在web开发当中,会话指的就是浏览器与服务器之间的一次连接,我们就称为一次会话。
在用户打开浏览器第一次访问服务器的时候,这个会话就建立了,直到有任何一方断开连接,此时会话就结束了。在一次会话当中,是可以包含多次请求和响应的。
比如:打开了浏览器来访问web服务器上的资源(浏览器不能关闭、服务器不能断开),以京东为例
- 第1次:访问的是登录的接口,完成登录操作
- 第2次:访问的是商品展示接口,查询所有相关商品数据
- 第3次:访问的是购物车接口,查询购物车数据
只要浏览器和服务器都没有关闭,以上3次请求都属于一次会话当中完成的。
需要注意的是:会话是和浏览器关联的,当有三个浏览器客户端和服务器建立了连接时,就会有三个会话。同一个浏览器在未关闭之前请求了多次服务器,这多次请求是属于同一个会话。比如:1、2、3这三个请求都是属于同一个会话。当我们关闭浏览器之后,这次会话就结束了。而如果我们是直接把web服务器关了,那么所有的会话就都结束了。
会话跟踪
会话跟踪:一种维护浏览器状态的方法,服务器需要识别多次请求是否来自于同一浏览器,以便在同一次会话的多次请求间共享数据。
服务器会接收很多的请求,但是服务器是需要识别出这些请求是不是同一个浏览器发出来的。比如:1和2这两个请求是不是同一个浏览器发出来的,3和5这两个请求不是同一个浏览器发出来的。如果是同一个浏览器发出来的,就说明是同一个会话。如果是不同的浏览器发出来的,就说明是不同的会话。而识别多次请求是否来自于同一浏览器的过程,这就称为会话跟踪。
我们使用会话跟踪技术就是要完成在同一个会话中,多个请求之间进行共享数据。
为什么要共享数据呢?
由于HTTP是无状态协议,在后面请求中怎么拿到前一次请求生成的数据呢?此时就需要在一次会话的多次请求之间进行数据共享。
现在几种主要会话跟踪技术:
- Cookie技术
- Session技术
- token(令牌)
2. Cookie技术
Cookie技术是通过在客户端记录的信息确定用户身份。HTTP是一种无连接协议,客户端和服务器交互仅仅限于请求/响应过程,结束后断开,下一次请求时,服务器会认为是一个新的客户端,为了维护他们之间的连接,让服务器知道这是前一个用户发起的请求,必须在一个地方保存客户端信息。
比如第一次请求了登录接口,登录接口执行完成之后,我们就可以设置一个cookie,在cookie当中我们就可以来存储用户相关的一些数据信息。如我可以在cookie当中来存储当前登录用户的用户名,用户的ID。服务器端在给客户端在响应数据的时候,会自动的将cookie响应给浏览器,浏览器接收到响应回来的cookie之后,会自动的将cookie的值存储在浏览器本地。接下来在后续的每一次请求当中,都会将浏览器本地所存储的cookie自动地携带到服务端。
之后再次请求时,在服务端就可以获取到cookie值,然后经过检验这个cookie值是否存在,若不存在,则说明该客户端之前是并没有登录的,存在,则说明该客户端已经登录过了。
上面提到了很多次自动,为什么呢?是因为cookie它是HTPP协议当中所支持的技术,而各大浏览器厂商都支持了这一标准。在HTTP协议官方给我们提供了一个响应头和请求头:
- 响应头 Set-Cookie :设置Cookie数据的
- 请求头 Cookie:携带Cookie数据的
在代码中测试一下,这里是基于spring boot项目做的,通过一个前端表单设置post请求,携带登录数据。
login.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>login</title>
</head>
<body>
<form id="loginForm" action="/login" method="post">
<label>用户名:<input id="username" type="text" name="username" autocomplete="off"></label>
<br>
<label>密 码:<input id="password" type="password" name="password" autocomplete="off"></label>
<br>
<input type="submit" value="登录"/>
<input type="reset" value="重置"/>
</form>
<script>
document.getElementById("loginForm").onsubmit = () => {
const username = document.getElementById("username").value;
const password = document.getElementById("password").value;
if (username.trim() === '' || password.trim() === '') {
alert('用户名和密码不能为空!');
return false; // 阻止表单提交
}
};
</script>
</body>
</html>
在Controller类中实现cookie的设置等。
CookieController.java
@RestController
public class CookieController {
/**
* 登录(设置Cookie)
*/
@PostMapping("/login")
public String login(HttpServletResponse response, String username, String password) {
// 创建Cookie
Cookie cookie = new Cookie(username, password);
// 设置Cookie存在最大时间 单位:秒
cookie.setMaxAge(30);
// 返回响应中设置Cookie
response.addCookie(cookie);
return "登录成功!";
}
/**
* 获取Cookie
*/
@GetMapping("/all-cookies")
// 还可以通过注解获取 @CookieValue
public String getCookies(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if(cookies != null){
return Arrays.stream(cookies)
.map(cookie -> cookie.getName() + "=" + cookie.getValue())
.collect(Collectors.joining(",")); // 使用逗号分割
}
return "没有cookie值!";
}
}
然后就可以访问登录页面,进行测试一下。
这里我们没有做校验,只是简单演示一下,故随便填写什么都是可以登录成功的,我们主要关注Cookie,这里我使用的用户名为admin,密码为123456。
这里打开调试工具可以看一下,我们请求的返回响应。
就可以看到返回了Set-Cookie字段值了,然后查看请求标头中并没有Cookie值,因为我们这是第一访问,这里随后还会提到。
先来看看这个cookie存储在哪的?
这里可以看到Cookie的存储位置,重点关注一下名称、值和时间期限(Expires)三个地方。
有的人可能会发现这里怎么什么都没有?这是因为我们设置了时间期限已经过期了,如上图,浏览器这里Expire显示的是格林威治时间,而我们是东八区时间,所以浏览器这里显示的会比本地晚8个小时。因为我们设置了30s过期,故这里时间期限只有30s,超过30s之后(即浏览器显示的时间之后)该Cookie值会自动删除,超过这个时间访问,就会重新进行登录校验生成新的Cookie值。默认的时间期限是一次会话,即关闭浏览器之后就会自动删除,再次打开浏览器访问就需要重新登录校验了。
这里在登录状态且Cookie值未过期的情况下,我们访问另一个接口/all-cookie,获取一下cookie值。
在抓包一下,看看请求标头内容是否包含Cookie。
可以发现,浏览器会自动携带Cookie值请求。当Cookie值过期之中,再次访问时
这里就需要重新登录校验了,然后请求标头中也不在携带Cookie值了。
这里会发现,密码不就暴漏了?故一般设置Cookie信息时,往往不会存入密码等信息,且正式项目中也应该进行编码加密处理,而不是直接明文展示Cookie值。
HTTP协议中支持的技术(Set-Cookie响应头的解析以及Cookie请求头数据的携带,都是浏览器自动进行的,是无需我们手动操作的),但是Cookie存在一些缺点:
- 移动端APP(Android、IOS)中无法使用Cookie
- 不安全,用户可以自己禁用Cookie
- Cookie不能跨域
什么跨域呢?简单来说,就是协议、IP、端口任意一个值不同,就是跨域操作。不明白的大家可以自行查阅其他资料。
3. Session技术
Session,中文称之为会话,通过在服务端记录的信息确定用户身份,这里这个session就是一个指的是会话。其本义是指有始有终的一系列动作、消息。例如打电话时,从拿起电话拨号到挂断电话之间的一系列过程就可以称为一个Session。Session时存在服务端的,具体流程如下图:
注意:服务器端在给浏览器响应数据的时候它会将Session的ID通过Cookie响应给浏览器。其实在响应头当中增加了一个Set-Cookie响应头。浏览器会自动识别这个响应头,然后自动将Cookie存储在浏览器本地。
SessionController.java
@RestController
public class SessionController {
/**
* 登录(设置Session)
*/
@PostMapping("/login")
public String login(HttpSession session, String username, String password) {
// 设置session
session.setAttribute("username", username);
session.setAttribute("password", password);
return "登录成功!";
}
/**
* 获取Session
*/
@GetMapping("/all-sessions")
public String getCookies(HttpServletRequest request) {
HttpSession session = request.getSession();
String username = (String) session.getAttribute("username");
String password = (String) session.getAttribute("password");
Map<String, String> result = new HashMap<>();
result.put("username", username);
result.put("password", password);
return result.toString();
}
}
好,现在还是进行测试一下:
这里的Session过期时间就为整个会话期间,一旦结束,就会要重新验证。
这里给返回了一个JSESSIONID的字段,就是存储到服务端的Session_ID。再次请求时,浏览器就会携带这个作为Cookie值,服务端校验通过后,就可以访问登录后的一些功能。访问/all-sessions查看。
这里我们可以检验Session过期时间是否为整个会话期间,关闭浏览器,重新访问一下该接口,或者换一个浏览器访问。
同Cookie一样,不要把密码等重要信息放入其中。
Session是存储在服务端的,安全,但同时也存在一些缺点:
- 服务器集群环境下无法直接使用Session
- 移动端APP(Android、IOS)中无法使用Cookie
- 用户可以自己禁用Cookie
- Cookie不能跨域
除此此为,当访问量比较大时,服务器端就会存在较大压力,且耗费很多存储空间。
4. token技术
什么是token?token其实就是我们称做令牌的,本质上就是一段字符串。是一个用户身份的标识,访问资源接口(API)时所需要的资源凭证
服务器对Token的存储方式:
- 存到数据库中,每次客户端请求的时候取出来验证(服务端有状态)
- 存到 redis 中,设置过期时间,每次客户端请求的时候取出来验证(服务端有状态)
- 不存,每次客户端请求的时候根据之前的生成方法再生成一次来验证(JWT,服务端无状态)
token身份校验过程:
token相比cookie与session缺点就是需要我们自己实现了,包括令牌的生成、令牌的传递、令牌的校验等。
下面主要讲到JWT的技术。
4.1 JWT令牌
JWT全称:JSON Web Token(官网:jwt.io/)
-
定义了一种简洁的、自包含的格式,用于在通信双方以json数据格式安全的传输信息。由于数字签名的存在,这些信息是可靠的。
简洁:是指jwt就是一个简单的字符串。可以在请求参数或者是请求头当中直接传递。
自包含:指的是jwt令牌,看似是一个随机的字符串,但是我们是可以根据自身的需求在jwt令牌中存储自定义的数据内容。如:可以直接在jwt令牌中存储用户的相关信息。
简单来讲,jwt就是将原始的json数据格式进行了安全的封装,这样就可以直接基于jwt在通信双方安全的进行信息传输了。
JWT的组成: (JWT令牌由三个部分组成,三个部分之间使用英文的点来分割)
-
第一部分:Header(头), 记录令牌类型、签名算法等。 例如:{"alg":"HS256","type":"JWT"}
-
第二部分:Payload(有效载荷),携带一些自定义信息、默认信息等。 例如:{"id":"1","username":"Tom"}
-
第三部分:Signature(签名),防止Token被篡改、确保安全性。将header、payload,并加入指定秘钥,通过指定签名算法计算而来。
签名的目的就是为了防jwt令牌被篡改,而正是因为jwt令牌最后一个部分数字签名的存在,所以整个jwt 令牌是非常安全可靠的。一旦jwt令牌当中任何一个部分、任何一个字符被篡改了,整个令牌在校验的时候都会失败,所以它是非常安全可靠的。
JWT是如何将原始的JSON格式数据,转变为字符串的呢?
其实在生成JWT令牌时,会对JSON格式的数据进行一次编码:进行base64编码
Base64:是一种基于64个可打印的字符来表示二进制数据的编码方式。既然能编码,那也就意味着也能解码。所使用的64个字符分别是A到Z、a到z、 0- 9,一个加号,一个斜杠,加起来就是64个字符。任何数据经过base64编码之后,最终就会通过这64个字符来表示。当然还有一个符号,那就是等号。等号它是一个补位的符号。
需要注意:Base64是编码方式,而不是加密方式。
JWT令牌最典型的应用场景就是登录认证:
- 在浏览器发起请求来执行登录操作,此时会访问登录的接口,如果登录成功之后,我们需要生成一个jwt令牌,将生成的 jwt令牌返回给前端。
- 前端拿到jwt令牌之后,会将jwt令牌存储起来。在后续的每一次请求中都会将jwt令牌携带到服务端。
- 服务端统一拦截请求之后,先来判断一下这次请求有没有把令牌带过来,如果没有带过来,直接拒绝访问,如果带过来了,还要校验一下令牌是否是有效。如果有效,就直接放行进行请求的处理。
在JWT登录认证的场景中,整个流程当中涉及到两步操作:
- 在登录成功之后,要生成令牌。
- 每一次请求当中,要接收令牌并对令牌进行校验。
然后进行代码中测试:
首先,需要引入依赖,这里我使用了jjwt依赖,还有许多其他的依赖选项,可以自行选择,在官方网站中也有推荐。
点击libraries -> 筛选语言(java),这里就可以看到一些依赖包含的加密方式和maven仓库的引用(底部),也可以去其github仓库中查看更加详细的信息。
jjwt的依赖:
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
JwtUtil.java
public class JwtUtil {
/**
* jwt密钥
* 此秘钥一定要保留好在服务端, 不能暴露出去, 否则sign就可以被伪造, 如果对接多个客户端建议改造成多个
*/
private static final String secretKey = "testJwt";
/**
* jwt过期时间(亳秒)
*/
private static final Long expire = 1800000L;
/**
* 生成Jwt令牌
* @param claims 设置的信息
* @return Jwt令牌
*/
public static String generateJwt(Map<String, Object> claims) {
return Jwts.builder()
// 设置的信息
.setClaims(claims)
// 设置签名使用的签名算法和签名使用的秘钥
.signWith(SignatureAlgorithm.HS256, secretKey.getBytes(StandardCharsets.UTF_8))
// 设置过期时间(毫秒)
.setExpiration(new Date(System.currentTimeMillis() + expire))
.compact();
}
/**
* jwt解密
* @param jwt jwt令牌
* @return 设置进入jwt的信息
*/
public static Claims ParseJwt(String jwt) {
return Jwts.parser()
// 设置签名的秘钥
.setSigningKey(secretKey.getBytes(StandardCharsets.UTF_8))
// 设置需要解析的jwt
.parseClaimsJws(jwt)
.getBody();
}
}
我将生成和解析jwt令牌进行封装,可以直接进行使用,其中secretKey和expire根据自己的需求进行更改即可。
JWTController.java
@RestController
public class JWTController {
/**
* 登录(生成jwt令牌)
*/
@PostMapping("/login")
public String login(String username, String password) {
// 登录成功后,生成jwt令牌
// 返回给前端,前端开发会保存jwt,以便每一次请求携带jwt令牌
Map<String, Object> claims = new HashMap<>();
claims.put("username", username);
claims.put("password", password);
String jwt = JwtUtil.generateJwt(claims);
// 解析JWT,这里一般会放在拦截器或过滤器中
Claims claims1 = JwtUtil.ParseJwt(jwt);
System.out.println("JWT解析后的数据:" + claims1);
return "登录成功!Jwt令牌为:" + jwt;
}
}
这里我将解析JWT的功能也写到了api接口中,解析JWT一般是会放在拦截器或过滤器来进行校验的,这里只是为了演示效果。
注意,有的人或许测试时会报错,提示下面的错误:
这是因为JDK版本过高,jjwt中的有些依赖已经别丢弃了,只需要在pom文件中添加如下依赖:
<dependency> <groupId>javax.xml.bind</groupId> <artifactId>jaxb-api</artifactId> <version>2.3.1</version> </dependency>
然后进行登录请求:
解析后的结果,在控制台输出:
这里简单演示了JWT的使用,关于过滤器和拦截器可以查看下一篇博客,在那里还会继续使用到JWT令牌技术。
4.2 Sa-Token
这里也给大家推荐一个比较见到那好用的工具,即Sa-token。
官方地址:sa-token.cc/
有兴趣的大家可以自行探索使用,目前我还在摸索中。