Shiro是如何拦截未登录请求的(二)

前言

在上一篇文章{% post_link Shiro是如何拦截未登录请求的(一) %}中提到了,我们在实际的项目中采用了基于token的方式来实现用户的身份鉴权,但是由于开发的时候对shiro的内部机制不太了解导致那一块的代码实现不够完善、整洁并且还对业务造成了影响,经过了对shiro源码的跟踪分析之后,我们已经知道shiro是如何拦截未登录请求的了,那么接下来我们开始来针对问题制定相应的解决方案.

解决方案

第一种方案
由于最初在app端是使用传输cookie的方式来实现身份鉴权的,跨域问题也已经解决了,为了尽量不改动已经写好的代码,我们可以想办法来让h5应用也能在跨域的情况下传输cookie,首先服务端在使用cors协议时需要设置响应消息头Access-Control-Allow-Credentials的值为true即允许在ajax访问时携带cookie,客户端方面也需通过js设置withCredentials为true才能真正实现跨域传输cookie.另外为了安全,在cors标准里不允许Access-Control-Allow-Origin设置为*,而是必须指定明确的、与请求网页一致的域名.cookie也依然遵循“同源策略”,只有用目标服务器域名设置的cookie才会上传,而且使用document.cookie也无法读取目标服务器域名下的cookie.接下来我们来看看代码是怎么实现的:
1.我们原先在springboot中关于支持跨域有多种实现方式,我们采用最后的一种:

    @Bean
    public FilterRegistrationBean corsFilter() {
        return new FilterRegistrationBean(new Filter() {
            public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
                    throws IOException, ServletException {
                HttpServletRequest request = (HttpServletRequest) req;
                HttpServletResponse response = (HttpServletResponse) res;
                String method = request.getMethod();
                String origin = request.getHeader("Origin");
                if(origin == null) {
                    origin = request.getHeader("Referer");
                }
                // this origin value could just as easily have come from a database
                response.setHeader("Access-Control-Allow-Origin", origin);            // 允许指定域访问跨域资源
                //response.setHeader("Access-Control-Allow-Origin", "*");
                response.setHeader("Access-Control-Allow-Methods", "GET, HEAD, POST, PUT, DELETE, OPTIONS");
                response.setHeader("Access-Control-Max-Age", "3600");
                response.setHeader("Access-Control-Allow-Credentials", "true");
                response.setHeader("Access-Control-Allow-Headers", "Accept, Origin, X-Requested-With, Content-Type,Last-Modified,device,token");
                if ("OPTIONS".equals(method)) {
                    response.setStatus(HttpStatus.OK.value());
                } else {
                    chain.doFilter(req, res);
                }
            }

            public void init(FilterConfig filterConfig) {
            }

            public void destroy() {
            }
        });
    }

2.客户端也不再需要在请求头中带上token了,只要登录之后不管调什么接口都会自动带上cookie到后端校验的,代码如下:

    $.ajax({
        url:'http://localhost:8080/win/api/test/cors',
        type:'post',
         beforeSend:(xhr)=> {
           //xhr.setRequestHeader('Content-Type','application/x-www-form-urlencoded');
           //xhr.setRequestHeader("token", "web_session_key-5ce2ae9c-8f79-4f83-9b47-1510da4b2fb0");
           xhr.setRequestHeader("device","APP");
        },
        xhrFields:{
            withCredentials:true,
            useDefaultXhrHeader:false
        },
        corssDomain:true,
        success:function(data){
        console.log(data);
        }
    });

这样接口就可以正常返回数据了,控制台也不再报错(注意request header中的cookie).

image.png

第二种方案
从上一篇文章中我们知道shiro是在其默认的会话管理器DefaultWebSessionManager中获取请求携带过来的cookie的,我们可以通过继承这个类来扩展其中相关的代码来实现我们的需求,之前在项目中我们已经扩展过这个类了,当时是为了重写其中定时验证session有效性的部分以便在session失效时做一些数据清理工作,下面贴出的是shiro从cookie中获取sessionid的主要源代码:

    @Override
    public Serializable getSessionId(SessionKey key) {
        Serializable id = super.getSessionId(key);
        if (id == null && WebUtils.isWeb(key)) {
            ServletRequest request = WebUtils.getRequest(key);
            ServletResponse response = WebUtils.getResponse(key);
            id = getSessionId(request, response);
        }
        return id;
    }

    protected Serializable getSessionId(ServletRequest request, ServletResponse response) {
        return getReferencedSessionId(request, response);
    }
    private Serializable getReferencedSessionId(ServletRequest request, ServletResponse response) {
        String id = getSessionIdCookieValue(request, response);
        .......
        return id;
    }
    private String getSessionIdCookieValue(ServletRequest request, ServletResponse response) {
        .......
        //getSessionIdCookie().readValue()操作的是cookie对象.
        return getSessionIdCookie().readValue(httpRequest, WebUtils.toHttp(response));
    }

那么我们只要在扩展类中覆写这些方法,通过在请求头传输过来的device标识便可以区分出不同的调用端来源,即pc端后台依然采用shiro原有的认证方式,而app端或者h5应用则可以使用基于token的身份认证方式,达到两者共存的目的.下面来看看我们自定义的CustomerWebSessionManager类,其继承了shiro的DefaultWebSessionManager类.

public class CustomerWebSessionManager extends DefaultWebSessionManager {

    private static final Logger logger = LoggerFactory.getLogger(CustomerWebSessionManager.class);

    private static final String AUTH_TOKEN = "token";

    public CustomerWebSessionManager() {
        super();
    }
    @Override
    public void validateSessions() {
        if (logger.isInfoEnabled()) {
            logger.info("Validating all active sessions...");
        }
        ......
    }

其中定义的类静态变量AUTH_TOKEN为请求头中需要携带的会话id的名称,validateSessions方法是我们重写的用来实现当session失效时做数据清理的.由于DefaultWebSessionManager中的大部分方法为私有的方法,无法为其子类所继承,所以只好重写其中所有用到的protected方法,代码如下:

    /**
     * 重写父类获取sessionID的方法,若请求为APP或者H5则从请求头中取出token,若为PC端后台则从cookie中获取
     *
     * @param request
     * @param response
     * @return
     */
    @Override
    protected Serializable getSessionId(ServletRequest request, ServletResponse response) {
        if (!(request instanceof HttpServletRequest)) {
            logger.debug("Current request is not an HttpServletRequest - cannot get session ID.  Returning null.");
            return null;
        }
        HttpServletRequest httpRequest = WebUtils.toHttp(request);
        if (StringHelpUtils.isNotBlank(httpRequest.getHeader("device"))
                && (httpRequest.getHeader("device").equals("APP") || httpRequest
                .getHeader("device").equals("H5"))) {
            //从header中获取token
            String token = httpRequest.getHeader(AUTH_TOKEN);
            // 每次读取之后都把当前的token放入response中
            HttpServletResponse httpResponse = WebUtils.toHttp(response);
            if (StringHelpUtils.isNotEmpty(token)) {
                httpResponse.setHeader(AUTH_TOKEN, token);
                request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_SOURCE, "header");
                request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID, token);
                request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_IS_VALID, Boolean.TRUE);
            }
            //sessionIdUrlRewritingEnabled的配置为false,不会在url的后面带上sessionID
            request.setAttribute(ShiroHttpServletRequest.SESSION_ID_URL_REWRITING_ENABLED, isSessionIdUrlRewritingEnabled());
            return token;
        }
        return getReferencedSessionId(request, response);
    }
    /**
     * shiro默认从cookie中获取sessionId
     *
     * @param request
     * @param response
     * @return
     */
    private Serializable getReferencedSessionId(ServletRequest request, ServletResponse response) {
        String id = getSessionIdCookieValue(request, response);
        if (id != null) {
            request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_SOURCE,
                    ShiroHttpServletRequest.COOKIE_SESSION_ID_SOURCE);
        } else {
            //not in a cookie, or cookie is disabled - try the request URI as a fallback (i.e. due to URL rewriting):
            //try the URI path segment parameters first:
            id = getUriPathSegmentParamValue(request, ShiroHttpSession.DEFAULT_SESSION_ID_NAME);
            if (id == null) {
                //not a URI path segment parameter, try the query parameters:
                String name = getSessionIdName();
                id = request.getParameter(name);
                if (id == null) {
                    //try lowercase:
                    id = request.getParameter(name.toLowerCase());
                }
            }
            if (id != null) {
                request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_SOURCE,
                        ShiroHttpServletRequest.URL_SESSION_ID_SOURCE);
            }
        }
        if (id != null) {
            request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID, id);
            //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());
        return id;
    }

    //copy from DefaultWebSessionManager
    private String getSessionIdCookieValue(ServletRequest request, ServletResponse response) {
        if (!isSessionIdCookieEnabled()) {
            logger.debug("Session ID cookie is disabled - session id will not be acquired from a request cookie.");
            return null;
        }
        if (!(request instanceof HttpServletRequest)) {
            logger.debug("Current request is not an HttpServletRequest - cannot get session ID cookie.  Returning null.");
            return null;
        }
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        return getSessionIdCookie().readValue(httpRequest, WebUtils.toHttp(response));
    }

    //since 1.2.2 copy from DefaultWebSessionManager
    private String getUriPathSegmentParamValue(ServletRequest servletRequest, String paramName) {
        if (!(servletRequest instanceof HttpServletRequest)) {
            return null;
        }
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        String uri = request.getRequestURI();
        if (uri == null) {
            return null;
        }
        int queryStartIndex = uri.indexOf('?');
        if (queryStartIndex >= 0) { //get rid of the query string
            uri = uri.substring(0, queryStartIndex);
        }
        int index = uri.indexOf(';'); //now check for path segment parameters:
        if (index < 0) {
            //no path segment params - return:
            return null;
        }
        //there are path segment params, let's get the last one that may exist:
        final String TOKEN = paramName + "=";
        uri = uri.substring(index + 1); //uri now contains only the path segment params
        //we only care about the last JSESSIONID param:
        index = uri.lastIndexOf(TOKEN);
        if (index < 0) {
            //no segment param:
            return null;
        }
        uri = uri.substring(index + TOKEN.length());
        index = uri.indexOf(';'); //strip off any remaining segment params:
        if (index >= 0) {
            uri = uri.substring(0, index);
        }
        return uri; //what remains is the value
    }

    //since 1.2.1 copy from DefaultWebSessionManager
    private String getSessionIdName() {
        String name = this.getSessionIdCookie() != null ? this.getSessionIdCookie().getName() : null;
        if (name == null) {
            name = ShiroHttpSession.DEFAULT_SESSION_ID_NAME;
        }
        return name;
    }

当shiro取不到sessionid时,会调用DelegatingSubject类中的getSession(true)方法创建一个新的session.

    public Session getSession(boolean create) {
        if (log.isTraceEnabled()) {
            log.trace("attempting to get session; create = " + create +
                    "; session is null = " + (this.session == null) +
                    "; session has id = " + (this.session != null && session.getId() != null));
        }

        if (this.session == null && create) {

            //added in 1.2:
            if (!isSessionCreationEnabled()) {
                String msg = "Session creation has been disabled for the current subject.  This exception indicates " +
                        "that there is either a programming error (using a session when it should never be " +
                        "used) or that Shiro's configuration needs to be adjusted to allow Sessions to be created " +
                        "for the current Subject.  See the " + DisabledSessionException.class.getName() + " JavaDoc " +
                        "for more.";
                throw new DisabledSessionException(msg);
            }

            log.trace("Starting session for host {}", getHost());
            SessionContext sessionContext = createSessionContext();
            Session session = this.securityManager.start(sessionContext);
            this.session = decorate(session);
        }
        return this.session;
    }

上面的第22行this.securityManager.start最终调用的是DefaultWebSessionManager中的onStart方法,所以我们要重写这个方法,将产生的sessionid放到response header中.另外当session失效或销毁时的相关方法也需重新实现,具体代码如下:

    //存储会话id到response header中
    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);
        }
        String idString = currentId.toString();
        if (StringHelpUtils.isNotBlank(request.getHeader("device"))
                && (request.getHeader("device").equals("APP") || request
                .getHeader("device").equals("H5"))) {
            response.setHeader(AUTH_TOKEN, idString);
        } else {
            Cookie template = getSessionIdCookie();
            Cookie cookie = new SimpleCookie(template);
            cookie.setValue(idString);
            cookie.saveTo(request, response);
        }
        logger.trace("Set session ID cookie for session with id {}", idString);
    }

    //设置deleteMe到response header中
    private void removeSessionIdCookie(HttpServletRequest request, HttpServletResponse response) {
        if (StringHelpUtils.isNotBlank(request.getHeader("device"))
                && (request.getHeader("device").equals("APP") || request
                .getHeader("device").equals("H5"))) {
            response.setHeader(AUTH_TOKEN, Cookie.DELETED_COOKIE_VALUE);
        } else {
            getSessionIdCookie().removeFrom(request, response);
        }
    }

    /**
     * 会话创建
     * Stores the Session's ID, usually as a Cookie, to associate with future requests.
     *
     * @param session the session that was just {@link #createSession created}.
     */
    @Override
    protected void onStart(Session session, SessionContext context) {
        super.onStart(session, context);
        if (!WebUtils.isHttp(context)) {
            logger.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 {
            logger.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);
    }
    //会话失效
    @Override
    protected void onExpiration(Session s, ExpiredSessionException ese, SessionKey key) {
        super.onExpiration(s, ese, key);
        onInvalidation(key);
    }

    @Override
    protected void onInvalidation(Session session, InvalidSessionException ise, SessionKey key) {
        super.onInvalidation(session, ise, key);
        onInvalidation(key);
    }

    private void onInvalidation(SessionKey key) {
        ServletRequest request = WebUtils.getRequest(key);
        if (request != null) {
            request.removeAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_IS_VALID);
        }
        if (WebUtils.isHttp(key)) {
            logger.debug("Referenced session was invalid.  Removing session ID cookie.");
            removeSessionIdCookie(WebUtils.getHttpRequest(key), WebUtils.getHttpResponse(key));
        } else {
            logger.debug("SessionKey argument is not HTTP compatible or does not have an HTTP request/response " +
                    "pair. Session ID cookie will not be removed due to invalidated session.");
        }
    }
    //会话销毁
    @Override
    protected void onStop(Session session, SessionKey key) {
        super.onStop(session, key);
        if (WebUtils.isHttp(key)) {
            HttpServletRequest request = WebUtils.getHttpRequest(key);
            HttpServletResponse response = WebUtils.getHttpResponse(key);
            logger.debug("Session has been stopped (subject logout or explicit stop).  Removing session ID cookie.");
            removeSessionIdCookie(request, response);
        } else {
            logger.debug("SessionKey argument is not HTTP compatible or does not have an HTTP request/response " +
                    "pair. Session ID cookie will not be removed due to stopped session.");
        }
    }

最后再在springboot中做如下配置:

    @Bean
    public CustomerWebSessionManager sessionManager() {
        CustomerWebSessionManager sessionManager = new CustomerWebSessionManager();
        //会话验证器调度时间
        sessionManager.setSessionValidationInterval(1800000);
        //定时检查失效的session
        sessionManager.setSessionValidationSchedulerEnabled(true);
        //是否在会话过期后会调用SessionDAO的delete方法删除会话 默认true
        sessionManager.setDeleteInvalidSessions(true);
        sessionManager.setSessionDAO(redisSessionDAO());
        sessionManager.setSessionIdUrlRewritingEnabled(false);
        sessionManager.setSessionIdCookie(wapsession());
        sessionManager.setSessionIdCookieEnabled(true);
        return sessionManager;
    }

我们来看看在postman中的接口访问测试结果:


image.png

而在h5应用中已无需再支持跨域传输cookie了,但需重新在请求头中传输token,js代码稍微做如下修改:

    $.ajax({
        url:'http://localhost:8080/win/api/test/cors',
        type:'post',
         beforeSend:(xhr)=> {
           //xhr.setRequestHeader('Content-Type','application/x-www-form-urlencoded');
           xhr.setRequestHeader("token", "web_session_key-26653f18-1d81-4bd3-a039-301870788abb");
           xhr.setRequestHeader("device","APP");
        },
        xhrFields:{
            //withCredentials:true,
            //useDefaultXhrHeader:false
        },
        //corssDomain:true,
        success:function(data){
        console.log(data);
        }
    });

通过浏览器可以看到已经成功访问接口了,控制台也没有报错,结果如下图(注意其中request header和response header中的token)


image.png

好了,到此我们已经完成了对shiro支持token身份认证的全部改造了.

推荐阅读更多精彩内容