008-shiro使用token认证

实际开发中,我们的项目大多数都采用前后端分离或者集群的方式部署。在这些情况下,往往会有些问题,比如前后端分离的跨域问题、跨域携带cookie的问题、分布式应用下的session共享等问题,虽然这些问题都能得到相应的解决,但总是觉得没有直接使用token或者jwt方便。接下来我们来对我们目前实现的shiro功能进行一些修改,让它能直接使用token的方式进行认证。

我们先修改一下LoginController

@RequestMapping("login")
    public MyResponse login(@RequestBody MyUser myUser){
        Subject subject = SecurityUtils.getSubject();
        UsernamePasswordToken usernamePasswordToken = new UsernamePasswordToken(myUser.getUserName(), myUser.getPassword());
        usernamePasswordToken.setRememberMe(true);

        try {
            subject.login(usernamePasswordToken);
        } catch (LockedAccountException e) {
            return MyResponse.error("用户被锁定");
        } catch (AuthenticationException e){
            return MyResponse.error("用户名密码错误");
        }

        return MyResponse.success("登录成功",subject.getSession().getId());
    }

登录后直接返回sessionId(也就是我们的token)给前端,当然你也可以通过其他方式给前端提供token。

编写获取session的Controller

@RestController
@RequestMapping("testSession")
public class TestSessionController {

    @RequestMapping("getSession")
    public MyResponse getSession(){
        Subject subject = SecurityUtils.getSubject();
        Session session = subject.getSession();

        return MyResponse.success(session);
    }
}

除了获取session,我们还可以通过subject.getPrincipal()获取我们的登录用户信息。

编写axios异步请求

当然JQuery或直接AJAX请求也可以

编写test.html文件如下:

DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Titletitle>
    <script src="./js/axios.min.js">script>
head>
<body>


<button onclick="getToken()" type="button">获取tokenbutton>
<button onclick="checkSession()" type="button">验证sessionbutton>

<script>

    axios.defaults.withCredentials=false
    axios.defaults.crossDomain=true
    axios.defaults.headers.post['Content-Type'] = 'application/json'

    var baseUrl = "http://localhost:8099/shiro1";
    var token = '';
    var request = axios.create({
        baseURL: baseUrl, // api的base_url
        timeout: 50000, // request timeout
        data: {}
    });


    function getToken() {

        request({
            url: '/login/login',
            method: 'post',
            data : {
                userName: 'anonymous',
                password: '123456'
            }
        }).then(response => {
            console.log(response.data)
            token = response.data.content;
        });
    }

    function checkSession() {
        console.log(token)
        request({
            url: '/testSession/getSession',
            method: 'post',
            headers:{
                'X-Token':token
            }
        }).then(response => {
            console.log(response.data)
        });
    }

script>

body>
html>

点击下载axios.min.js文件

html代码运行效果如下
008-shiro使用token认证_第1张图片

  1. 获取token,是模拟的登录操作
  2. 验证session是模拟的登录后通过token获取会话的操作

编写CorsFilter解决跨域问题

在使用shiro-web的情况下,许多解决跨域问题的办法会不生效(比如我之前使用的拦截器的方式或springBoot的方式),我们此处采用Filter的方式来解决跨域的问题
CorsFilter定义如下:

package com.yyoo.mytest.shiro1.config;

import org.springframework.stereotype.Component;

import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * 使用shiro的情况下,拦截器来处理跨域有时候不生效
 * 建议使用Filter方式处理跨域
 */
@Component
public class CorsFilter implements Filter {

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {

    }

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletResponse response = (HttpServletResponse) servletResponse;
        HttpServletRequest request = (HttpServletRequest) servletRequest;

        // String origin = request.getHeader("Origin");
        response.setHeader("Access-Control-Allow-Origin", "*");
        response.setHeader("Access-Control-Allow-Methods", "GET,POST,DELETE,PUT,OPTIONS");
        response.setHeader("Access-Control-Max-Age", "86400");
        // 为true时Access-Control-Allow-Origin不能为*
        response.setHeader("Access-Control-Allow-Credentials", "false");
        // 此处为允许请求携带的所以请求头,如果要限制,可自行定义
        response.setHeader("Access-Control-Allow-Headers", request.getHeader("Access-Control-Request-Headers"));
        filterChain.doFilter(request, response);
    }

    @Override
    public void destroy() {

    }
}

shiro默认是使用的cookie来实现的登录认证,这意味着我们前后端分离的项目需要设置Access-Control-Allow-Credentials为true,并且前端ajax需要设置携带cookie请求。也就是axios.defaults.withCredentials=true,才行。我们这里由于使用token方式,所以我们的Access-Control-Allow-Credentials设置为false,这意味着我们的每个请求都是不同的请求会话。

我们再来看一下SessionDao接口的定义

其获取Session的定义如下

Session readSession(Serializable sessionId) throws UnknownSessionException;

这里需要一个sessionId,这个sessionId是通过DefaultWebSessionManager获取的。查看源码,我们会找到如下方法:

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 = this.getSessionId(request, response);
        }

        return id;
    }

    protected Serializable getSessionId(ServletRequest request, ServletResponse response) {
        return this.getReferencedSessionId(request, response);
    }

private Serializable getReferencedSessionId(ServletRequest request, ServletResponse response) {
        String id = this.getSessionIdCookieValue(request, response);
        if (id != null) {
            request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_SOURCE, "cookie");
        } else {
            id = this.getUriPathSegmentParamValue(request, "JSESSIONID");
            if (id == null) {
                String name = this.getSessionIdName();
                id = request.getParameter(name);
                if (id == null) {
                    id = request.getParameter(name.toLowerCase());
                }
            }

            if (id != null) {
                request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_SOURCE, "url");
            }
        }

        if (id != null) {
            request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID, id);
            request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_IS_VALID, Boolean.TRUE);
        }

        request.setAttribute(ShiroHttpServletRequest.SESSION_ID_URL_REWRITING_ENABLED, this.isSessionIdUrlRewritingEnabled());
        return id;
    }

shiro 先执行getSessionId(SessionKey key) ,如果是web应用它将执行getSessionId(ServletRequest request, ServletResponse response),其实就是执行getReferencedSessionId

getReferencedSessionId方法信息量很大,总结起来就是,它会先从请求携带的cookie中获取sessionId,如果为空,则会获取请求参数JSESSIONID的值,但我们可以看到,该实现没有获取请求头的操作,所以我们将自己实现。

其中JSESSIONID这个名称是cookie的名称,这个cookie在DefaultWebSessionManager实例化的时候创建,该名称可以修改(自行实例化一个cookie来传入DefaultWebSessionManager实例即可。)

编写MyWebSessionManager继承DefaultWebSessionManager

package com.yyoo.mytest.shiro1.config;

import org.apache.shiro.web.session.mgt.DefaultWebSessionManager;

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

public class MyWebSessionManager extends DefaultWebSessionManager {

    @Override
    protected Serializable getSessionId(ServletRequest request, ServletResponse response) {
        HttpServletRequest req = (HttpServletRequest) request;
        Serializable sessionId = req.getHeader("X-Token");

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

        // 如果消息头获取为空,则使用shiro原来的方式获取
        return super.getSessionId(request, response);
    }
}

注意:我们此处读取的请求头信息为X-Token,请与前端对应。

修改ShiroConfig配置

@Bean
    public SecurityManager securityManager(Realm realm) {
        // 注意:这里的DefaultWebSecurityManager和我们之前的Demo使用的DefaultSecurityManager有区别
        DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
        // 修改web环境下的默认sessionManager
        DefaultWebSessionManager sessionManager = new MyWebSessionManager();
        // 60分钟(此设置会覆盖容器(tomcat)的会话过期时间设置)
        sessionManager.setGlobalSessionTimeout(60*60*1000);
        securityManager.setSessionManager(sessionManager);
        // 禁用cookie来存sessionID(我们这里不用禁用,如果不传Token,则会使用原方式认证)
//        sessionManager.setSessionIdCookieEnabled(false);

        log.info("myShiro SessionManager:{}",securityManager.getSessionManager());
        log.info("myShiro SessionListeners:{}",sessionManager.getSessionListeners());
        log.info("myShiro SessionDAO:{}",sessionManager.getSessionDAO());
        securityManager.setRealm(realm);
        return securityManager;
    }

其实就修改了DefaultWebSessionManager的实现为MyWebSessionManager,然后新增了sessionManager.setSessionIdCookieEnabled(false);而已

解决OPTIONS请求无法获取x-token请求头信息的问题

以上修改完成后启动程序,登录没有问题,但是获取session调用的时候会出现如下错误:

Access to XMLHttpRequest at 'http://localhost:8099/shiro1/testSession/getSession' from origin 'http://localhost:63342' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: Redirect is not allowed for a preflight request.

其根本原因就是前端跨域请求时,如果是复杂的跨域请求,前端会先发送一个OPTIONS请求来确认是否能够访问,但是OPTIONS请求有个问题,我们自定义的请求头x-token无法获取值,这导致该请求进入shiro认证时无法通过认证。这就意味着,如果是OPTIONS请求,我们需要跳过认证。
找到问题所在了,接下来就解决它把。

继承FormAuthenticationFilter重写onAccessDenied方法

package com.yyoo.mytest.shiro1.config;

import org.apache.shiro.web.filter.authc.FormAuthenticationFilter;

import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;

public class MyFormAuthenticationFilter extends FormAuthenticationFilter {

    @Override
    protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {

        HttpServletRequest req = (HttpServletRequest) request;
        if("OPTIONS".equals(req.getMethod())){
            return true;
        }

        return super.onAccessDenied(request, response);
    }
}

修改ShiroConfig配置

// shiro的authc过滤器(注意这些过滤器是有顺序的,此map类型为LinkedHashMap)
shiroFilterFactoryBeanFilters.put("authc",new MyFormAuthenticationFilter());

其实就是修改ShiroFilterFactoryBean配置中的过滤器配置将FormAuthenticationFilter修改为我们重新实现的MyFormAuthenticationFilter即可。
重启后,再次尝试结果图如下

008-shiro使用token认证_第2张图片

到此为止,我们的shiro使用token实现就完成了。但还有些后续的问题,比如接JWT,比如分布式环境下的操作。

shiro使用JWT

我们在007-shiro的会话管理一章说到,我们可以自定义sessionID,只需实现SessionIdGenerator接口即可,SessionIdGenerator默认使用java的uuid实现,我们当前的token其实就是uuid,那么我们自定义实现用JWT来实现sessionId的生成即可。

分布式环境下的session共享

现在我们知道,shiro获取session的时候是通过sessionId来直接获取的,而我们的SessionDAO默认是MemorySessionDAO,如果我们自定义实现SessionDAO,将session存储在redis,那么我在分布式环境下,只要sessionID不变(本文其实就是token),通过sessionDao获取redis中存储的session就不会变,这样就轻松实现了session的分布式共享。

未登录或登录过期跳转首页的问题

在前后端分离的情况下,我们在请求的时候如果未登录或登录过期,shiro是会跳转到我们设置的登录页面的,我们目前设置的登录页面如下

shiroFilterFactoryBean.setLoginUrl("https://blog.csdn.net/forlinkext/article/details/115748941");

在axios请求下会有如下问题

Access to XMLHttpRequest at 'https://blog.csdn.net/forlinkext/article/details/115748941' (redirected from 'http://localhost:8099/shiro1/testSession/getSession') from origin 'http://localhost:63342' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.

我们可以将登陆页面地址设置为一个后端的不验证登录的地址,然后该地址直接返回未登录的状态给前端,前端axios统一处理该状态(跳转到登陆页)即可。

总结

由于时间原因,shiro使用JWT和session共享,没有详细编写,只提了基本思路,后续如果有空实现的话我们再来编写把。
上一篇:007-shiro的会话管理

你可能感兴趣的:(人在江湖之shiro详解,shiro,jwt,tokenization,分布式,session)