什么是 Session
session 是一种服务端的会话机制。(被称为域对象),作为范围是一次会话的范围。服务器为每个用户创建一个会话,存储用户的相关信息,以便多次请求能够定位到同一个上下文。当用户在应用程序的 Web 页之间跳转时,存储在 Session 对象中的变量将不会丢失,而是在整个用户会话中一直存在下去。当用户请求来自应用程序的 Web 页时,如果该用户还没有会话,则 Web 服务器将自动创建一个 Session 对象。当会话过期或被放弃后,服务器将终止该会话。
Session 一致性问题
当第一次请求来时,服务器端会接受到客户端请求,会创建一个 session,使用响应头返回 sessionid 给客户端。浏览器获取到 sessionid 后会保存到本地 cookie 中,当第二次请求来时,客户端会读取本地的 sessionid,存放在请求头中,服务端在请求头中获取对象的 sessionid 在本地 session 内存中查询,session 属于会话机制,当当先会话结束时,session 就会被销毁,并且 web 程序会为每一次不同的会话创建不同的 session,所以在分布式场景下,即使是调用同一个方法执行同样的代码,但是他们的服务器不同,自然 web 程序不同,整个上下文对象也不同,理所当然 session 也是不同的。
Cookie 跨域
Cookie 保存在浏览器端,并不是用浏览器打开网页都可以读取到 cookie,一定要是同一个域名下。
代码演示分布式 Session 存在的问题
首先利用 SpringBoot 初始化器创建一个 SpringBoot 项目如下图:
然后紧接着创建一个控制器 MyController.java
:
/**
* @author BNTang
* @version 1.0
* @project distributed-session-project
* @description
* @since Created in 2021/10/4 004 10:24
**/
public class MyController {
/**
* 登录
*
* @param username 用户名
* @param httpSession http会话
* @return {@link String}
*/
("/login")
public String login(String username, HttpSession httpSession) {
httpSession.setAttribute("user", username);
return "登录成功:" + username;
}
/**
* 用户信息
*
* @param httpSession http会话
* @return {@link String}
*/
("/userInfo")
public String userInfo(HttpSession httpSession) {
return "当前用户为:" + httpSession.getAttribute("user");
}
}
启动两个不同端口但是代码一样的服务出来,首先需要开启允许多启动项目的选项如下图:
首先启动第一个,修改 application.yml 端口号为 8080,然后点击 run 按钮:
第一个服务启动完毕:
紧接着还是和上面一样的步骤,修改 application.yml 修改端口号为 8081,然后点击 run 按钮启动:
启动浏览器访问:http://localhost:8080/login?username=jonathanLee 效果如下:
然后在访问:http://localhost:8081/userInfo 效果如下:
从如上图的请求过程发现,JSESSIONID 是不同的,那么就说明一个问题,我们访问第一台服务器 8080 端口的 login 接口时服务端帮我们创建了一个 session 会话,然后给客户端浏览器返回了一个 JSESSIONID,当我们访问第二台服务器 8081 的时候浏览器会带着 JSESSIONID 去服务端找有没有对应的 sessionId 会话,那么这个时候第二个服务端里面肯定是没有的,所以会重新创建一个返回给客户端浏览器,所以获取的就是为 null 了这一点就是如上所说 session 一致性的问题,那么如何解决该问题呢,解决方案有几种分别如下。
Spring Session
实现原理
当 Web 服务器接收到 http 请求后,当请求进入对应的 Filter 进行过滤,将原本需要由 web 服务器创建会话的过程转交给 Spring-Session 进行创建,本来创建的会话保存在 Web 服务器内存中,通过 Spring-Session 创建的会话信息可以保存第三方的服务中,如:Redis,MySQL 等。Web 服务器之间通过连接第三方服务来共享数据,实现 Session 共享!
实现过程
添加依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
然后在修改 application.yml 添加配置即可:
spring
redis
host127.0.0.1
port6379
session
store-type redis
timeout3600
redis
namespace login_user
然后在像如上的方式那样进行测试,我这里就直接上解决的效果图了如下:
Token + Redis
注入 redisTemplate
,修改 MyController.java:
/**
* @author BNTang
* @version 1.0
* @project distributed-session-project
* @description
* @since Created in 2021/10/4 004 10:24
**/
public class MyController {
private StringRedisTemplate stringRedisTemplate;
/**
* 登录
*
* @param username 用户名
* @return {@link String}
*/
("/login")
public String login(String username) {
String key = "token_" + UUID.randomUUID();
this.stringRedisTemplate.opsForValue().set(key, username, 3600L, TimeUnit.SECONDS);
return "登录成功:" + key;
}
/**
* 用户信息
*
* @param token 令牌
* @return {@link String}
*/
("/userInfo")
public String userInfo(String token) {
return "当前用户为:" + this.stringRedisTemplate.opsForValue().get(token);
}
}
删除之前添加的 SpringSession 依赖,就留下 spring-boot-starter-data-redis,如下图:
测试方式还是和之前的差不多,只不过就是获取用户信息的时候需要带着服务端返回的 token 进行获取即可效果如下:
Token 与 JWT
登录模式
单一服务器模式
单一服务器,用户认证,用户信息存储到 session 当中。
SSO(single sign on)模式
单点登录(Single Sign On,SSO),就是通过用户的一次性鉴别登录,当用户在身份认证服务器上登录一次以后,即可获得访问单点登录系统中其他关联系统和应用软件的权限,同时这种实现是不需要管理员对用户的登录状态或其他信息进行修改的,这意味着在多个应用系统中,用户只需一次登录就可以访问所有相互信任的应用系统。
优点,用户身份信息独立管理,更好的分布式管理。可以自己扩展安全策略,缺点,认证服务器访问压力较大。
Token 模式
Token 是一种无状态、跨服务器、高性能的验证模式,特点,无状态、可扩展,支持移动设备,跨程序调用,安全,原理,基于 Token 的身份验证是无状态的,我们不用将用户信息存在服务器或 Session 中,用户通过用户名和密码发送请求。程序验证。程序返回一个签名的 token 给客户端。客户端储存 token,并且每次请求都会附带它。服务端验证 token 并返回数据。
原理图
优点,无状态:token 无状态,session 有状态的,基于标准化:你的 API 可以采用标准化的 JSON Web Token(JWT),缺点,占用带宽,无法在服务器端销毁。
验证方式
传统用户身份验证,特点,用户向服务器发送用户名和密码。验证服务器后,相关数据(如用户角色,登录时间等)将保存在当前会话中。用户的每个后续请求都将通过在 Cookie 中取出 session_id 传给服务器。服务器收到 session_id 并对比之前保存的数据,确认用户的身份。服务器向用户返回 session_id,session 信息都会写入到用户的 Cookie。缺点,没有分布式架构,无法支持横向扩展
共享 Session
将透明令牌存入 cookie,将用户身份信息存入 Redis
JWT 令牌,在微服务之间使用 JWT 共享信息
JWT 令牌
JSON Web Token(JWT)是一个开放的行业标准(RFC 7519)它定义了一种简介的、自包含的协议格式,用于在通信双方传递 json 对象,传递的信息经过数字签名可以被验证和信任,JWT 可以使用 HMAC 算法或使用 RSA 的公钥/私钥对来签名,防止被篡改,对普通的信息进行加密处理后,转成 json,转过之后的数据 称它是 token。
JWT 能做什么
授权,这是使用 JWT 的最常见方案。一旦用户登录,每个后续请求将包括 JWT,从而允许用户访问该令牌允许的路由,服务和资源,登录成功之后,生成一个 JWT,当中包含用户的信息,信息交换,JSON Web Token 是在各方之间安全地传输信息的好方法。
Session 认证与 JWT 认证的区别
session:基于 session 和基于 jwt 的方式的主要区别就是用户的状态保存的位置,session 是保存在服务端的,下一次再取从 session 当中取数据。jwt 认证,用户输入用户名与密码,校验(从数据库当中查看有没有对应的数据),如果有对应的数据,会把用户取出来,把取出的用户数据,转成 JWT,以 token 令牌的形式传给前端,前端拿到数据之后,会给存储到 cookie,以后每一次请求都要携带 token,服务器就会获取 token 之后,再进行 jwt 解析,读取用户数据,如果没有数据, 就代表没有登录,而 jwt 是保存在客户端的。
JWT 令牌的优缺点
JWT 令牌的优点,jwt 基于 json,非常方便解析。可以在令牌中自定义丰富的内容,易扩展。通过非对称加密算法及数字签名技术,JWT 防止篡改,安全性高。资源服务使用JWT可不依赖认证服务即可完成授权。JWT 令牌的缺点,JWT 令牌较长,占存储空间比较大。
令牌结构
JWT 令牌由三部分组成,每部分中间使用点(.)分隔,比如:xxxxx.yyyyy.zzzzz
。
三部分
Header
JWT 头部分是一个描述 JWT 元数据的 JSON 对象,头部包括令牌的类型(即 JWT)及使用的哈希算法(如 HMAC SHA256 或 RSA)。
{
"alg": "HS256",
"typ": "JWT"
}
Payload
第二部分是负载,内容也是一个 json 对象。
JWT 指定七个默认字段供选择:
- iss:发行人
- exp:到期时间
- sub:主题
- aud:用户
- nbf:在此之前不可用
- iat:发布时间
- jti:JWT ID 用于标识该 JWT
除以上默认字段外,我们还可以自定义私有字段:
{
"sub": "1234567890",
"name": "456",
"admin": true
}
此部分不建议存放敏感信息,因为此部分可以解码还原原始内容。最后将第二部分负载使用 Base64Url 编码,得到一个字符串就是 JWT 令牌的第二部分。
Signature
第三部分签名哈希部分是对上面两部分数据签名,通过指定的算法生成哈希,以确保数据不会被篡改。首先,需要指定一个密码(secret)。该密码仅仅为保存在服务器中,并且不能向用户公开。然后,使用标头中指定的签名算法(默认情况下为 HMAC SHA256)根据以下公式生成签名:
HMACSHA256(
base64UrlEncode(header) + "." +base64UrlEncode(payload),secret
)
Java 中使用方法
添加依赖:
<!-- JWT-->
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.4.1</version>
</dependency>
使用 jwt,修改 MyController.java:
/**
* @author BNTang
* @version 1.0
* @project distributed-session-project
* @description
* @since Created in 2021/10/4 004 10:24
**/
public class MyController {
private static final String JWT_KEY = "BNTang";
private static final String USERNAME = "username";
private static final String UID = "uid";
/**
* 登录
*
* @param username 用户名
* @return {@link String}
*/
("/login")
public String login(String username) {
Algorithm algorithm = Algorithm.HMAC256(JWT_KEY);
return JWT.create()
.withClaim(USERNAME, username)
.withClaim(UID, "1001")
.withExpiresAt(new Date(System.currentTimeMillis() + 10000))
.sign(algorithm);
}
/**
* 用户信息
*
* @param token 令牌
* @return {@link String}
*/
("/userInfo")
public String userInfo(String token) {
Algorithm algorithm = Algorithm.HMAC256(JWT_KEY);
JWTVerifier verifier = JWT.require(algorithm).build();
try {
DecodedJWT jwt = verifier.verify(token);
System.out.println(jwt);
return jwt.getClaim(USERNAME).asString();
} catch (TokenExpiredException e) {
System.out.println("token过期");
} catch (JWTDecodeException e) {
System.out.println("token错误");
}
return null;
}
}
测试方式还是和之前的差不多,如下:
token 其实是可以放在请求头当中的,我这里方便测试就没有放在请求头当中了:
拦截 Token
创建拦截器
LoginInterceptor.java
/**
* @author BNTang
* @version 1.0
* @project distributed-session-project
* @description
* @since Created in 2021/10/4 004 23:46
**/
public class LoginInterceptor implements HandlerInterceptor {
private static final String JWT_KEY = "BNTang";
private static final String JWT_TOKEN = "header_token";
private static final String UID = "uid";
private static final String LOGIN_USER = "username";
/**
* 前处理
* 返回true, 表示不拦截,继续往下执行
* 返回false/抛出异常,不再往下执行
*
* @param request 请求
* @param response 响应
* @param handler 处理程序
* @return boolean
*/
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String token = request.getHeader(JWT_TOKEN);
if (StringUtil.isNullOrEmpty(token)) {
throw new RuntimeException("token为空!");
}
Algorithm algorithm = Algorithm.HMAC256(JWT_KEY);
JWTVerifier verifier = JWT.require(algorithm).build();
try {
DecodedJWT jwt = verifier.verify(token);
request.setAttribute(UID, jwt.getClaim(UID).asInt());
request.setAttribute(LOGIN_USER, jwt.getClaim(LOGIN_USER).asString());
} catch (TokenExpiredException e) {
// token过期
throw new RuntimeException("token 过期!");
} catch (JWTDecodeException e) {
// token错误
throw new RuntimeException("解码失败,token 错误!");
}
return true;
}
}
配置拦截器
WebMvcConfig.java
/**
* web mvc配置
*
* @author BNTang
* @date 2021/10/05
*/
public class WebMvcConfig implements WebMvcConfigurer {
private LoginInterceptor loginInterceptor;
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginInterceptor)
.addPathPatterns("/userInfo");
}
}
使用一下配置好的拦截器存放在 request 域当中的用户数据,修改 MyController.java:
/**
* @author BNTang
* @version 1.0
* @project distributed-session-project
* @description
* @since Created in 2021/10/4 004 10:24
**/
public class MyController {
private static final String JWT_KEY = "BNTang";
private static final String USERNAME = "username";
private static final String UID = "uid";
/**
* 登录
*
* @param username 用户名
* @return {@link String}
*/
("/login")
public String login(String username) {
Algorithm algorithm = Algorithm.HMAC256(JWT_KEY);
return JWT.create()
.withClaim(USERNAME, username)
.withClaim(UID, "1001")
.withExpiresAt(new Date(System.currentTimeMillis() + 10000))
.sign(algorithm);
}
/**
* 用户信息
*
* @param username 用户名
* @return {@link String}
*/
("/userInfo")
public String userInfo( String username) {
return username;
}
}
测试方式同上,我测试的结果如下图所示: