天天看點

SpringBoot+Shiro+ehcache實作登入失敗超次數鎖定帳号

文章目錄

  • ​​二、Controller層接收登入請求​​
  • ​​三、自定義的Realm​​
  • ​​四、密碼驗證器增加登入次數校驗功能​​
  • ​​五、ShiroConfig的配置類​​
  • ​​六、EhCache 的配置​​
  • ​​七、全局異常的配置​​

####### 一、 Shiro的執行流程

SpringBoot+Shiro+ehcache實作登入失敗超次數鎖定帳号

1、核心介紹

1)Application Code使用者編寫代碼

2)Subject就是shiro管理的使用者

3)SecurityManager安全管理器,就是shiro權限控制核心對象,在程式設計時,隻需要操作Subject方法,底層調用SecurityManager方法,無需直接程式設計操作SecurityManager

4)Realm應用程式和安全資料之間連接配接器,應用程式進行權限控制讀取安全資料(資料表、檔案、網絡…)通過Realm對象完成

2、Shiro執行流程

應用程式(就是你自己的項目)—>Subject—>SecurityManager—>Realm—>安全資料

3、Shiro進行權限控制的四種主要方式

1)在程式中通過Subject程式設計方式進行權限控制

2)配置Filter實作URL級别粗粒度權限控制

3)配置代理,基于注解實作細粒度權限控制

4)在頁面中使用shiro自定義标簽實作,頁面顯示權限控制

Shiro執行登入的流程如下圖.

SpringBoot+Shiro+ehcache實作登入失敗超次數鎖定帳号

大緻的思路如下, 在Controller層接收前端輸入的使用者名和密碼. 調用Shiro的SecurityUtils.getSubject()方法擷取Subject對象.

之後用Subject對象調用login方法,其Shiro底層會進行密碼的驗證, 傳入UsernamePasswordToken對象,此對象封裝了前端傳入的使用者名和密碼.

SpringBoot+Shiro+ehcache實作登入失敗超次數鎖定帳号

接着Shiro的SecurityManager會去調用自定義的Realm的AuthenticationInfo方法進行登入的驗證, 此方法會傳回一個SimpleAuthenticationInfo對象,此對象封裝了ShiroUser ,資料庫中存儲的目前使用者的密碼, 密碼加鹽的值,Realm的名稱, 即把資料庫中的目前的使用者, 與使用者輸入的使用者名密碼即存儲在 UsernamePasswordToken進行比較,如果密碼正确,登入成功,密碼不正确登入失敗.

SpringBoot+Shiro+ehcache實作登入失敗超次數鎖定帳号
SpringBoot+Shiro+ehcache實作登入失敗超次數鎖定帳号

調用自定義Realm的AuthenticationInfo完了之後, 調用RetryLimitCredentialsMatcher類中的doCredentialsMatch方法, 進行密碼比對次數的記錄. 并用EhCache作為緩存, 把目前登入的使用者名作為key,key的過期時間按照需求設定即可, 把登入的次數作為值.首先通過使用者名,擷取登入次數,如果登入次數為0, 那麼先給目前使用者設定一個緩存,登入次數+1,之後判斷是否大于限定的登入錯誤次數,如果超過了限定次數,則抛出異常,用全局的異常攔截器,攔截此異常, 記錄登入錯誤次數的異常, 并封裝登入次數過多的提示,給用戶端. 具體的代碼在下面.

二、Controller層接收登入請求

Shiro的工具類ShiroKit

import org.apache.shiro.SecurityUtils;
import org.apache.shiro.crypto.hash.Md5Hash;
import org.apache.shiro.crypto.hash.SimpleHash;
import org.apache.shiro.session.Session;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.util.ByteSource;

import java.util.List;

/**
 * shiro工具類
 *
 *
 */
public class ShiroKit {

    /**
     * 名稱分隔符
     */
    private static final String NAMES_DELIMETER = ",";

    /**
     * 加鹽參數
     */
    public final static String HASH_ALGORITHM_NAME = "MD5";

    /**
     * 循環次數
     */
    public final static int HASHITERATIONS = 1024;

    /**
     * 驗證是否同一個賬号重新登入的屬性,為true代表是重新登入, 初始化為false,代表不是重新登入
     */
    public static boolean ISPEATEDLOGIN =false;

    /**
     * shiro密碼加密工具類
     *
     * @param credentials 密碼
     * @param saltSource  密碼鹽
     * @return
     */
    public static String md5(String credentials, String saltSource) {
        ByteSource salt = new Md5Hash(saltSource);
        return new SimpleHash(HASH_ALGORITHM_NAME, credentials, salt, HASHITERATIONS).toString();
    }

    /**
     * 擷取随機鹽值
     *
     * @param length
     * @return
     */
    public static String getRandomSalt(int length) {
        return ToolUtil.getRandomString(length);
    }

    /**
     * 擷取目前 Subject.
     * Subject表示單個應用程式使用者的狀态和安全操作。
     * 這些操作包括身份驗證(登入/登出),授權(通路控制)和會話通路。
     * 這是Shiro的單使用者安全功能的主要機制。
     *
     * @return Subject
     */
    public static Subject getSubject() {
        return SecurityUtils.getSubject();
    }

    /**
     * 擷取封裝的 ShiroUser
     *
     * @return ShiroUser
     */
    public static ShiroUser getUser() {
        if (isGuest()) {
            return null;
        } else {
            return (ShiroUser) getSubject().getPrincipals().getPrimaryPrincipal();
        }
    }

    /**
     * 從shiro擷取session
     */
    public static Session getSession() {
        return getSubject().getSession();
    }

    /**
     * 擷取shiro指定的sessionKey
     */
    @SuppressWarnings("unchecked")
    public static <T> T getSessionAttr(String key) {
        Session session = getSession();
        return session != null ? (T) session.getAttribute(key) : null;
    }

    /**
     * 設定shiro指定的sessionKey
     */
    public static void setSessionAttr(String key, Object value) {
        Session session = getSession();
        session.setAttribute(key, value);
    }

    /**
     * 移除shiro指定的sessionKey
     */
    public static void removeSessionAttr(String key) {
        Session session = getSession();
        if (session != null)
            session.removeAttribute(key);
    }

    /**
     * 驗證目前使用者是否屬于該角色?,使用時與lacksRole 搭配使用
     *
     * @param roleName 角色名
     * @return 屬于該角色:true,否則false
     */
    public static boolean hasRole(String roleName) {
        return getSubject() != null && roleName != null
                && roleName.length() > 0 && getSubject().hasRole(roleName);
    }

    /**
     * 與hasRole标簽邏輯相反,當使用者不屬于該角色時驗證通過。
     *
     * @param roleName 角色名
     * @return 不屬于該角色:true,否則false
     */
    public static boolean lacksRole(String roleName) {
        return !hasRole(roleName);
    }

    /**
     * 驗證目前使用者是否屬于以下任意一個角色。
     *
     * @param roleNames 角色清單
     * @return 屬于:true,否則false
     */
    public static boolean hasAnyRoles(String roleNames) {
        boolean hasAnyRole = false;
        Subject subject = getSubject();
        if (subject != null && roleNames != null && roleNames.length() > 0) {
            for (String role : roleNames.split(NAMES_DELIMETER)) {
                if (subject.hasRole(role.trim())) {
                    hasAnyRole = true;
                    break;
                }
            }
        }
        return hasAnyRole;
    }

    /**
     * 驗證目前使用者是否屬于以下所有角色。
     *
     * @param roleNames 角色清單
     * @return 屬于:true,否則false
     */
    public static boolean hasAllRoles(String roleNames) {
        boolean hasAllRole = true;
        Subject subject = getSubject();
        if (subject != null && roleNames != null && roleNames.length() > 0) {
            for (String role : roleNames.split(NAMES_DELIMETER)) {
                if (!subject.hasRole(role.trim())) {
                    hasAllRole = false;
                    break;
                }
            }
        }
        return hasAllRole;
    }

    /**
     * 驗證目前使用者是否擁有指定權限,使用時與lacksPermission 搭配使用
     *
     * @param permission 權限名
     * @return 擁有權限:true,否則false
     */
    public static boolean hasPermission(String permission) {
        return getSubject() != null && permission != null
                && permission.length() > 0
                && getSubject().isPermitted(permission);
    }

    /**
     * 與hasPermission标簽邏輯相反,目前使用者沒有制定權限時,驗證通過。
     *
     * @param permission 權限名
     * @return 擁有權限:true,否則false
     */
    public static boolean lacksPermission(String permission) {
        return !hasPermission(permission);
    }

    /**
     * 已認證通過的使用者。不包含已記住的使用者,這是與user标簽的差別所在。與notAuthenticated搭配使用
     *
     * @return 通過身份驗證:true,否則false
     */
    public static boolean isAuthenticated() {
        return getSubject() != null && getSubject().isAuthenticated();
    }

    /**
     * 未認證通過使用者,與authenticated标簽相對應。與guest标簽的差別是,該标簽包含已記住使用者。。
     *
     * @return 沒有通過身份驗證:true,否則false
     */
    public static boolean notAuthenticated() {
        return !isAuthenticated();
    }

    /**
     * 認證通過或已記住的使用者。與guset搭配使用。
     *
     * @return 使用者:true,否則 false
     */
    public static boolean isUser() {
        return getSubject() != null && getSubject().getPrincipal() != null;
    }

    /**
     * 驗證目前使用者是否為“訪客”,即未認證(包含未記住)的使用者。用user搭配使用
     *
     * @return 訪客:true,否則false
     */
    public static boolean isGuest() {
        return !isUser();
    }

    /**
     * 輸出目前使用者資訊,通常為登入帳号資訊。
     *
     * @return 目前使用者資訊
     */
    public static String principal() {
        if (getSubject() != null) {
            Object principal = getSubject().getPrincipal();
            return principal.toString();
        }
        return "";
    }

}      

三、自定義的Realm

import cn.stylefeng.roses.core.util.HttpContext;
import cn.stylefeng.roses.core.util.ToolUtil;
import cn.utry.govaffairs.core.shiro.service.UserAuthService;
import cn.utry.govaffairs.core.shiro.service.impl.UserAuthServiceServiceImpl;
import cn.utry.govaffairs.modular.system.model.User;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authc.UsernamePasswordToken;
import org.apache.shiro.authc.credential.CredentialsMatcher;
import org.apache.shiro.authc.credential.HashedCredentialsMatcher;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.authz.SimpleAuthorizationInfo;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.session.Session;
import org.apache.shiro.session.mgt.eis.SessionDAO;
import org.apache.shiro.subject.PrincipalCollection;
import org.springframework.beans.factory.annotation.Autowired;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

public class ShiroDbRealm extends AuthorizingRealm {


    @Autowired
    private SessionDAO sessionDAO;

    /**
     * 登入認證  證明 鑒定
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authcToken)
            throws AuthenticationException {

        // 擷取shirorealm所需資料的Service層
        UserAuthService shiroFactory = UserAuthServiceServiceImpl.me();

        //擷取Controller層傳遞的token,包含了前端輸入的使用者名和密碼 一個簡單的使用者名/密碼身份驗證令牌
        UsernamePasswordToken token = (UsernamePasswordToken) authcToken;

        //擷取登入的使用者名
        String username = token.getUsername();

        //根據前端輸入的賬号, 去資料庫查詢使用者資訊. 此時如果賬号不存在(包括邏輯删除)或被當機,直接抛出異常,終止登入
        User user = shiroFactory.user(token.getUsername());

        //進行使用者的驗證
        ShiroUser shiroUser = shiroFactory.shiroUser(user);
        
         // 擷取需要登入的使用者在資料庫中存儲的加鹽的密碼
        String credentials = user.getPassword();

        //  擷取需要登入的使用者在資料庫中存儲的密碼的鹽值
        String source = user.getSalt();
        ByteSource credentialsSalt = new Md5Hash(source);
        // 建立SimpleAuthenticationInfo 傳回給shiro的安全管理器去比較目前登入使用者輸入的密碼,與資料庫中存儲的加鹽的密碼是否一緻
        // 即在登入的Controller層 UsernamePasswordToken 中存儲了目前輸入的使用者名與密碼, 與此SimpleAuthenticationInfo 進行比較
        // 如果密碼一緻,代表登入成功, 密碼不一緻,則報密碼錯誤的異常
        return new SimpleAuthenticationInfo(shiroUser, credentials, credentialsSalt, realmName);
       
    }

    /**
     * 權限認證  授權,認可
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
       return null;
    }      

四、密碼驗證器增加登入次數校驗功能

import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authc.ExcessiveAttemptsException;
import org.apache.shiro.authc.credential.HashedCredentialsMatcher;
import org.apache.shiro.cache.Cache;
import org.apache.shiro.cache.CacheManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * 驗證器,增加了登入次數校驗功能
 */

public class RetryLimitCredentialsMatcher extends HashedCredentialsMatcher {

    /**
     * 密碼輸入錯誤次數就被當機
     */
    private Integer errorPasswordTimes=5;

    private Cache<String, AtomicInteger> passwordRetryCache;

    /**
     * 構造方法 建立對象,傳入緩存的管理器
     * @param cacheManager
     */
    public RetryLimitCredentialsMatcher(CacheManager cacheManager) {
        passwordRetryCache = cacheManager.getCache("passwordRetryCache");
    }

    /**
     * 方法名: doCredentialsMatch
     * 方法描述: 使用者登入錯誤次數方法.
     * 修改日期: 2019/2/26 20:19
      * @param token
     * @param info
     * @return boolean
     * @throws
     */
    @Override
    public boolean doCredentialsMatch(AuthenticationToken token,
                                      AuthenticationInfo info) {
        String username = (String) token.getPrincipal();
        Set<String> keys = passwordRetryCache.keys();

        // retry count + 1
        AtomicInteger retryCount = passwordRetryCache.get(username);
        if (retryCount == null) {
            retryCount = new AtomicInteger(0);
            passwordRetryCache.put(username, retryCount);
        }
        if (retryCount.incrementAndGet() > errorPasswordTimes) {
            // if retry count > 5 throw
            throw new ExcessiveAttemptsException();
        }

        boolean matches = super.doCredentialsMatch(token, info);
        if (matches) {
            // clear retry count
            passwordRetryCache.remove(username);
        }
        return matches;
    }
}      

五、ShiroConfig的配置類

在此配置類中, 要注意的是把ShiroDbRealm的bean中要調用set方法注入retryLimitCredentialsMatcher,否則密碼錯誤次數的校驗不會生效.

@Configuration
public class ShiroConfig {
     /**
     * Shiro生命周期處理器:
     * 用于在實作了Initializable接口的Shiro bean初始化時調用Initializable接口回調(例如:UserRealm)
     * 在實作了Destroyable接口的Shiro bean銷毀時調用 Destroyable接口回調(例如:DefaultSecurityManager)
     */
    @Bean
    public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
        return new LifecycleBeanPostProcessor();
    }

    /**
     * 方法名: getDefaultAdvisorAutoProxyCreator
     * 方法描述:  開啟Shiro的注解模式
     * 修改日期: 2019/2/25 16:03
      * @param
     * @return org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator
     * @author taohongchao
     * @throws
     */
    @Bean
    public DefaultAdvisorAutoProxyCreator getDefaultAdvisorAutoProxyCreator() {
        DefaultAdvisorAutoProxyCreator autoProxyCreator = new DefaultAdvisorAutoProxyCreator();
        autoProxyCreator.setProxyTargetClass(true);
        return autoProxyCreator;
    }

    /**
     * 安全管理器
     */
    @Bean
    public DefaultWebSecurityManager securityManager(CookieRememberMeManager rememberMeManager,
                                                     CacheManager cacheShiroManager,
                                                     SessionManager sessionManager,
                                                     RetryLimitCredentialsMatcher retryLimitCredentialsMatcher) {
        DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();

        //把自定義的Realm注入安全管理器中
        securityManager.setRealm(this.shiroDbRealm(retryLimitCredentialsMatcher));
        securityManager.setCacheManager(cacheShiroManager);
        securityManager.setRememberMeManager(rememberMeManager);
        securityManager.setSessionManager(sessionManager);
        return securityManager;
    }

      /**
     * 緩存管理器 使用Ehcache實作
     */
    @Bean
    public CacheManager getCacheShiroManager(EhCacheManagerFactoryBean ehcache) {
        EhCacheManager ehCacheManager = new EhCacheManager();
        ehCacheManager.setCacheManager(ehcache.getObject());
        ehCacheManager.setCacheManagerConfigFile("ehcache.xml");
        return ehCacheManager;
    }
    
     @Bean
    public RetryLimitCredentialsMatcher getRetryLimit(CacheManager cacheManager){
        RetryLimitCredentialsMatcher retryLimitCredentialsMatcher = new RetryLimitCredentialsMatcher(cacheManager);
        retryLimitCredentialsMatcher.setHashAlgorithmName(ShiroKit.HASH_ALGORITHM_NAME);
        retryLimitCredentialsMatcher.setHashIterations(ShiroKit.HASHITERATIONS);
        retryLimitCredentialsMatcher.setStoredCredentialsHexEncoded(true);
        return retryLimitCredentialsMatcher;
    }
    /**
     * 項目自定義的Realm
     */
    @Bean
    public ShiroDbRealm shiroDbRealm(RetryLimitCredentialsMatcher retryLimitCredentialsMatcher) {
        ShiroDbRealm shiroDbRealm = new ShiroDbRealm();
        shiroDbRealm.setCredentialsMatcher(retryLimitCredentialsMatcher);
        return shiroDbRealm;
    }
}      

六、EhCache 的配置

EhCache.xml中的配置, 其中設定了名稱為passwordRetryCache的緩存,用于當機密碼輸入錯誤次數多過的緩存.
<?xml version="1.0" encoding="UTF-8"?>
<ehcache xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="ehcache.xsd"
         updateCheck="false" monitoring="autodetect"
         dynamicConfig="true" >
         
    <diskStore path="java.io.tmpdir/ehcache"/>

    <defaultCache
            maxElementsInMemory="50000"
            eternal="false"
            timeToIdleSeconds="3600"
            timeToLiveSeconds="3600"
            overflowToDisk="true"
            diskPersistent="false"
            diskExpiryThreadIntervalSeconds="120"
    />

    <!-- 登入記錄緩存 鎖定10分鐘 -->
    <cache name="passwordRetryCache"
           eternal="false"
           timeToIdleSeconds="600"
           timeToLiveSeconds="0"
           overflowToDisk="false"
           statistics="true"
           maxEntriesLocalHeap="0">
    </cache>

</ehcache>      

EhCacheConfig的配置類

import net.sf.ehcache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.ehcache.EhCacheCacheManager;
import org.springframework.cache.ehcache.EhCacheManagerFactoryBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;

/**
 * ehcache配置
 *
 * @author
 * @date 2017-05-20 23:11
 */
@Configuration
@EnableCaching
public class EhCacheConfig {

    /**
     * EhCache的配置
     */
    @Bean
    public EhCacheCacheManager cacheManager(CacheManager cacheManager) {
        return new EhCacheCacheManager(cacheManager);
    }

    /**
     * EhCache的配置
     */
    @Bean
    public EhCacheManagerFactoryBean ehcache() {
        EhCacheManagerFactoryBean ehCacheManagerFactoryBean = new EhCacheManagerFactoryBean();
        ehCacheManagerFactoryBean.setConfigLocation(new ClassPathResource("ehcache.xml"));
        return ehCacheManagerFactoryBean;
    }
}      

七、全局異常的配置

當密碼輸入錯誤次數過多時,抛出ExcessiveAttemptsException異常,被此異常攔截器攔截
(-1)
public class GlobalExceptionHandler {
    /**
     * 方法名: excessiveAttemptsException
     * 方法描述:  登入錯誤次數過多異常    
     * @throws
     */
    @ExceptionHandler(ExcessiveAttemptsException.class)
    @ResponseStatus(HttpStatus.UNAUTHORIZED)
    public String excessiveAttemptsException(ExcessiveAttemptsException e, Model model) {
        String username = getRequest().getParameter("username");
        LogManager.me().executeLog(LogTaskFactory.loginLog(username, "登入錯誤次數超過五次", getIp()));
        model.addAttribute("tips", "登入錯誤次數超過五次,請十分鐘後登入!");
        return "/login.html";
    }
}