SpringBoot整合Shiro+MD5+Salt+Redis實現認證和動態許可權管理|前後端分離(下)----築基後期

賴柄灃發表於2020-10-12

寫在前面

在上一篇文章《SpringBoot整合Shiro+MD5+Salt+Redis實現認證和動態許可權管理(上)----築基中期》當中,我們初步實現了SpringBoot整合Shiro實現認證和授權。

在這篇文章當中,我將帶領大家一起完善這個Demo。當然,在這之前我們需要了解一些知識點。

本片文章與上一篇《SpringBoot整合Shiro+MD5+Salt+Redis實現認證和動態許可權管理(上)----築基中期》 緊密相連,建議您先閱讀上一篇文章,再閱讀本文。

知識點補充

Shiro快取

流程分析

在原來的專案當中,由於沒有配置快取,因此每次需要驗證當前主體有沒有訪問許可權時,都會去查詢資料庫。由於許可權資料是典型的讀多寫少的資料,因此,我們應該要對其加入快取的支援。

當我們加入快取後,shiro在做鑑權時先去快取裡查詢相關資料,快取裡沒有,則查詢資料庫並將查到的資料寫入快取,下次再查時就能從快取當中獲取資料,而不是從資料庫中獲取。這樣就能改善我們的應用的效能。

接下來,我們去實現shiro的快取管理部分。

Shiro會話機制

Shiro 提供了完整的企業級會話管理功能,不依賴於底層容器(如 web 容器 tomcat),不管 JavaSE 還是 JavaEE 環境都可以使用,提供了會話管理、會話事件監聽、會話儲存 / 持久化、容器無關的叢集、失效 / 過期支援、對 Web 的透明支援、SSO 單點登入的支援等特性。

我們將使用 Shiro 的會話管理來接管我們應用的web會話,並通過Redis來儲存會話資訊。

整合步驟

新增快取

CacheManager

在Shiro當中,它提供了CacheManager這個類來做快取管理。

使用Shiro預設的EhCache實現

在shiro當中,預設使用的是EhCache快取框架。EhCache 是一個純Java的程式內快取框架,具有快速、精幹等特點。關於更多EhCache的內容,同學們可以自行百度瞭解,這裡不做過多介紹。

引入shiro-EhCache依賴
<dependency>
    <groupId>org.apache.shiro</groupId>
    <artifactId>shiro-ehcache</artifactId>
    <version>1.4.0</version>
</dependency>

在SpringBoot整合Redis的過程中,還要注意版本匹配的問題,不然有可能報方法未找到的異常。

在ShiroConfig中新增快取配置
private void enableCache(MySQLRealm realm){
    //開啟全域性快取配置
    realm.setCachingEnabled(true);
    //開啟認證快取配置
    realm.setAuthenticationCachingEnabled(true);
    //開啟授權快取配置
    realm.setAuthorizationCachingEnabled(true);

    //為了方便操作,我們給快取起個名字
    realm.setAuthenticationCacheName("authcCache");
    realm.setAuthorizationCacheName("authzCache");
    //注入快取實現
    realm.setCacheManager(new EhCacheManager());
}

然後再在getRealm中呼叫這個方法即可。

提示:在這個實現當中,只是實現了本地的快取。也就是說快取的資料同應用一樣共用一臺機器的記憶體。如果伺服器發生當機或意外停電,那麼快取資料也將不復存在。當然你也可通過cacheManager.setCacheManagerConfigFile()方法給予快取更多的配置。

接下來我們將通過Redis快取我們的許可權資料

使用Redis實現

新增依賴
<!--shiro-redis相關依賴-->
        <dependency>
            <groupId>org.crazycake</groupId>
            <artifactId>shiro-redis</artifactId>
            <version>3.1.0</version>
            <!--    裡面這個shiro-core版本較低,會引發一個異常
					ClassNotFoundException: org.apache.shiro.event.EventBus
                    需要排除,直接使用上面的shiro
                    shiro1.3 加入了時間匯流排。-->
            <exclusions>
                <exclusion>
                    <groupId>org.apache.shiro</groupId>
                    <artifactId>shiro-core</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
配置redis

在application.yml中新增redis的相關配置

spring:
   redis:
     host: 127.0.0.1
     port: 6379
     password: hewenping
     timeout: 3000
     jedis:
       pool:
         min-idle: 5
         max-active: 20
         max-idle: 15

修改ShiroConfig配置類,新增shiro-redis外掛配置

/**shiro配置類
 * @author 賴柄灃 bingfengdev@aliyun.com
 * @version 1.0
 * @date 2020/10/6 9:11
 */
@Configuration
public class ShiroConfig {

    private static final String CACHE_KEY = "shiro:cache:";
    private static final String SESSION_KEY = "shiro:session:";
    private static final int EXPIRE = 18000;
    @Value("${spring.redis.host}")
    private String host;
    @Value("${spring.redis.port}")
    private int port;
    @Value("${spring.redis.timeout}")
    private int timeout;
    @Value("${spring.redis.password}")
    private String password;

    @Value("${spring.redis.jedis.pool.min-idle}")
    private int minIdle;
    @Value("${spring.redis.jedis.pool.max-idle}")
    private int maxIdle;
    @Value("${spring.redis.jedis.pool.max-active}")
    private int maxActive;

    @Bean
    public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(org.apache.shiro.mgt.SecurityManager securityManager) {
        AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor();
        authorizationAttributeSourceAdvisor.setSecurityManager(securityManager);
        return authorizationAttributeSourceAdvisor;
    }


    /**
     * 建立ShiroFilter攔截器
     * @return ShiroFilterFactoryBean
     */
    @Bean(name = "shiroFilterFactoryBean")
    public ShiroFilterFactoryBean getShiroFilterFactoryBean(DefaultWebSecurityManager securityManager){
        ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
        shiroFilterFactoryBean.setSecurityManager(securityManager);

        //配置不攔截路徑和攔截路徑,順序不能反
        HashMap<String, String> map = new HashMap<>(5);

        map.put("/authc/**","anon");
        map.put("/login.html","anon");
        map.put("/js/**","anon");
        map.put("/css/**","anon");

        map.put("/**","authc");
        shiroFilterFactoryBean.setFilterChainDefinitionMap(map);

        //覆蓋預設的登入url
        shiroFilterFactoryBean.setLoginUrl("/authc/unauthc");
        return shiroFilterFactoryBean;
    }

    @Bean
    public Realm getRealm(){
        //設定憑證匹配器,修改為hash憑證匹配器
        HashedCredentialsMatcher myCredentialsMatcher = new HashedCredentialsMatcher();
        //設定演算法
        myCredentialsMatcher.setHashAlgorithmName("md5");
        //雜湊次數
        myCredentialsMatcher.setHashIterations(1024);
        MySQLRealm realm = new MySQLRealm();
        realm.setCredentialsMatcher(myCredentialsMatcher);
        //開啟快取
        realm.setCachingEnabled(true);
        realm.setAuthenticationCachingEnabled(true);
        realm.setAuthorizationCachingEnabled(true);
        return realm;
    }

    /**
     * 建立shiro web應用下的安全管理器
     * @return DefaultWebSecurityManager
     */
    @Bean
    public DefaultWebSecurityManager getSecurityManager( Realm realm){
        DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
        securityManager.setRealm(realm);
    
        securityManager.setCacheManager(cacheManager());
        SecurityUtils.setSecurityManager(securityManager);
        return securityManager;
    }



    /**
     * 配置Redis管理器
     * @Attention 使用的是shiro-redis開源外掛
     * @return
     */
    @Bean
    public RedisManager redisManager() {
        RedisManager redisManager = new RedisManager();
        redisManager.setHost(host);
        redisManager.setPort(port);
        redisManager.setTimeout(timeout);
        redisManager.setPassword(password);
        JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
        jedisPoolConfig.setMaxTotal(maxIdle+maxActive);
        jedisPoolConfig.setMaxIdle(maxIdle);
        jedisPoolConfig.setMinIdle(minIdle);
        redisManager.setJedisPoolConfig(jedisPoolConfig);
        return redisManager;
    }


    @Bean
    public RedisCacheManager cacheManager() {
        RedisCacheManager redisCacheManager = new RedisCacheManager();
        redisCacheManager.setRedisManager(redisManager());
        redisCacheManager.setKeyPrefix(CACHE_KEY);
        // shiro-redis要求放在session裡面的實體類必須有個id標識
        //這是組成redis中所儲存資料的key的一部分
        redisCacheManager.setPrincipalIdFieldName("username");
        return redisCacheManager;
    }

}

修改MySQLRealm中的doGetAuthenticationInfo方法,將User物件整體作為SimpleAuthenticationInfo的第一個引數。shiro-redis將根據RedisCacheManagerprincipalIdFieldName屬性值從第一個引數中獲取id值作為redis中資料的key的一部分。

/**
 * 認證
 * @param token
 * @return
 * @throws AuthenticationException
 */
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {

    if(token==null){
        return null;
    }
    String principal = (String) token.getPrincipal();
    User user = userService.findByUsername(principal);
    SimpleAuthenticationInfo simpleAuthenticationInfo = new MyAuthcInfo(
            //由於shiro-redis外掛需要從這個屬性中獲取id作為redis的key
            //所有這裡傳的是user而不是username
            user,
            //憑證資訊
            user.getPassword(),
            //加密鹽值
            new CurrentSalt(user.getSalt()),
            getName());
    
    return simpleAuthenticationInfo;
}

並修改MySQLRealm中的doGetAuthorizationInfo方法,從User物件中獲取主身份資訊。

/**
 * 授權
 * @param principals
 * @return
 */
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
   User user = (User) principals.getPrimaryPrincipal();
    String username = user.getUsername();
    List<Role> roleList = roleService.findByUsername(username);
    SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
    for (Role role : roleList) {
        authorizationInfo.addRole(role.getRoleName());
    }
    List<Long> roleIdList  = new ArrayList<>();
    for (Role role : roleList) {
        roleIdList.add(role.getRoleId());
    }

    List<Resource> resourceList = resourceService.findByRoleIds(roleIdList);
    for (Resource resource : resourceList) {
        authorizationInfo.addStringPermission(resource.getResourcePermissionTag());
    }
    return authorizationInfo;
}
自定義Salt

由於Shiro裡面預設的SimpleByteSource沒有實現序列化介面,導致ByteSource.Util.bytes()生成的salt在序列化時出錯,因此需要自定義Salt類並實現序列化介面。並在自定義的Realm的認證方法使用new CurrentSalt(user.getSalt())傳入鹽值。

/**由於shiro當中的ByteSource沒有實現序列化介面,快取時會發生錯誤
 * 因此,我們需要通過自定義ByteSource的方式實現這個介面
 * @author 賴柄灃 bingfengdev@aliyun.com
 * @version 1.0
 * @date 2020/10/8 16:17
 */
public class CurrentSalt extends SimpleByteSource implements Serializable {
    public CurrentSalt(String string) {
        super(string);
    }

    public CurrentSalt(byte[] bytes) {
        super(bytes);
    }

    public CurrentSalt(char[] chars) {
        super(chars);
    }

    public CurrentSalt(ByteSource source) {
        super(source);
    }

    public CurrentSalt(File file) {
        super(file);
    }

    public CurrentSalt(InputStream stream) {
        super(stream);
    }
}

新增Shiro自定義會話

新增自定義會話ID生成器

/**SessionId生成器
 * <p>@author 賴柄灃 laibingf_dev@outlook.com</p>
 * <p>@date 2020/8/15 15:19</p>
 */
public class ShiroSessionIdGenerator implements SessionIdGenerator {

    /**
     *實現SessionId生成
     * @param session
     * @return
     */
    @Override
    public Serializable generateId(Session session) {
        Serializable sessionId = new JavaUuidSessionIdGenerator().generateId(session);
        return String.format("login_token_%s", sessionId);
    }
}

新增自定義會話管理器

/**
 * <p>@author 賴柄灃 laibingf_dev@outlook.com</p>
 * <p>@date 2020/8/15 15:40</p>
 */
public class ShiroSessionManager extends DefaultWebSessionManager {

    //定義常量
    private static final String AUTHORIZATION = "Authorization";
    private static final String REFERENCED_SESSION_ID_SOURCE = "Stateless request";
    //重寫構造器
    public ShiroSessionManager() {
        super();
        this.setDeleteInvalidSessions(true);
    }

    /**
     * 重寫方法實現從請求頭獲取Token便於介面統一
     *      * 每次請求進來,
     *      Shiro會去從請求頭找Authorization這個key對應的Value(Token)
     * @param request
     * @param response
     * @return
     */
    @Override
    public Serializable getSessionId(ServletRequest request, ServletResponse response) {
        String token = WebUtils.toHttp(request).getHeader(AUTHORIZATION);
        //如果請求頭中存在token 則從請求頭中獲取token
        if (!StringUtils.isEmpty(token)) {
            request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_SOURCE, REFERENCED_SESSION_ID_SOURCE);
            request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID, token);
            request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_IS_VALID, Boolean.TRUE);
            return token;
        } else {
            // 這裡禁用掉Cookie獲取方式
            return null;
        }
    }
}

配置自定義會話管理器

在ShiroConfig中新增對會話管理器的配置

/**
 * SessionID生成器
 *
 */
@Bean
public ShiroSessionIdGenerator sessionIdGenerator(){
    return new ShiroSessionIdGenerator();
}

/**
 * 配置RedisSessionDAO
 */
@Bean
public RedisSessionDAO redisSessionDAO() {
    RedisSessionDAO redisSessionDAO = new RedisSessionDAO();
    redisSessionDAO.setRedisManager(redisManager());
    redisSessionDAO.setSessionIdGenerator(sessionIdGenerator());
    redisSessionDAO.setKeyPrefix(SESSION_KEY);
    redisSessionDAO.setExpire(EXPIRE);
    return redisSessionDAO;
}

/**
 * 配置Session管理器
 * @Author Sans
 *
 */
@Bean
public SessionManager sessionManager() {
    ShiroSessionManager shiroSessionManager = new ShiroSessionManager();
    shiroSessionManager.setSessionDAO(redisSessionDAO());
    //禁用cookie
    shiroSessionManager.setSessionIdCookieEnabled(false);
    //禁用會話id重寫
    shiroSessionManager.setSessionIdUrlRewritingEnabled(false);
    return shiroSessionManager;
}

目前最新版本(1.6.0)中,session管理器的setSessionIdUrlRewritingEnabled(false)配置沒有生效,導致沒有認證直接訪問受保護資源出現多次重定向的錯誤。將shiro版本切換為1.5.0後就解決了這個bug。

本來這篇文章應該是昨晚發的,因為這個原因搞了好久,所有今天才發。。。

修改自定義Realm的doGetAuthenticationInfo認證方法

在認證資訊返回前,我們需要做一個判斷:如果當前使用者已在舊裝置上登入,則需要將舊裝置上的會話id刪掉,使其下線。

/**
 * 認證
 * @param token
 * @return
 * @throws AuthenticationException
 */
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {

    if(token==null){
        return null;
    }
    String principal = (String) token.getPrincipal();
    User user = userService.findByUsername(principal);
    SimpleAuthenticationInfo simpleAuthenticationInfo = new MyAuthcInfo(
            //由於shiro-redis外掛需要從這個屬性中獲取id作為redis的key
            //所有這裡傳的是user而不是username
            user,
            //憑證資訊
            user.getPassword(),
            //加密鹽值
            new CurrentSalt(user.getSalt()),
            getName());

    //清除當前主體舊的會話,相當於你在新電腦上登入系統,把你之前在舊電腦上登入的會話擠下去
    ShiroUtils.deleteCache(user.getUsername(),true);
    return simpleAuthenticationInfo;
}

修改login介面

我們將會話資訊儲存在redis中,並在使用者認證通過後將會話Id以token的形式返回給使用者。使用者請求受保護資源時帶上這個token,我們根據token資訊去redis中獲取使用者的許可權資訊,從而做訪問控制。

@PostMapping("/login")
public HashMap<Object, Object> login(@RequestBody LoginVO loginVO) throws AuthenticationException {
    boolean flags = authcService.login(loginVO);
    HashMap<Object, Object> map = new HashMap<>(3);
    if (flags){
        Serializable id = SecurityUtils.getSubject().getSession().getId();
        map.put("msg","登入成功");
        map.put("token",id);
        return map;
    }else {
        return null;
    } 
}

新增全域性異常處理

/**shiro異常處理
 * @author 賴柄灃 bingfengdev@aliyun.com
 * @version 1.0
 * @date 2020/10/7 18:01
 */
@ControllerAdvice(basePackages = "pers.lbf.springbootshiro")
public class AuthExceptionHandler {

    //==================認證異常====================//

    @ExceptionHandler(ExpiredCredentialsException.class)
    @ResponseBody
    public String expiredCredentialsExceptionHandlerMethod(ExpiredCredentialsException e) {
        return "憑證已過期";
    }

    @ExceptionHandler(IncorrectCredentialsException.class)
    @ResponseBody
    public String incorrectCredentialsExceptionHandlerMethod(IncorrectCredentialsException e) {
        return "使用者名稱或密碼錯誤";
    }

    @ExceptionHandler(UnknownAccountException.class)
    @ResponseBody
    public String unknownAccountExceptionHandlerMethod(IncorrectCredentialsException e) {
        return "使用者名稱或密碼錯誤";
    }

    
    @ExceptionHandler(LockedAccountException.class)
    @ResponseBody
    public String lockedAccountExceptionHandlerMethod(IncorrectCredentialsException e) {
        return "賬戶被鎖定";
    }

    //=================授權異常=====================//

    @ExceptionHandler(UnauthorizedException.class)
    @ResponseBody
    public String unauthorizedExceptionHandlerMethod(UnauthorizedException e){
        return "未授權!請聯絡管理員授權";
    }
}

實際開發中,應該對返回結果統一化,並給出業務錯誤碼。這已經超出了本文的範疇,如有需要,請根據自身系統特點考量。

進行測試

認證

登入成功的情況

使用者名稱或密碼錯誤的情況

image-20201009130152368

為了安全起見,不要暴露具體是使用者名稱錯誤還是密碼錯誤。

訪問受保護資源

認證後訪問有許可權的資源

image-20201009130937100

認證後訪問無許可權的資源

image-20201009131118464

未認證直接訪問的情況

檢視redis

image-20201009131904740

三個鍵值分別對應認證資訊快取、授權資訊快取和會話資訊快取。

寫在最後

目前基本上把shiro的入門知識點學完了。國慶中秋小長假也結束了。後面有時間再補充shiro標籤內容的使用。

最後貼出shiro的入門修仙功法連結,方便檢視:

  1. 《走進shiro,構建安全的應用程式---shiro修仙序章》
  2. 《shiro認證流程原始碼分析--練氣初期》
  3. 《Shiro入門學習---使用自定義Realm完成認證|練氣中期》
  4. 《shiro入門學習--使用MD5和salt進行加密|練氣後期》
  5. 《shiro入門學習--授權(Authorization)|築基初期|》
  6. 《SpringBoot整合Shiro+MD5+Salt+Redis實現認證和動態許可權管理(上)----築基中期》

如果您覺得這篇文章能給您帶來幫助,那麼可以點贊鼓勵一下。如有錯誤之處,還請不吝賜教。在此,謝過各位鄉親父老!

程式碼及sql下載方式:微信搜尋【Java開發實踐】,加關注並回復20201009 即可獲取下載連結。

相關文章