天天看點

spring boot 2.x + shiro + redis實作前後端分離的項目

簡介

Apache Shiro是一個強大且易用的Java安全架構,執行身份驗證、授權、密碼學和會話管理。使用Shiro的易于了解的API,您可以快速、輕松地獲得任何應用程式,從最小的移動應用程式到最大的網絡和企業應用程式。

demo位址在最下方給出。

本文主要實作shiro的以下幾個功能:

       1.當使用者沒有登陸時隻能通路登入接口,通路其他接口會傳回無效的授權碼

  2.當使用者登陸成功後,隻能通路該使用者權限下的接口,其他接口會傳回無權限通路

  3.一個使用者不能兩個人同時線上,後登入的會自動踢出先登入的使用者

本文使用架構如下:

  1. 核心架構:spring boot 2.0.0, spring
  2. mvc架構:spring mvc
  3. 持久層架構:mybatis
  4. 資料庫連接配接池:alibaba druid
  5. 安全架構:apache shiro
  6. 緩存架構:redis
  7. 日志架構:logback

資料庫設計:

資料庫主要分為5個表,分别是:使用者表,角色表,權限表,角色權限表,使用者角色表

spring boot 2.x + shiro + redis實作前後端分離的項目

由于資料表結構我直接拷貝的以前的項目,是以上表中很多字段該文不會用到,各位請根據自己的實際情況修改。

引入依賴:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>

	<groupId>com.alex</groupId>
	<artifactId>springboot</artifactId>
	<version>0.0.1-SNAPSHOT</version>
	<packaging>jar</packaging>

	<name>springboot</name>
	<description>Demo project for Spring Boot</description>

	<parent>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-parent</artifactId>
		<version>2.0.0.RELEASE</version>
		<relativePath/> <!-- lookup parent from repository -->
	</parent>

	<properties>
		<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
		<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
		<java.version>1.8</java.version>
		<shiro.version>1.4.0</shiro.version>
		<shiro-redis.version>3.1.0</shiro-redis.version>
	</properties>

	<dependencies>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-jdbc</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
		</dependency>
		<dependency>
			<groupId>org.mybatis.spring.boot</groupId>
			<artifactId>mybatis-spring-boot-starter</artifactId>
			<version>1.3.2</version>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-aop</artifactId>
		</dependency>
		<!-- 通路靜态資源 -->
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-thymeleaf</artifactId>
		</dependency>

		<dependency>
			<groupId>mysql</groupId>
			<artifactId>mysql-connector-java</artifactId>
			<scope>runtime</scope>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
		</dependency>

		<!-- 分頁插件 -->
		<dependency>
			<groupId>com.github.pagehelper</groupId>
			<artifactId>pagehelper-spring-boot-starter</artifactId>
			<version>1.2.5</version>
		</dependency>
		<!-- alibaba的druid資料庫連接配接池 -->
		<dependency>
			<groupId>com.alibaba</groupId>
			<artifactId>druid</artifactId>
			<version>1.1.0</version>
		</dependency>


		<[email protected]自動化日志對象-log-->
		<dependency>
			<groupId>org.projectlombok</groupId>
			<artifactId>lombok</artifactId>
			<version>1.16.16</version>
		</dependency>

		<!-- shiro spring. -->
		<dependency>
			<groupId>org.apache.shiro</groupId>
			<artifactId>shiro-core</artifactId>
			<version>${shiro.version}</version>
		</dependency>
		<dependency>
			<groupId>org.apache.shiro</groupId>
			<artifactId>shiro-spring</artifactId>
			<version>${shiro.version}</version>
		</dependency>
		<!-- shiro ehcache -->
		<dependency>
			<groupId>org.apache.shiro</groupId>
			<artifactId>shiro-ehcache</artifactId>
			<version>${shiro.version}</version>
		</dependency>

		<!-- shiro+redis緩存插件 -->
		<dependency>
			<groupId>org.crazycake</groupId>
			<artifactId>shiro-redis</artifactId>
			<version>${shiro-redis.version}</version>
		</dependency>

		<!--工具類-->
		<dependency>
			<groupId>org.apache.commons</groupId>
			<artifactId>commons-lang3</artifactId>
			<version>3.4</version>
		</dependency>

		<!-- fastjson阿裡巴巴jSON處理器 -->
		<dependency>
			<groupId>com.alibaba</groupId>
			<artifactId>fastjson</artifactId>
			<version>1.2.13</version>
		</dependency>
	</dependencies>

	<build>
		<plugins>
			<plugin>
				<groupId>org.springframework.boot</groupId>
				<artifactId>spring-boot-maven-plugin</artifactId>
			</plugin>
		</plugins>
	</build>

</project>
           

編輯application.yml

server:
  port: 8080

spring:
    datasource:
        name: test
        url: jdbc:mysql://127.0.0.1:3306/springboot
        username: admin
        password: 123456
        # 使用druid資料源
        type: com.alibaba.druid.pool.DruidDataSource
        driver-class-name: com.mysql.jdbc.Driver
        #初始化大小,最小,最大
        initialSize: 5
        minIdle: 5
        maxActive: 20
        # 配置擷取連接配接等待逾時的時間
        maxWait: 60000
        # 配置間隔多久才進行一次檢測,檢測需要關閉的空閑連接配接,機關是毫秒
        timeBetweenEvictionRunsMillis: 60000
        # 配置一個連接配接在池中最小生存的時間,機關是毫秒
        minEvictableIdleTimeMillis: 300000
        # 校驗SQL,Oracle配置 spring.datasource.validationQuery=SELECT 1 FROM DUAL,如果不配validationQuery項,則下面三項配置無用
        validationQuery: select 'x'
        testWhileIdle: true
        testOnBorrow: false
        testOnReturn: false
        # 打開PSCache,并且指定每個連接配接上PSCache的大小
        poolPreparedStatements: true
        maxPoolPreparedStatementPerConnectionSize : 20
        # 配置監控統計攔截的filters,去掉後監控界面sql無法統計,'wall'用于防火牆
        filters: stat, wall, logback
        connectionProperties : druid.stat.mergeSql=true;druid.stat.slowSqlMillis=5000
    redis:
      host: 127.0.0.1
      port: 6379
      password : 123456
      timeout: 0
      jedis:
        pool:
          max-active: 8
          max-idle: 8
          min-idle: 0
          max-wait: -1ms


## 該配置節點為獨立的節點,有很多同學容易将這個配置放在spring的節點下,導緻配置無法被識别
mybatis:
  mapper-locations: classpath:mapping/*.xml  #注意:一定要對應mapper映射xml檔案的所在路徑
  type-aliases-package: com.alex.springboot.model  # 注意:對應實體類的路徑

#pagehelper分頁插件
pagehelper:
    helperDialect: mysql
    reasonable: true
    supportMethodsArguments: true
    params: count=countSql


           

建立ShiroConfig

package com.alex.springboot.system.config;

import com.alex.springboot.system.shiro.CredentialsMatcher;
import com.alex.springboot.system.shiro.SessionControlFilter;
import com.alex.springboot.system.shiro.SessionManager;
import com.alex.springboot.system.shiro.ShiroRealm;
import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.spring.LifecycleBeanPostProcessor;
import org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.apache.shiro.web.servlet.SimpleCookie;
import org.crazycake.shiro.RedisCacheManager;
import org.crazycake.shiro.RedisManager;
import org.crazycake.shiro.RedisSessionDAO;
import org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.servlet.Filter;
import java.util.LinkedHashMap;
import java.util.Map;

@Configuration
public class ShiroConfig {

    @Value("${spring.redis.host}")
    private String redisHost;

    @Value("${spring.redis.port}")
    private int redisPort;

    @Value("${spring.redis.password}")
    private String redisPassword;

    @Bean
    public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager) {
        ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
        shiroFilterFactoryBean.setSecurityManager(securityManager);
        // 沒有登陸的使用者隻能通路登陸頁面,前後端分離中登入界面跳轉應由前端路由控制,背景僅傳回json資料
        shiroFilterFactoryBean.setLoginUrl("/common/unauth");
        // 登入成功後要跳轉的連結
        //shiroFilterFactoryBean.setSuccessUrl("/auth/index");
        // 未授權界面;
        shiroFilterFactoryBean.setUnauthorizedUrl("common/unauth");

        //自定義攔截器
        Map<String, Filter> filtersMap = new LinkedHashMap<String, Filter>();
        //限制同一帳号同時線上的個數。
        filtersMap.put("kickout", kickoutSessionControlFilter());
        shiroFilterFactoryBean.setFilters(filtersMap);

        // 權限控制map.
        Map<String, String> filterChainDefinitionMap = new LinkedHashMap<String, String>();
        // 公共請求
        filterChainDefinitionMap.put("/common/**", "anon");
        // 靜态資源
        filterChainDefinitionMap.put("/static/**", "anon");
        // 登入方法
        filterChainDefinitionMap.put("/admin/login*", "anon"); // 表示可以匿名通路

        //此處需要添加一個kickout,上面添加的自定義攔截器才能生效
        filterChainDefinitionMap.put("/admin/**", "authc,kickout");// 表示需要認證才可以通路
        shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
        return shiroFilterFactoryBean;
    }

    @Bean
    public SecurityManager securityManager() {
        DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
        // 設定realm.
        securityManager.setRealm(myShiroRealm());
        // 自定義緩存實作 使用redis
        securityManager.setCacheManager(cacheManager());
        // 自定義session管理 使用redis
        securityManager.setSessionManager(sessionManager());
        return securityManager;
    }

    /**
     * 身份認證realm; (這個需要自己寫,賬号密碼校驗;權限等)
     *
     * @return
     */
    @Bean
    public ShiroRealm myShiroRealm() {
        ShiroRealm myShiroRealm = new ShiroRealm();
        myShiroRealm.setCredentialsMatcher(credentialsMatcher());
        return myShiroRealm;
    }

    @Bean
    public CredentialsMatcher credentialsMatcher() {
        return new CredentialsMatcher();
    }

    /**
     * cacheManager 緩存 redis實作
     * 使用的是shiro-redis開源插件
     *
     * @return
     */
    public RedisCacheManager cacheManager() {
        RedisCacheManager redisCacheManager = new RedisCacheManager();
        redisCacheManager.setRedisManager(redisManager());
        redisCacheManager.setKeyPrefix("SPRINGBOOT_CACHE:");   //設定字首
        return redisCacheManager;
    }

    /**
     * RedisSessionDAO shiro sessionDao層的實作 通過redis
     * 使用的是shiro-redis開源插件
     */
    @Bean
    public RedisSessionDAO redisSessionDAO() {
        RedisSessionDAO redisSessionDAO = new RedisSessionDAO();
        redisSessionDAO.setRedisManager(redisManager());
        redisSessionDAO.setKeyPrefix("SPRINGBOOT_SESSION:");
        return redisSessionDAO;
    }

    /**
     * Session Manager
     * 使用的是shiro-redis開源插件
     */
    @Bean
    public SessionManager sessionManager() {
        SimpleCookie simpleCookie = new SimpleCookie("Token");
        simpleCookie.setPath("/");
        simpleCookie.setHttpOnly(false);

        SessionManager sessionManager = new SessionManager();
        sessionManager.setSessionDAO(redisSessionDAO());
        sessionManager.setSessionIdCookieEnabled(false);
        sessionManager.setSessionIdUrlRewritingEnabled(false);
        sessionManager.setDeleteInvalidSessions(true);
        sessionManager.setSessionIdCookie(simpleCookie);
        return sessionManager;
    }


    /**
     * 配置shiro redisManager
     * 使用的是shiro-redis開源插件
     *
     * @return
     */
    public RedisManager redisManager() {
        RedisManager redisManager = new RedisManager();
        redisManager.setHost(redisHost);
        redisManager.setPort(redisPort);
        redisManager.setTimeout(1800); //設定過期時間
        redisManager.setPassword(redisPassword);
        return redisManager;
    }

    /**
     * 限制同一賬号登入同時登入人數控制
     *
     * @return
     */
    // 這裡的@Bean不要啟用了,自定義的filter不要交由Spring建立,否則會出現被标記為anon的url仍然會執行該自定義過濾器。 updated 2020.06.05
    //@Bean
    public SessionControlFilter kickoutSessionControlFilter() {
        SessionControlFilter kickoutSessionControlFilter = new SessionControlFilter();
        kickoutSessionControlFilter.setCache(cacheManager());
        kickoutSessionControlFilter.setSessionManager(sessionManager());
        kickoutSessionControlFilter.setKickoutAfter(false);
        kickoutSessionControlFilter.setMaxSession(1);
        kickoutSessionControlFilter.setKickoutUrl("/common/kickout");
        return kickoutSessionControlFilter;
    }


    /***
     * 授權所用配置
     *
     * @return
     */
    @Bean
    public DefaultAdvisorAutoProxyCreator getDefaultAdvisorAutoProxyCreator() {
        DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
        defaultAdvisorAutoProxyCreator.setProxyTargetClass(true);
        return defaultAdvisorAutoProxyCreator;
    }

    /***
     * 使授權注解起作用不如不想配置可以在pom檔案中加入
     * <dependency>
     *<groupId>org.springframework.boot</groupId>
     *<artifactId>spring-boot-starter-aop</artifactId>
     *</dependency>
     * @param securityManager
     * @return
     */
    @Bean
    public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager){
        AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor();
        authorizationAttributeSourceAdvisor.setSecurityManager(securityManager);
        return authorizationAttributeSourceAdvisor;
    }

    /**
     * Shiro生命周期處理器
     * 此方法需要用static作為修飾詞,否則無法通過@Value()注解的方式擷取配置檔案的值
     *
     */
    @Bean
    public static LifecycleBeanPostProcessor getLifecycleBeanPostProcessor() {
        return new LifecycleBeanPostProcessor();
    }
}
           

自定義Realm

package com.alex.springboot.system.shiro;

import com.alex.springboot.model.Menu;
import com.alex.springboot.model.Role;
import com.alex.springboot.model.User;
import com.alex.springboot.service.MenuService;
import com.alex.springboot.service.RoleService;
import com.alex.springboot.service.UserService;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.shiro.authc.*;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.authz.SimpleAuthorizationInfo;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;
import org.springframework.beans.factory.annotation.Autowired;

import java.util.List;

@Slf4j
public class ShiroRealm extends AuthorizingRealm {
    @Autowired
    private UserService userService;
    @Autowired
    private RoleService roleService;
    @Autowired
    private MenuService menuService;
    /**
     * 認證資訊.(身份驗證) : Authentication 是用來驗證使用者身份
     *
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authcToken) throws AuthenticationException {
        log.info("---------------- 執行 Shiro 憑證認證 ----------------------");
        UsernamePasswordToken token = (UsernamePasswordToken) authcToken;
        String name = token.getUsername();
        // 從資料庫擷取對應使用者名密碼的使用者
        User user = userService.getUserByName(name);
        if (user != null) {
            // 使用者為禁用狀态
            if (!user.getLoginFlag().equals("1")) {
                throw new DisabledAccountException();
            }
            log.info("---------------- Shiro 憑證認證成功 ----------------------");
            SimpleAuthenticationInfo authenticationInfo = new SimpleAuthenticationInfo(
                    user, //使用者
                    user.getPassword(), //密碼
                    getName()  //realm name
            );
            return authenticationInfo;
        }
        throw new UnknownAccountException();
    }

    /**
     * 授權
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        log.info("---------------- 執行 Shiro 權限擷取 ---------------------");
        Object principal = principals.getPrimaryPrincipal();
        SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
        if (principal instanceof User) {
            User userLogin = (User) principal;
            if(userLogin != null){
                List<Role> roleList = roleService.findByUserid(userLogin.getId());
                if(CollectionUtils.isNotEmpty(roleList)){
                    for(Role role : roleList){
                        info.addRole(role.getEnname());

                        List<Menu> menuList = menuService.getAllMenuByRoleId(role.getId());
                        if(CollectionUtils.isNotEmpty(menuList)){
                            for (Menu menu : menuList){
                                if(StringUtils.isNoneBlank(menu.getPermission())){
                                    info.addStringPermission(menu.getPermission());
                                }
                            }
                        }
                    }
                }
            }
        }
        log.info("---------------- 擷取到以下權限 ----------------");
        log.info(info.getStringPermissions().toString());
        log.info("---------------- Shiro 權限擷取成功 ----------------------");
        return info;
    }
}
           

自定義密碼校驗器

package com.alex.springboot.system.shiro;

import com.alex.springboot.utils.MD5Util;
import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authc.UsernamePasswordToken;
import org.apache.shiro.authc.credential.SimpleCredentialsMatcher;

public class CredentialsMatcher extends SimpleCredentialsMatcher {

	@Override
	public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) {
		UsernamePasswordToken utoken = (UsernamePasswordToken) token;
		// 獲得使用者輸入的密碼:(可以采用加鹽(salt)的方式去檢驗)
		String inPassword = new String(utoken.getPassword());
		// 獲得資料庫中的密碼
		String dbPassword = (String) info.getCredentials();
		// 進行密碼的比對
		return this.equals(MD5Util.encrypt(inPassword), dbPassword);
	}
}
           

自定義session容器,用于實作前後端分離,前端請求接口時将Token放在請求Header中,即可擷取到使用者的session資訊(建議前端是将Token放在Header中,而不是放到body請求參數中,這樣可以統一做封裝處理,下面的代碼中是擷取Header中或者body中Token,建議直接擷取Header中Token即可)

package com.alex.springboot.system.shiro;

import org.apache.commons.lang3.StringUtils;
import org.apache.shiro.session.Session;
import org.apache.shiro.session.UnknownSessionException;
import org.apache.shiro.session.mgt.SessionKey;
import org.apache.shiro.web.servlet.ShiroHttpServletRequest;
import org.apache.shiro.web.session.mgt.DefaultWebSessionManager;
import org.apache.shiro.web.session.mgt.WebSessionKey;
import org.apache.shiro.web.util.WebUtils;

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

public class SessionManager extends DefaultWebSessionManager {
    private static final String AUTHORIZATION = "Token";

    private static final String REFERENCED_SESSION_ID_SOURCE = "Stateless request";

    public SessionManager() {
    }

    @Override
    protected Serializable getSessionId(ServletRequest request, ServletResponse response) {
        //擷取請求頭,或者請求參數中的Token
        String id = StringUtils.isEmpty(WebUtils.toHttp(request).getHeader(AUTHORIZATION))
                ? request.getParameter(AUTHORIZATION) : WebUtils.toHttp(request).getHeader(AUTHORIZATION);
        // 如果請求頭中有 Token 則其值為sessionId
        if (StringUtils.isNotEmpty(id)) {
            request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_SOURCE, REFERENCED_SESSION_ID_SOURCE);
            request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID, id);
            request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_IS_VALID, Boolean.TRUE);

            return id;
        } else {
            // 否則按預設規則從cookie取sessionId
            return super.getSessionId(request, response);
        }
    }

    /**
     * 擷取session 優化單次請求需要多次通路redis的問題
     *
     * @param sessionKey
     * @return
     * @throws UnknownSessionException
     */
    @Override
    protected Session retrieveSession(SessionKey sessionKey) throws UnknownSessionException {
        Serializable sessionId = getSessionId(sessionKey);

        ServletRequest request = null;
        if (sessionKey instanceof WebSessionKey) {
            request = ((WebSessionKey) sessionKey).getServletRequest();
        }

        if (request != null && null != sessionId) {
            Object sessionObj = request.getAttribute(sessionId.toString());
            if (sessionObj != null) {
                return (Session) sessionObj;
            }
        }

        Session session = super.retrieveSession(sessionKey);
        if (request != null && null != sessionId) {
            request.setAttribute(sessionId.toString(), session);
        }
        return session;
    }
}
           

自定義攔截器,用于限制使用者登入人數

package com.alex.springboot.system.shiro;

import com.alex.springboot.model.User;
import com.alibaba.fastjson.JSON;
import org.apache.shiro.cache.Cache;
import org.apache.shiro.cache.CacheManager;
import org.apache.shiro.session.Session;
import org.apache.shiro.session.mgt.DefaultSessionKey;
import org.apache.shiro.session.mgt.SessionManager;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.web.filter.AccessControlFilter;
import org.apache.shiro.web.util.WebUtils;

import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.Serializable;
import java.util.Deque;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.Map;


public class SessionControlFilter extends AccessControlFilter {
    private String kickoutUrl; //踢出後到的位址
    private boolean kickoutAfter = false; //踢出之前登入的/之後登入的使用者 預設踢出之前登入的使用者
    private int maxSession = 1; //同一個帳号最大會話數 預設1

    private SessionManager sessionManager;
    private Cache<String, Deque<Serializable>> cache;

    @Override
    protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception {
        return false;
    }

    @Override
    protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
        Subject subject = getSubject(request, response);
        if(!subject.isAuthenticated() && !subject.isRemembered()) {
            //如果沒有登入,直接進行之後的流程
            return true;
        }


        Session session = subject.getSession();
        User user = (User) subject.getPrincipal();
        String username = user.getLoginName();
        Serializable sessionId = session.getId();

        //讀取緩存   沒有就存入
        Deque<Serializable> deque = cache.get(username);

        //如果此使用者沒有session隊列,也就是還沒有登入過,緩存中沒有
        //就new一個空隊列,不然deque對象為空,會報空指針
        if(deque == null){
            deque = new LinkedList<Serializable>();
        }

        //如果隊列裡沒有此sessionId,且使用者沒有被踢出;放入隊列
        if(!deque.contains(sessionId) && session.getAttribute("kickout") == null) {
            //将sessionId存入隊列
            deque.push(sessionId);
            //将使用者的sessionId隊列緩存
            cache.put(username, deque);
        }

        //如果隊列裡的sessionId數超出最大會話數,開始踢人
        while(deque.size() > maxSession) {
            Serializable kickoutSessionId = null;
            if(kickoutAfter) { //如果踢出後者
                kickoutSessionId = deque.removeFirst();
                //踢出後再更新下緩存隊列
                cache.put(username, deque);
            } else { //否則踢出前者
                kickoutSessionId = deque.removeLast();
                //踢出後再更新下緩存隊列
                cache.put(username, deque);
            }

            try {
                //擷取被踢出的sessionId的session對象
                Session kickoutSession = sessionManager.getSession(new DefaultSessionKey(kickoutSessionId));
                if(kickoutSession != null) {
                    //設定會話的kickout屬性表示踢出了
                    kickoutSession.setAttribute("kickout", true);
                }
            } catch (Exception e) {//ignore exception

            }
        }

        //如果被踢出了,直接退出,重定向到踢出後的位址
        if (session.getAttribute("kickout") != null) {
            //會話被踢出了
            try {
                //登出
                subject.logout();
            } catch (Exception e) { //ignore
            }
            saveRequest(request);

            Map<String, String> resultMap = new HashMap<String, String>();
            //判斷是不是Ajax請求
            if ("XMLHttpRequest".equalsIgnoreCase(((HttpServletRequest) request).getHeader("X-Requested-With"))) {
                resultMap.put("user_status", "300");
                resultMap.put("message", "您已經在其他地方登入,請重新登入!");
                //輸出json串
                out(response, resultMap);
            }else{
                //重定向
                WebUtils.issueRedirect(request, response, kickoutUrl);
            }
            return false;
        }
        return true;
    }

    private void out(ServletResponse hresponse, Map<String, String> resultMap)
            throws IOException {
        try {
            hresponse.setCharacterEncoding("UTF-8");
            PrintWriter out = hresponse.getWriter();
            out.println(JSON.toJSONString(resultMap));
            out.flush();
            out.close();
        } catch (Exception e) {
            System.err.println("KickoutSessionFilter.class 輸出JSON異常,可以忽略。");
        }
    }

    public String getKickoutUrl() {
        return kickoutUrl;
    }

    public void setKickoutUrl(String kickoutUrl) {
        this.kickoutUrl = kickoutUrl;
    }

    public boolean isKickoutAfter() {
        return kickoutAfter;
    }

    public void setKickoutAfter(boolean kickoutAfter) {
        this.kickoutAfter = kickoutAfter;
    }

    public int getMaxSession() {
        return maxSession;
    }

    public void setMaxSession(int maxSession) {
        this.maxSession = maxSession;
    }

    public SessionManager getSessionManager() {
        return sessionManager;
    }

    public void setSessionManager(SessionManager sessionManager) {
        this.sessionManager = sessionManager;
    }

    public Cache<String, Deque<Serializable>> getCache() {
        return cache;
    }

    public void setCache(CacheManager cacheManager) {
        this.cache = cacheManager.getCache("shiro_redis_cache");
    }
}
           

全局異常攔截

package com.alex.springboot.system.handler;

import com.alex.springboot.system.enums.ResultStatusCode;
import com.alex.springboot.system.vo.Result;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authz.UnauthorizedException;
import org.springframework.http.HttpStatus;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.validation.BindException;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.ServletRequestBindingException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.ResponseStatus;

import javax.validation.ConstraintViolationException;

@Slf4j
@ControllerAdvice
@ResponseBody
public class ExceptionAdvice {
    /**
     * 400 - Bad Request
     */
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler({HttpMessageNotReadableException.class, MissingServletRequestParameterException.class, BindException.class,
            ServletRequestBindingException.class, MethodArgumentNotValidException.class, ConstraintViolationException.class})
    public Result handleHttpMessageNotReadableException(Exception e) {
        log.error("參數解析失敗", e);
        if (e instanceof BindException){
            return new Result(ResultStatusCode.BAD_REQUEST.getCode(), ((BindException)e).getAllErrors().get(0).getDefaultMessage());
        }
        return new Result(ResultStatusCode.BAD_REQUEST.getCode(), e.getMessage());
    }

    /**
     * 405 - Method Not Allowed
     */
    @ResponseStatus(HttpStatus.METHOD_NOT_ALLOWED)
    @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
    public Result handleHttpRequestMethodNotSupportedException(HttpRequestMethodNotSupportedException e) {
        log.error("不支援目前請求方法", e);
        return new Result(ResultStatusCode.METHOD_NOT_ALLOWED, null);
    }

    /**
     * shiro權限異常處理
     * @return
     */
    @ExceptionHandler(UnauthorizedException.class)
    public Result unauthorizedException(UnauthorizedException e){
        log.error(e.getMessage(), e);

        return new Result(ResultStatusCode.UNAUTHO_ERROR);
    }

    /**
     * 500
     * @param e
     * @return
     */
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    @ExceptionHandler(Exception.class)
    public Result handleException(Exception e) {
        e.printStackTrace();
        log.error("服務運作異常", e);
        return new Result(ResultStatusCode.SYSTEM_ERR, null);
    }
}
           

未授權和被踢出後跳轉方法

package com.alex.springboot.controller;

import com.alex.springboot.system.enums.ResultStatusCode;
import com.alex.springboot.system.vo.Result;
import org.apache.shiro.SecurityUtils;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RequestMapping("/common")
@RestController
public class CommonController {

    /**
     * 未授權跳轉方法
     * @return
     */
    @RequestMapping("/unauth")
    public Result unauth(){
        SecurityUtils.getSubject().logout();
        return new Result(ResultStatusCode.UNAUTHO_ERROR);
    }

    /**
     * 被踢出後跳轉方法
     * @return
     */
    @RequestMapping("/kickout")
    public Result kickout(){
        return new Result(ResultStatusCode.INVALID_TOKEN);
    }

}
           

登入和登出

package com.alex.springboot.controller;

import com.alex.springboot.system.enums.ResultStatusCode;
import com.alex.springboot.system.vo.Result;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.authc.IncorrectCredentialsException;
import org.apache.shiro.authc.LockedAccountException;
import org.apache.shiro.authc.UnknownAccountException;
import org.apache.shiro.authc.UsernamePasswordToken;
import org.apache.shiro.subject.Subject;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/admin")
public class LoginController {

    @RequestMapping("/login")
    public Result login(String loginName, String pwd){
        try {
            UsernamePasswordToken token = new UsernamePasswordToken(loginName, pwd);
            //登入不在該處處理,交由shiro處理
            Subject subject = SecurityUtils.getSubject();
            subject.login(token);

            if (subject.isAuthenticated()) {
                JSON json = new JSONObject();
                ((JSONObject) json).put("token", subject.getSession().getId());

                return new Result(ResultStatusCode.OK, json);
            }else{
                return new Result(ResultStatusCode.SHIRO_ERROR);
            }
        }catch (IncorrectCredentialsException | UnknownAccountException e){
            return new Result(ResultStatusCode.NOT_EXIST_USER_OR_ERROR_PWD);
        }catch (LockedAccountException e){
            return new Result(ResultStatusCode.USER_FROZEN);
        }catch (Exception e){
            return new Result(ResultStatusCode.SYSTEM_ERR);
        }
    }

    /**
     * 登出
     * @return
     */
    @RequestMapping("/logout")
    public Result logout(){
        SecurityUtils.getSubject().logout();
        return new Result(ResultStatusCode.OK);
    }
}
           

測試接口方法

package com.alex.springboot.controller;

import com.alex.springboot.service.UserService;
import com.alex.springboot.system.vo.Grid;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/admin/user")
public class UserController {

    @Autowired
    private UserService userService;

    @RequiresPermissions("sys:user:view")
    @RequestMapping("findList")
    public Grid findList(){
        return userService.findList();
    }

}
           

測試結果

    未攜帶Token時通路findList接口

spring boot 2.x + shiro + redis實作前後端分離的項目

     登入,等之後擷取到token

spring boot 2.x + shiro + redis實作前後端分離的項目

     攜帶Token再次請求findList接口

spring boot 2.x + shiro + redis實作前後端分離的項目

    再次調用登入接口,擷取到新的Token後,使用舊Token通路findList接口

spring boot 2.x + shiro + redis實作前後端分離的項目

關于無權限通路時,redis會産生session的問題請參考:spring boot 2.x + shiro + redis前後端分離,無權限通路時session會存入redis的問題解決

Demo位址:源碼位址

相關參考連結:springboot+shiro+redis項目整合

繼續閱讀