Spring Boot 2.X + Shiro 优雅解决 session 跨域问题

1 篇文章 0 订阅
0 篇文章 0 订阅

一、跨域问题

web 开发中跨域问题是一个老生常谈的问题,根本原因是浏览器基于安全原因考虑对非同源的脚本操作和 ajax 访问进行了限制,介绍的文章网上有很多,这里不做赘述。

二、解决方案

跨域问题有多种解决方案,笔者认为最简单的办法的就是用 nginx 反向代理将不同源的静态站点和后端 rest 接口转换为同源,这样在浏览器端打开就不存在跨域问题了,当然这并不是接下来介绍的解决方案。

 Spring MVC 添加跨域配置支持,集成 Shiro,并且使用自定义的 SessionManager 替代容器自带的 Session 管理功能,主要代码如下:

package com.qiwen.base.config.shiro;

import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.session.Session;
import org.apache.shiro.session.mgt.SessionContext;
import org.apache.shiro.web.servlet.Cookie;
import org.apache.shiro.web.servlet.ShiroHttpServletRequest;
import org.apache.shiro.web.servlet.SimpleCookie;
import org.apache.shiro.web.session.mgt.DefaultWebSessionManager;
import org.apache.shiro.web.util.WebUtils;

import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.Serializable;

/**
 * 扩展了 SessionId 保存逻辑
 */
@Slf4j
public class QWWebSessionManager extends DefaultWebSessionManager {

    /**
     * 保存新 sessionId 默认实现
     */
    public interface SessionIdManager {

        /**
         * 从请求中读取 sessionId 信息
         * @param sessionManager QWWebSessionManager 对象引用
         * @param request
         * @param response
         * @return
         */
        Serializable getSessionId(QWWebSessionManager sessionManager, ServletRequest request, ServletResponse response);

        /**
         * 保存 sessionId 到客户端
         * @param currentId 新创建的 sessionId,保证客户端能够获取到此值
         * @param realCookie 需要保存到客户端的 cookie 信息, 按照需求决定是否返回给客户端
         * @param request
         * @param response
         */
        void storeSessionId(Serializable currentId, Cookie realCookie, HttpServletRequest request, HttpServletResponse response);
    }

    private SessionIdManager sessionIdManager;

    public QWWebSessionManager() {

    }

    public QWWebSessionManager(SessionIdManager sessionIdManager) {
        this.sessionIdManager = sessionIdManager;
    }

    public SessionIdManager getSessionIdManager() {
        return sessionIdManager;
    }

    public void setSessionIdManager(SessionIdManager sessionIdManager) {
        this.sessionIdManager = sessionIdManager;
    }

    private void storeSessionId(Serializable currentId, HttpServletRequest request, HttpServletResponse response) {
        if (currentId == null) {
            String msg = "sessionId cannot be null when persisting for subsequent requests.";
            throw new IllegalArgumentException(msg);
        }
        // 不能调用 template.saveTo 方法保存 sessionId, 因为 template 是共享的,直接调用在多线程是不安全的
        Cookie template = getSessionIdCookie();
        Cookie cookie = new SimpleCookie(template);
        String idString = currentId.toString();
        cookie.setValue(idString);

        if(this.sessionIdManager != null) {
            sessionIdManager.storeSessionId(currentId, cookie, request, response);
        } else {
            cookie.saveTo(request, response);
        }

        log.trace("Set session ID cookie for session with id {}", idString);
    }

    @Override
    protected void onStart(Session session, SessionContext context) {
        if (!WebUtils.isHttp(context)) {
            log.debug("SessionContext argument is not HTTP compatible or does not have an HTTP request/response " +
                    "pair. No session ID cookie will be set.");
            return;

        }
        HttpServletRequest request = WebUtils.getHttpRequest(context);
        HttpServletResponse response = WebUtils.getHttpResponse(context);

        if (isSessionIdCookieEnabled()) {
            Serializable sessionId = session.getId();
            storeSessionId(sessionId, request, response);
        } else {
            log.debug("Session ID cookie is disabled.  No cookie has been set for new session with id {}", session.getId());
        }

        request.removeAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_SOURCE);
        request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_IS_NEW, Boolean.TRUE);
    }

    public Serializable getReferencedSessionId(ServletRequest request, ServletResponse response) {
        return super.getSessionId(request, response);
    }

    @Override
    protected Serializable getSessionId(ServletRequest request, ServletResponse response) {
        Serializable sessionId;

        if(this.sessionIdManager != null) {
            sessionId = this.sessionIdManager.getSessionId(this, request, response);

            if (sessionId != null) {
                request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID, sessionId);
                //automatically mark it valid here.  If it is invalid, the
                //onUnknownSession method below will be invoked and we'll remove the attribute at that time.
                request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_IS_VALID, Boolean.TRUE);
            }

            // always set rewrite flag - SHIRO-361
            request.setAttribute(ShiroHttpServletRequest.SESSION_ID_URL_REWRITING_ENABLED, isSessionIdUrlRewritingEnabled());
        } else {
            sessionId = super.getSessionId(request, response);
        }

        return sessionId;
    }
}

上文的中的代码定义了 QWWebSessionManager.SessionIdManager 作为自定义 sessionId 读取和保存的逻辑扩展点,下面是一个 QWWebSessionManager.SessionIdManager 接口的示例实现,如下:

package com.qiwen.sms.config;

import cn.hutool.core.util.StrUtil;
import com.qiwen.base.config.shiro.QWWebSessionManager;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.session.mgt.DefaultSessionKey;
import org.apache.shiro.web.servlet.Cookie;
import org.apache.shiro.web.util.WebUtils;
import org.springframework.boot.web.servlet.ServletListenerRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSessionAttributeListener;
import javax.servlet.http.HttpSessionBindingEvent;
import java.io.Serializable;

@Slf4j
@Configuration
public class SmsBeanConfig {

    /**
     * 跨域配置
     */
    @Bean
    public WebMvcConfigurer crosConfigurer() {
        return new WebMvcConfigurer() {
            @Override
            public void addCorsMappings(CorsRegistry registry) {
                registry.addMapping("/sms/**")
                        .allowedOrigins("*")
                        .allowedMethods("GET", "POST", "DELETE", "PUT", "OPTIONS")
                        // 跨域预检测请求头, 默认允许所有请求头, 如果有特殊需求自定义配置
                        // .allowedHeaders("*")

                        // 允许的响应头,这里默认需要添加 set-custom-token 响应头,
                        // 以保证响应的 sessionId 不浏览器拦截
                        .exposedHeaders("set-custom-token")
                        .allowCredentials(true)
                        .maxAge(3600 * 30);
            }
        };
    }

    /**
     * 实现一个简单的 SessionIdManager
     */
    @Bean
    public QWWebSessionManager.SessionIdManager sessionIdManager() {
        return new QWWebSessionManager.SessionIdManager() {

            @Override
            public Serializable getSessionId(QWWebSessionManager sessionManager, ServletRequest request, ServletResponse response) {
                // getReferencedSessionId 表示使用原来的 sessionId 读取逻辑,如果不需要,可以删除以下的相关逻辑
                Serializable sessionId = sessionManager.getReferencedSessionId(request, response);

                if(sessionId != null) {
                    return sessionId;
                }

                HttpServletRequest httpServletRequest = WebUtils.toHttp(request);
                String token = httpServletRequest.getHeader("custom-token");

                if(StrUtil.isEmpty(token)) {
                    return token;
                }

                // 校验 token 是否有效,避免上传无效 token 导致 session 校验抛出异常
                if(sessionManager.isValid(new DefaultSessionKey(token))) {
                    return token;
                }

                return null;
            }

            @Override
            public void storeSessionId(Serializable currentId, Cookie realCookie, HttpServletRequest request, HttpServletResponse response) {

                // 调用此方法添加 Set-Cookie 响应头,如果前端访问后端接口都是跨域请求,
                // 则可以注释下面的代码
                 realCookie.saveTo(request, response);

                // currentId 则代表新创建的 sessionId,和 realCookie.getValue() 值一致。
                // 下面简单的为 response 设置 set-custom-token, 值为 currentId
                response.addHeader("set-custom-token", currentId.toString());
            }
        };
    }

}

Spring Boot + Shiro 配置网上有许多教程,大同小异,这里不做赘述,只要将使用 QWWebSessionManger 替换 DefaultWebSessionManger 即可。

以上配置可以实现使用 custom-token/set-custom-token 替换 cookie/set-cookie 的简单功能,上述配置完成之后,项目中其他地方使用 Session 的逻辑不需要做任何更改,也不用使用拦截器或者在自己的业务代码里面主动返回 sessionId 给前端。

下面提供了一个简单的示例:

后端代码

// 每次将 session 里面的 count 增加 1
@ResponseBody
@GetMapping("/sms/index2")
public String index2(HttpSession session) {
    Object count = session.getAttribute("count");
    if(count == null) {
        session.setAttribute("count", 1);
    } else {
        int intCount = (int) count;
        session.setAttribute("count", ++intCount);
    }
    return Result.ok().putData("count", session.getAttribute("count")).json();
}

前端全局处理 token 逻辑,使用了 jQuery 实现,如下:

<html>
    <head>
        <meta http-equiv="Content-Type" content="text/html;charset=UTF-8">
        <title>跨域测试</title>
        <script type="text/javascript" src="https://libs.cdnjs.net/vue/2.6.10/vue.js"></script>
        <script type="text/javascript" src="https://cdn.bootcss.com/jquery/2.1.1/jquery.js"></script>
        <style type="text/css">
            * {margin:0;padding:0;list-style:none}
        </style>
    </head>
    <body>
        <div id="app">
            <div style="text-align: center; padding-top: 20px;">
                <h1 style="text-align: center;">{{ count }}</h1>
                <button @click="sendRequest">click Me</button>
            </div>
        </div>
        <script type="text/javascript">
            $.ajaxSetup({
                // 用户没有发送 custom-token 头,就自动添加
                beforeSend: function(xhr, options) {
                    if(!options.headers || !options.headers['custom-token']) {
                        xhr.setRequestHeader('custom-token', localStorage.getItem('token-info'));
                    }
                },
                // 请求完成之后发现响应头里面有 set-custom-token,就直接存在 localStorage 里面。
                complete: function(xhr, status) {
                    token = xhr.getResponseHeader('set-custom-token');
                    if(!!token) {
                        localStorage.setItem('token-info', token);
                    }
                }
            });

            new Vue({
                el: '#app',
                data: {
                    url: 'http://localhost:8443/sms/index2',
                    count: -1
                },
                methods: {
                    sendRequest () {
                        $.getJSON(this.url, (data) => {
                            this.count = data.body.count;
                        });
                    }
                }
            })

        </script>
    </body>
</html>

源码地址参考:ShiroConfig.java,具体文档参看:后端文档#跨域配置支持

另外一个跨域的问题是图片验证码的问题,不跨域的情况下直接访问验证码地址浏览器能够自动附带 cookie 信息,但是跨域情况下此种方法会失效,解决办法使直接使用 xhr 访问图片地址,将后端返回的 blob 转换为 base64 data-url 的形式展示即可,或者是后端直接转换为 base64 返回给前端(适用于 jQuery,因为 jQuery 不支持读取 blob 类型)。

三、总结

网上有许多 Shiro 跨域 session 解决方案,但是 sessionId 保存至客户端这个步骤几乎都是下面两个方法:

  1. 使用拦截器,将 sessionId 读取出来返回给客户端
  2. 在业务代码中将 sessionId 读取出来返回给客户端

笔者觉得比较优雅的方式还是直接简单的扩展 Shiro DefaultWebSessionManger 读取和保存 sessionId,只要配置一处就能解决 session 跨域失效的问题。

并且在获取 sessionId 的时候几乎都没有做 sessionId 有效性校验,但是这样潜在的风险就是客户端上传了一个失效的 seesionId,服务器读取 sessionId 之后报错。

此方案解决的 session 跨域问题,不是 session 跨域共享问题,Session 跨域共享可以使用单点登录方式解决。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值