1. Cookie是什么
Cookie是一种在客户端(通常是用户的Web浏览器)和服务器之间进行状态管理的技术。当用户访问Web服务器时,服务器可以向用户的浏览器发送一个名为Cookie的小数据块。浏览器会将这个Cookie存储在客户端,为这个Cookie设置了时间后,这个Cookie就会存储到客户端端的磁盘上。如果没有设置时间,它就会存放在浏览器所开启的进程内存中。在随后对该服务器的请求中,每次都会自动附带上这个Cookie。
Cookie通常包含一些基本信息,如唯一标识符、用户偏好设置、会话状态等。服务器通过解析这些Cookie来识别用户的身份、维持会话状态、个性化用户体验,甚至追踪用户行为。
每个Cookie都有特定的属性,如名称(name)、值(value)、过期时间(expiration date)、域(domain)、路径(path)等。服务器可以根据这些属性来确定何时发送或接收Cookie,以及如何处理它们。
简而言之,Cookie是Web应用中实现用户状态保持和个性化服务的重要手段,但它同时也涉及到用户隐私问题,因为它们可以被用于追踪用户在互联网上的活动。出于隐私保护原因,现代浏览器都提供了对Cookie的管理和控制功能,允许用户禁用、删除或限制特定网站的Cookie使用。
上一章讨论了Shiro如何保存会话,每个会话都会有一个SessionID。当一个会话被创建后,默认情况下这个SessionID作为Key被保存起来,上章保存到了Redis中。同时会被放入到Cookie中响应给浏览器,这样浏览器以后每次访问服务端,都会携带这个SessionID,服务端收到这个SessionID后,到Redis中获取Session数据,这样就能够识别到这个用户了。这就是保持会话的原理
2. 禁用Cookie
浏览器是可以禁用Cookie的,一旦浏览器禁用了Cookie后,传递SessionID没法传递到服务端,那就没法实现会话保持了。有一种办法是在所有请求的URL上添加一个请求参数如:JSESSIONID=2e8e4189-9254-4651-a77b-151f504efc3d
, 这个请求参数就是SessionID,服务端读取这个参数来获取SessionID从而实现保持会话,这种办法被称为 URL重写(URL rewrite)。但是这种方法相对比较麻烦,业内采用这种方式的不多。
2.1 为什么要禁用Cookie
有一些场景比如 服务端要为小程序提供API服务,需要与小程序应用之间保持会话,还有原生的手机应用客户端程序,这些都没有使用浏览器,没法使用Cookie。
而我们的服务端需要做到为多端提供服务,不同渠道的客户端与服务端之间保持会话如何进行统一?我们的思路是禁用掉Cookie,禁用后,服务端就不会再向客户端响应Cookie数据了,取而代之的是将SessionID 作为响应数据返回给客户端,客户端收到响应后,将这个SessionID保存起来,后面每次发出请求的时候,取出这个SessionID,放入到请求头中,服务端收到请求之后,从请求头中取出SessionID,从而实现会话跟踪。
其实就是把浏览器自动发送Cookie变成了客户端程序发送SessionID,只不过方式是放在请求头中的。但是Shiro默认是开启Cookie的,即使我们禁用了Cookie,Shiro也不会到请求头中去取,这些都需要我们自己写代码去进行改造。
2.2 服务端禁用Cookie
在前一章节中,自己配置了SessionManager
, 配置的是DefaultWebSessionManager
我们可以直接在服务端配置,让服务端不产生Cookie。为了方便比较,这里把禁用前和禁用后的相应信息截图出来。
现在不禁用Cookie, 用 Api fox 工具发起一次正确的登录,看看请求和响应报文分别是什么:( Api fox 工具的实际请求=>请求代码=>HTTP 可以看到请求报文)
-
请求报文
POST /login HTTP/1.1 Host: 127.0.0.1:8080 User-Agent: Apifox/1.0.0 (https://apifox.com) Accept: */* Host: 127.0.0.1:8080 Connection: keep-alive Content-Type: application/x-www-form-urlencoded username=administrator&password=admin
-
响应报文
可以看到,服务器端产生了Cookie,并返回给了客户端。
现在在服务端禁用Cookie,代码如下(第13,15行):
package com.qinyeit.shirojwt.demos.configuration;
@Configuration
@Slf4j
public class ShiroConfiguration {
// sessionManager配置
@Bean
public SessionManager sessionManager(
SessionFactory sessionFactory,
SessionDAO sessionDAO) {
DefaultWebSessionManager webSessionManager = new DefaultWebSessionManager();
// 禁用Cookie
webSessionManager.setSessionIdCookieEnabled(false);
// 禁用URL重写
webSessionManager.setSessionIdUrlRewritingEnabled(false);
// 既然cookie都禁用了,就没有必要设置它了
// webSessionManager.setSessionIdCookie(cookieTemplate);
// 自动配置中已经配置了sessionFactory 直接注入进来
webSessionManager.setSessionFactory(sessionFactory);
// 使用自定义的ShiroRedisSessionDAO
webSessionManager.setSessionDAO(sessionDAO);
// 清理无效的session
webSessionManager.setDeleteInvalidSessions(true);
// 开启session定时检查
webSessionManager.setSessionValidationSchedulerEnabled(true);
webSessionManager.setSessionValidationScheduler(new ExecutorServiceSessionValidationScheduler());
return webSessionManager;
}
...
}
修改完毕,重启服务后,将redis中保存的会话数据全部清除掉,然后再执行登录:
-
请求报文:
POST /login HTTP/1.1 Host: 127.0.0.1:8080 User-Agent: Apifox/1.0.0 (https://apifox.com) Accept: */* Host: 127.0.0.1:8080 Connection: keep-alive Content-Type: application/x-www-form-urlencoded username=administrator&password=admin
-
响应报文:
可以看到,服务端不会再将SessionID响应回给客户端了。(不必关注那个 Cookie 1,那是工具里遗留的上一次的数据,没有清理掉)
现在在请求Home,返回的就是:
{
"code": 401,
"msg": "未登录或登录已过期"
}
因为保持会话的 SessionID丢了。也就是说虽然登录成功了,但是后面再请求服务器的时候,服务器依然不认识这个请求。
3. 保持会话的办法
引用Cookie后,无法保持会话。我们可以将会话ID作为数据响应给客户端程序,客户端程序将这个会话ID临时存储起来,下次发起请求的时候,将它放入到请求头中。(JavaScript 中使用 axios 库,在拦截器中很方便将数据放入到请求头中)。
这里做一个约定: SessionID在服务端依然叫 SessionID, 返回到客户端后,换一个叫法叫做 Access-Token , 请求头的名字也叫 Access-Token ,其实它就是SessionID
如果请求头中加入了自定义的头,这里会引发跨域问题。根据CORS(Cross-Origin Resource Sharing,跨源资源共享)规范,当发起跨域请求时,如果请求包含了自定义请求头(即非简单请求头),浏览器会先发送一个预检(OPTIONS)请求到服务器,询问服务器是否允许实际的请求发生。
这个预检请求会携带
Access-Control-Request-Headers
头部,列出实际请求打算发送的自定义请求头。服务器需要在响应中通过Access-Control-Allow-Headers
头部告知浏览器哪些自定义请求头是可以接受的。只有当服务器确认允许这些自定义请求头之后,浏览器才会发送真实的POST、PUT、DELETE等请求。简单请求指的是那些方法为GET、HEAD、POST,且满足以下条件之一的请求:
- Content-Type 是 application/x-www-form-urlencoded、multipart/form-data 或 text/plain。
- 请求头仅包含Accept、Accept-Language、Content-Language、Last-Event-ID、Content-Type(满足上述简单请求条件),以及其他若干标准请求头。
只要超出简单请求的范畴,浏览器都会执行预检请求以确保跨域请求的安全性。
不过不用担心,如果我们能确保请求是在同源策略(协议、域名、端口号均相同)下进行的。如果是同源请求,则无论是否有自定义请求头,浏览器都不会发送OPTIONS预检请求。
4.改造DefaultWebSessionManager
DefaultWebSessionManager 默认从Cookie中获取。我们从SessionManager接口开始跟踪,看看它到底是如何获取sessionID的。
先看看类继承图:
脉络很清晰: DefaultWebSessionManager->DefaultSessionManager->AbstractValidatingSessionManager->AbstractNativeSessionManager->AbstractSessionManager->SessionManger
4.1 找改造点
而SessionManger中哪个方法是获取SessionID的呢?
public interface SessionManager {
Session start(SessionContext context);
Session getSession(SessionKey key) throws SessionException;
}
从第一个实现类开始,一次向下查找源代码:
-
AbstractSessionManager
: 抽象类,并没有实现SessionManager接口。这个类中定义了默认失效时间为 30分钟,也可以在外部调用setGlobalSessionTimeout(long globalSessionTimeout)
来设置失效时间 -
AbstractNativeSessionManager
抽象类,它实现了 getSession方法,在这个方法中又调用了 本类中的 lookupSession方法, lookupSession方法调用了本类中的抽象方法 doGetsession。 因为是抽象方法,所以子类中一定会实现这个doGetSession方法。这个类中可以set 一个 SessionListener 集合进来,这样就可以监听Session的 onStart, onStop,onExpiration
-
AbstractValidatingSessionManager
抽象类,它实现了 doGetSession方法,在doGetSession方法中,调用了 retrieveSession方法, 而retrieveSession方法又是本类中的一个抽象方法, 继续在子类中找retrieveSession 抽象方法的实现这个类中可以设置是否开启 用于定期验证会话的调度,和 调度器对象(SessionValidationScheduler)
-
DefaultSessionManager
类,它实现了 retrieveSession 方法,代码片段如下:在 retrieveSession 主要调用了本类中的getSessionId 和 retrieveSessionFromDataSource 两个方法。 getSessionId 方法被子类
DefaultWebSessionManager
重写了。非Web应用的SessionManager 使用的就是这个类。
protected Session retrieveSession(SessionKey sessionKey) throws UnknownSessionException { // 可以看到 从SessionKey 中获取sessionID是有可能为null的 // getSessionId 被 子类 DefaultWebSessionManager重写了 Serializable sessionId = getSessionId(sessionKey); if (sessionId == null) { LOGGER.debug("Unable to resolve session ID from SessionKey [{}]. Returning null to indicate a " + "session could not be found.", sessionKey); return null; } Session s = retrieveSessionFromDataSource(sessionId); if (s == null) { //session ID was provided, meaning one is expected to be found, but we couldn't find one: String msg = "Could not find session with ID [" + sessionId + "]"; throw new UnknownSessionException(msg); } return s; } // 从sessionKey中获取sessionID, 这个方法是 protected的,子类可以重写 protected Serializable getSessionId(SessionKey sessionKey) { return sessionKey.getSessionId(); } // 从dao中获取sessionID关联的session对象 protected Session retrieveSessionFromDataSource(Serializable sessionId) throws UnknownSessionException { return sessionDAO.readSession(sessionId); }
这个类中,可以set进来 sessionDAO, cacheManager和 sessionFactory
-
DefaultWebSessionManager
类,看名字就知道它与Web会话管理有关系,有与cookie,url重写相关的属性。来看看它重写的 getSessionId方法:// 重写父类的方法 @Override public Serializable getSessionId(SessionKey key) { Serializable id = super.getSessionId(key); // 父类方法中没有获取到SessionID,而且是 一个 web key (WebSessionKey类) if (id == null && WebUtils.isWeb(key)) { ServletRequest request = WebUtils.getRequest(key); ServletResponse response = WebUtils.getResponse(key); // 调用了下面的方法,最终调用 getReferencedSessionId 方法从cookie中获取sessionID id = getSessionId(request, response); } return id; } protected Serializable getSessionId(ServletRequest request, ServletResponse response) { return getReferencedSessionId(request, response); } // 省略的代码就是在从Cookie中获取 SessionID private Serializable getReferencedSessionId(ServletRequest request, ServletResponse response) { // 省略代码 .... return id; }
代码跟踪到这里,就知道了我们该如何做了。
-
继承DefaultWebSessionManager
-
重写
protected Serializable getSessionId(ServletRequest request, ServletResponse response)
这个方法,在这个方法中从请求头中获取sessionID -
在DefaultWebSessionManager 中,看到了一个方法
private void storeSessionId(Serializable currentId, HttpServletRequest request, HttpServletResponse response)
它是私有方法,被protected void onStart(Session session, SessionContext context)
调用了,这个私有方法主要是创建cookie,并将cookie写回到浏览器。所以可以根据自己的需要,如果需要将SessionID响应到客户端,比如写入到响应头上,就可以重写onStart方法,如果没有这个需求则不用管这个方法
-
4.2 扩展DefaultWebSessionManager
下面我们写一个 AccessTokenWebSessionManager类,继承 DefaultWebSessionManager,并重写getSessionId 方法,从请求头上获取SessionID
package com.qinyeit.shirojwt.demos.shiro.session;
...
@Slf4j
public class AccessTokenWebSessionManager extends DefaultWebSessionManager {
public AccessTokenWebSessionManager() {
// 禁用Cookie
super.setSessionIdCookieEnabled(false);
// 禁用URL重写
super.setSessionIdUrlRewritingEnabled(false);
// 因为已经禁用了cookie,所以没有必要有这个配置了.
// super.setSessionIdCookie(cookieTemplate);
// 清理无效的session
super.setDeleteInvalidSessions(true);
// 开启session定时检查
super.setSessionValidationSchedulerEnabled(true);
super.setSessionValidationScheduler(new ExecutorServiceSessionValidationScheduler());
}
//从请求头 X-Access-Token 获取SessionID
protected Serializable getSessionId(ServletRequest request, ServletResponse response) {
String sessionId = WebUtils.toHttp(request).getHeader("X-Access-Token");
if (sessionId != null) {
return sessionId;
}
return super.getSessionId(request, response);
}
}
4.3 改写Controller登录方法
原来登录成功后,是将sessionID,放入了cookie中响应给浏览器,浏览器下次请求的时候,只要cookie没有过期就会自动发送包含了sessionID的cookie。
现在cookie被禁用了,我们需要在登录成功后,将sessionID作为数据返回给客户端,客户端收到后存储起来,下次发送请求的时候,将它放入到请求头X-Access-Token
上 .
package com.qinyeit.shirojwt.demos.controller;
@RestController
@Slf4j
public class AuthenticateController {
...
@PostMapping("/login")
public Map<String, String> login(HttpServletRequest req) {
Subject subject = SecurityUtils.getSubject();
Map<String, String> map = new HashMap<>();
if (subject.isAuthenticated()) {
// 主体的标识,可以有多个,但是需要具备唯一性。比如:用户名,手机号,邮箱等。
PrincipalCollection principalCollection = subject.getPrincipals();
log.info("是否认证:{},当前登录用户主体信息:{}", subject.isAuthenticated(), principalCollection.getPrimaryPrincipal());
map.put("name", principalCollection.getPrimaryPrincipal().toString());
// 将sessionID作为数据返回给客户端。
map.put("accessToken", subject.getSession().getId().toString());
map.put("message", "登录成功");
} else {
...
}
return map;
}
...
}
4.3 配置SessionManager
自定义的AccessTokenWebSessionManager
需要配置成SpringBean:
@Configuration
@Slf4j
public class ShiroConfiguration {
...
// sessionManager配置 AccessTokenWebSessionManager
@Bean
public SessionManager sessionManager(
SessionFactory sessionFactory,
SessionDAO sessionDAO) {
AccessTokenWebSessionManager webSessionManager = new AccessTokenWebSessionManager();
// 自动配置中已经配置了sessionFactory 直接注入进来
webSessionManager.setSessionFactory(sessionFactory);
// 使用自定义的ShiroRedisSessionDAO
webSessionManager.setSessionDAO(sessionDAO);
return webSessionManager;
}
...
}
5. 测试
程序启动后,先登录:
请求报文:
POST /login HTTP/1.1
Host: 127.0.0.1:8080
User-Agent: Apifox/1.0.0 (https://apifox.com)
Accept: */*
Host: 127.0.0.1:8080
Connection: keep-alive
Content-Type: application/x-www-form-urlencoded
username=administrator&password=admin
响应结果:
{
"name": "SystemAccount(account=administrator, pwdEncrypt=0b188436fd5c434e3b8ed05cfe7c107250c1ff0ac034fad089db0f017ac3cacb, salt=55ae2b2c63ddd6d4763e0c57bda9078e)",
"accessToken": "eb6490d0-7562-457c-b98a-69af27b8d6bc",
"message": "登录成功"
}
可以看到,sessionID作为数据响应回来了。这里没有客户端程序(JavaScript) ,就手动在Api fox 中添加请求头 X-Access-Token
, 将 accessToken设置到工具中,然后发送请求到 home
请求报文:
GET / HTTP/1.1
Host: 127.0.0.1:8080
X-Access-Token: 312cc7ce-38e5-4f89-914d-4d452bb130e5
User-Agent: Apifox/1.0.0 (https://apifox.com)
Accept: */*
Host: 127.0.0.1:8080
Connection: keep-alive
响应结果:
{
"sessionKeys": "[org.apache.shiro.subject.support.DefaultSubjectContext_AUTHENTICATED_SESSION_KEY, org.apache.shiro.subject.support.DefaultSubjectContext_PRINCIPALS_SESSION_KEY]",
"name": "SystemAccount(account=administrator, pwdEncrypt=0b188436fd5c434e3b8ed05cfe7c107250c1ff0ac034fad089db0f017ac3cacb, salt=55ae2b2c63ddd6d4763e0c57bda9078e)"
}
6. 总结
- 可以在服务端应用中禁用Cookie,配置SessionManager中的 sessionIdCookieEnabled和sessionIdUrlRewritingEnabled
- Cookie一旦被禁用,SessionID无法传递,无法保持会话。我们可以在每个请求发出前,在请求报文中加入自定义请求头如:
X-Access-Token
,将SessionID放入到这个头上 - 自定义一个SessionManager,继承DefaultWebSessionManager,并重写getSessionId 方法从请求头中获取SessionID
代码仓库 https://github.com/kaiwill/shiro-jwt , 本节代码在 5_springboot_shiro_jwt_多端认证鉴权_禁用Cookie 分支上.