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 跨域共享可以使用单点登录方式解决。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值