5_springboot_shiro_jwt_多端认证鉴权_禁用Cookie

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;
    }
    

    代码跟踪到这里,就知道了我们该如何做了。

    1. 继承DefaultWebSessionManager

    2. 重写 protected Serializable getSessionId(ServletRequest request, ServletResponse response) 这个方法,在这个方法中从请求头中获取sessionID

    3. 在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. 总结

  1. 可以在服务端应用中禁用Cookie,配置SessionManager中的 sessionIdCookieEnabled和sessionIdUrlRewritingEnabled
  2. Cookie一旦被禁用,SessionID无法传递,无法保持会话。我们可以在每个请求发出前,在请求报文中加入自定义请求头如: X-Access-Token ,将SessionID放入到这个头上
  3. 自定义一个SessionManager,继承DefaultWebSessionManager,并重写getSessionId 方法从请求头中获取SessionID

代码仓库 https://github.com/kaiwill/shiro-jwt , 本节代码在 5_springboot_shiro_jwt_多端认证鉴权_禁用Cookie 分支上.

  • 18
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

paopao_wu

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值