JAVA安全框架Apache Shiro淺析

構建一個互聯(lián)網應用涉瘾,權限校驗管理是很重要的安全措施兴革,這其中主要包含:

  • 認證 - 用戶身份識別壁拉,即登錄
  • 授權 - 訪問控制
  • 密碼加密 - 加密敏感數(shù)據防止被偷窺
  • 會話管理 - 與用戶相關的時間敏感的狀態(tài)信息

Shiro對以上功能都進行了很好的支持兵志,而且十分易于使用,且可運行在注入WEB, IOC, EJB等環(huán)境中姜骡。

在Shiro中导坟,有以下幾個核心概念。

1. Subject
對于一個應用的權限校驗模塊來說圈澈,首先要考慮的就是“當前操作的用戶是誰”惫周, “是否允許該用戶進行某項操作”。因為應用接口都是基于用戶的某個基本操作來構建的康栈,所以我們構建一個應用的權限模塊递递,是基于用戶的概念來構建的。

Shiro的Subject概念就很好地基于用戶概念做了抽象啥么。Subject在Shiro中表示當前執(zhí)行操作的用戶登舞,這個用戶概念不僅僅是指由真實人類發(fā)起的某項請求,也可以使一個后臺線程悬荣、一個后臺帳戶或者是其他實體對象菠秒。

例如在Shiro中,我們可以通過如下代碼獲得一個Subject對象:

Subject currentUser = SecurityUtils.getSubject();

在獲取了Subject對象之后氯迂,就可以執(zhí)行包括登錄践叠、登出、獲取會話嚼蚀、權限校驗等操作禁灼。Shiro的簡單易用的API,使得我們在程序的任何地方都能很方便地獲取當前登錄用戶轿曙,并進行登錄用戶的各項基本操作弄捕。

Subject currentUser = SecurityUtils.getSubject();
currentUser.isAuthenticated()
UsernamePasswordToken token = new UsernamePasswordToken("lonestarr", "vespa");
token.setRememberMe(true);
currentUser.login(token);
currentUser.hasRole("schwartz")
currentUser.isPermitted("lightsaber:wield")
currentUser.logout();

2.SecurityManager
通過ini的方式可以配置SecurityManager,里面包含用戶信息导帝、角色察藐、權限、url權限信息舟扎。SecurityManager通常是單例的分飞,因為新建需要讀取ini文件配置是耗時的,而且其只存儲相關配置信息睹限。
SecurityManager則管理所有用戶的安全操作譬猫,它是Shiro框架的核心。一旦其初始化配置完成羡疗,我們就不會再調用其相關API了染服,而是將精力集中在了Subject相關的權限操作上了。

Factory<SecurityManager> factory = new IniSecurityManagerFactory("classpath:shiro.ini");
SecurityManager securityManager = factory.getInstance();

# =======================
# Shiro INI configuration
# =======================

[main]
# Objects and their properties are defined here,
# Such as the securityManager, Realms and anything
# else needed to build the SecurityManager
iniRealm= org.apache.shiro.realm.text.IniRealm
securityManager.realms=iniRealm

[users]
# user 'root' with password 'secret' and the 'admin' role
root = secret, admin
# user 'guest' with the password 'guest' and the 'guest' role
guest = guest, guest
# user 'presidentskroob' with password '12345' ("That's the same combination on
# my luggage!!!" ;)), and role 'president'
presidentskroob = 12345, president
# user 'darkhelmet' with password 'ludicrousspeed' and roles 'darklord' and 'schwartz'
darkhelmet = ludicrousspeed, darklord, schwartz
# user 'lonestarr' with password 'vespa' and roles 'goodguy' and 'schwartz'
lonestarr = vespa, goodguy, schwartz

# -----------------------------------------------------------------------------
# Roles with assigned permissions
# 
# Each line conforms to the format defined in the
# org.apache.shiro.realm.text.TextConfigurationRealm#setRoleDefinitions JavaDoc
# -----------------------------------------------------------------------------
[roles]
# 'admin' role has all permissions, indicated by the wildcard '*'
admin = *
# The 'schwartz' role can do anything (*) with any lightsaber:
schwartz = lightsaber:*
# The 'goodguy' role is allowed to 'drive' (action) the winnebago (type) with
# license plate 'eagle5' (instance specific id)
goodguy = winnebago:drive:eagle5

[urls]
# The 'urls' section is used for url-based security
# in web applications.  We'll discuss this section in the
# Web documentation

3.Realms
Realm充當了Shiro與應用安全數(shù)據間的橋梁叨恨。當用戶需要授權登錄時柳刮,Shiro使用Realms獲取授權驗證所必須的安全數(shù)據。所以,從本質上將秉颗,Realm實質上是一個安全相關的DAO痢毒,它封裝了數(shù)據源的連接細節(jié),并在需要時將相關數(shù)據提供給Shiro蚕甥。當配置Shiro時哪替,你必須至少指定一個Realm,用于認證和(或)授權菇怀。配置多個Realm是可以的凭舶,但是至少需要一個。

Apache Shiro提供多種認證數(shù)據源的支持爱沟,包括從JDBC, JNDI, LDAP等數(shù)據源獲取認證信息帅霜。

Realms繼承結構

4.AuthenticationToken
AuthenticationToken是用戶Subject提交的有關登錄主體和憑證的基本信息組合,這個token會通過Authenticator#authenticate(AuthenticationToken)提交給Authenticator呼伸,由Authenticator執(zhí)行授權和登錄過程身冀。

同時,AuthenticationTokenUsernamePasswordToken的默認實現(xiàn)蜂大,如果我們程序是基本的通過用戶名+密碼的登錄方式闽铐,可以直接使用該類作為用戶登錄憑證的提交方式。
當然我們也可以通過implement AuthenticationToken的方式來實現(xiàn)自定義的登錄方式和特殊的必需登錄數(shù)據的索取奶浦。

AuthenticationToken繼承結構


下面從認證授權的全過程兄墅,來介紹Shiro的授權認證過程:

package com.zhuke.shiro;

import org.apache.shiro.SecurityUtils;
import org.apache.shiro.authc.*;
import org.apache.shiro.config.IniSecurityManagerFactory;
import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.session.Session;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.util.Factory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class Quickstart {

    private static final transient Logger log = LoggerFactory.getLogger(Quickstart.class);

    public static void main(String[] args) {
        Factory<SecurityManager> factory = new IniSecurityManagerFactory("classpath:shiro.ini");
        SecurityManager securityManager = factory.getInstance();
        SecurityUtils.setSecurityManager(securityManager);

        Subject currentUser = SecurityUtils.getSubject();

        if (!currentUser.isAuthenticated()) {
            UsernamePasswordToken token = new UsernamePasswordToken("lonestarr", "vespa");
            token.setRememberMe(true);
            try {
                currentUser.login(token);
            } catch (UnknownAccountException uae) {
                log.info("There is no user with username of " + token.getPrincipal());
            } catch (IncorrectCredentialsException ice) {
                log.info("Password for account " + token.getPrincipal() + " was incorrect!");
            } catch (LockedAccountException lae) {
                log.info("The account for username " + token.getPrincipal() + " is locked.  " +
                        "Please contact your administrator to unlock it.");
            }
            // ... catch more exceptions here (maybe custom ones specific to your application?
            catch (AuthenticationException ae) {
                //unexpected condition?  error?
            }
        }

        log.info("User [" + currentUser.getPrincipal() + "] logged in successfully.");

        //test a role:
        if (currentUser.hasRole("schwartz")) {
            log.info("May the Schwartz be with you!");
        } else {
            log.info("Hello, mere mortal.");
        }

        //test a typed permission (not instance-level)
        if (currentUser.isPermitted("lightsaber:wield")) {
            log.info("You may use a lightsaber ring.  Use it wisely.");
        } else {
            log.info("Sorry, lightsaber rings are for schwartz masters only.");
        }

        //a (very powerful) Instance Level permission:
        if (currentUser.isPermitted("winnebago:drive:eagle5")) {
            log.info("You are permitted to 'drive' the winnebago with license plate (id) 'eagle5'.  " +
                    "Here are the keys - have fun!");
        } else {
            log.info("Sorry, you aren't allowed to drive the 'eagle5' winnebago!");
        }

        //all done - log out!
        currentUser.logout();

        System.exit(0);
    }
}

Subject currentUser = SecurityUtils.getSubject();從當前線程獲取一個Subject授權對象,如果不存在澳叉,則新建一個隙咸。

public static Subject getSubject() {
        Subject subject = ThreadContext.getSubject();
        if (subject == null) {
            subject = (new Subject.Builder()).buildSubject();
            ThreadContext.bind(subject);
        }
        return subject;
    }

可以看到,這里的Subject對象信息是儲存在ThreadContext中的成洗,那么我們對這個ThreadContext做一個簡單分析五督。

ThreadContext提供了一個在當前線程上綁定和解綁key/value鍵值對的操作。其內部使用了一個ThreadLocal<Map<Object, Object>>來存儲鍵值對瓶殃。
如果程序不想要線程之間共享信息(注入線程池或者線程復用等手段)充包,那么必須在調用棧開始和結束階段主動調用清理敏感信息(通過remove方法)

//存儲線程獨占的key/value信息
private static final ThreadLocal<Map<Object, Object>> resources 
  = new InheritableThreadLocalMap<Map<Object, Object>>()

login的具體源碼方法為:

public void login(AuthenticationToken token) throws AuthenticationException {
        clearRunAsIdentitiesInternal();

        //委托給securityManager執(zhí)行具體的登錄驗證工作
        Subject subject = securityManager.login(this, token);
        ……
    }

securityManager又將具體的授權驗證任務交給Authenticator執(zhí)行:

public AuthenticationInfo authenticate(AuthenticationToken token) throws AuthenticationException {
        return this.authenticator.authenticate(token);
    }

Authenticator則會查找配置的所有realms,根據realms配置的授權驗證方案進行授權驗證:

protected AuthenticationInfo doAuthenticate(AuthenticationToken authenticationToken) throws AuthenticationException {
        assertRealmsConfigured();
        //獲取所有配置的realms
        Collection<Realm> realms = getRealms();
        if (realms.size() == 1) {
            return doSingleRealmAuthentication(realms.iterator().next(), authenticationToken);
        } else {
            return doMultiRealmAuthentication(realms, authenticationToken);
        }
    }
Subject.login調用過程

而通過以上對Realm的分析遥椿,我們知道Shiro有多個Realm的實現(xiàn)基矮,對于互聯(lián)網程序,通常情況我們將用戶名和密碼信息存儲在數(shù)據庫中冠场,在做授權驗證的時候家浇,從數(shù)據庫中取出用戶名和密碼進行比對。

下面將對Shiro的JDBCRealm進行分析碴裙。


JDBCRealm

其中定義了獲取存儲在數(shù)據庫中的用戶名|密碼|鹽值的相關sql語句钢悲。

 /**
     * The default query used to retrieve account data for the user.
     */
    protected static final String DEFAULT_AUTHENTICATION_QUERY = "select password from users where username = ?";
    
    /**
     * The default query used to retrieve account data for the user when {@link #saltStyle} is COLUMN.
     */
    protected static final String DEFAULT_SALTED_AUTHENTICATION_QUERY = "select password, password_salt from users where username = ?";

    /**
     * The default query used to retrieve the roles that apply to a user.
     */
    protected static final String DEFAULT_USER_ROLES_QUERY = "select role_name from user_roles where username = ?";

    /**
     * The default query used to retrieve permissions that apply to a particular role.
     */
    protected static final String DEFAULT_PERMISSIONS_QUERY = "select permission from roles_permissions where role_name = ?";

幾種鹽值存儲方案:

//NO_SALT - password hashes are not salted.
//CRYPT - password hashes are stored in unix crypt format.
//COLUMN - salt is in a separate column in the database.
//EXTERNAL - salt is not stored in the database. getSaltForUser(String) will be called to get the salt
 public enum SaltStyle {NO_SALT, CRYPT, COLUMN, EXTERNAL};

具體執(zhí)行授權驗證的代碼為:

protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {

        UsernamePasswordToken upToken = (UsernamePasswordToken) token;
        String username = upToken.getUsername();

        // Null username is invalid
        if (username == null) {
            throw new AccountException("Null usernames are not allowed by this realm.");
        }

        Connection conn = null;
        SimpleAuthenticationInfo info = null;
        try {
            conn = dataSource.getConnection();//獲取數(shù)據庫連接

            String password = null;
            String salt = null;
            switch (saltStyle) {
            case NO_SALT:
                password = getPasswordForUser(conn, username)[0];
                break;
            case CRYPT:
                ……
            case COLUMN:
                ……
            case EXTERNAL:
               ……
            }

            if (password == null) {
                throw new UnknownAccountException("No account found for user [" + username + "]");
            }

            info = new SimpleAuthenticationInfo(username, password.toCharArray(), getName());
            
            if (salt != null) {
                info.setCredentialsSalt(ByteSource.Util.bytes(salt));
            }

        } catch (SQLException e) {
            ……
        } finally {
            JdbcUtils.closeConnection(conn);
        }

        return info;
    }
JDBCRealm

getPasswordForUser內部執(zhí)行配置的authenticationQuery查找指定用戶名的密碼信息

private String[] getPasswordForUser(Connection conn, String username) throws SQLException {
        PreparedStatement ps = null;
        ResultSet rs = null;
        try {
            執(zhí)行配置的authenticationQuery語句查找指定用戶名的密碼信息
            ps = conn.prepareStatement(authenticationQuery);
            ps.setString(1, username);

            // Execute query
            rs = ps.executeQuery();
            ……
        } finally {
            JdbcUtils.closeResultSet(rs);
            JdbcUtils.closeStatement(ps);
        }

        return result;
    }

通過JDBCRealm配置的sql語句查找完成指定username的password, rolename, permission后点额,我們需要比對用戶提交的password和正確的password是否匹配。Shiro使用CredentialsMatcher來計算上述的匹配關系莺琳。

CredentialsMatcher類繼承結構
SimpleCredentialsMatcher

其中SimpleCredentialsMatcher簡單比較提交的密碼和真實密碼的byte流是否想等(密碼為:instanceof byte[] || o instanceof char[] || o instanceof String || o instanceof ByteSource || o instanceof File || o instanceof InputStream時)还棱,或者直接通過Object.equals比較(不滿足上訴條件時)

protected boolean equals(Object tokenCredentials, Object accountCredentials) {
        if (log.isDebugEnabled()) {
            log.debug("Performing credentials equality check for tokenCredentials of type [" +
                    tokenCredentials.getClass().getName() + " and accountCredentials of type [" +
                    accountCredentials.getClass().getName() + "]");
        }
        if (isByteSource(tokenCredentials) && isByteSource(accountCredentials)) {
            if (log.isDebugEnabled()) {
                log.debug("Both credentials arguments can be easily converted to byte arrays.  Performing " +
                        "array equals comparison");
            }
            byte[] tokenBytes = toBytes(tokenCredentials);
            byte[] accountBytes = toBytes(accountCredentials);
            return MessageDigest.isEqual(tokenBytes, accountBytes);
        } else {
            return accountCredentials.equals(tokenCredentials);
        }
    }
PasswordMatcher

PasswordMatcher是Shiro推薦的用戶名密碼校驗的最佳實踐,因為他通過注入一個程序自定義的PasswordService實現(xiàn)芦昔,來進行用戶名和密碼的授權校驗诱贿。

public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) {

        PasswordService service = ensurePasswordService();

        Object submittedPassword = getSubmittedPassword(token);
        Object storedCredentials = getStoredPassword(info);
        assertStoredCredentialsType(storedCredentials);

        if (storedCredentials instanceof Hash) {
            Hash hashedPassword = (Hash)storedCredentials;
            HashingPasswordService hashingService = assertHashingPasswordService(service);
            return hashingService.passwordsMatch(submittedPassword, hashedPassword);
        }
        //otherwise they are a String (asserted in the 'assertStoredCredentialsType' method call above):
        String formatted = (String)storedCredentials;
        //調用注入的passwordService的實現(xiàn)來進行密碼的匹配校驗
        return passwordService.passwordsMatch(submittedPassword, formatted);
    }

程序通過實現(xiàn)PasswordService來進行自定義的密碼校驗過程娃肿。

public interface PasswordService {
    String encryptPassword(Object plaintextPassword) throws IllegalArgumentException;
    boolean passwordsMatch(Object submittedPlaintext, String encrypted);
}

Session

Shiro提供一個權限的企業(yè)級Session解決方案咕缎,可以運行在簡單的命令行或者是智能手機平臺上,也可以工作在大型的集群應用上料扰。

以往我們需要使用Session的一些特性支持時凭豪,往往只能將服務部署在web容器或者EJB的Session特性。

Shiro的Session管理方案比上述兩種方案都更簡單晒杈,而且他可以運行在任何應用中嫂伞,與容器無關。

即使我們將應用部署在Servlet或者EJB容器中拯钻,Shiro Session的許多特性仍然值得我們使用它帖努。

  • POJO/J2SE based (IoC friendly) - 在Shiro的應用框架中,所有都是基于接口的粪般。這使得我們可以很簡單快速地配置所有有關session的組件(通過JSON, YAML, Spring XML etc.)拼余。同時我們也能通過繼承Shiro的基本組件,實現(xiàn)我們自定義的session方案亩歹。
  • Easy Custom Session Storage - 因為session對象是基于POJO的匙监,所以session數(shù)據可以很簡單方便地存儲在任意數(shù)據源中。比如:文件系統(tǒng)小作、分布式緩存亭姥、關系型數(shù)據庫等。
  • Container-Independent Clustering - Shiro Session可以很方便地和目前成熟的緩存方案進行結合顾稀,比如 Ehcache + Terracotta, Coherence, GigaSpaces, et达罗。這意味著我們可以通過配置session存儲集群,使之和應用的部署容器無關静秆。
  • 跨客戶端訪問 - 當我們使用EJB或者web 的session的時候粮揉,當我們要獲取session對象時,必須要在容器內才能獲得诡宗。Shiro通過在統(tǒng)一數(shù)據源(EhCache, redis, memcache etc)獲取到Session滔蝉,可以實現(xiàn)跨客戶端共享session數(shù)據。比如塔沃,一個java swing客戶端可以看到和共享web客戶端的同一用戶的session數(shù)據蝠引。
  • Event Listeners - 事件監(jiān)聽機制允許我們監(jiān)聽session生命周期的全過程阳谍,并在相應事件發(fā)生時做出對應的反應。比如我們可以再一個用戶session過期時更新其對應的狀態(tài)信息螃概。
  • Host Address Retention - Shiro Session保留了Session初始化時的原始IP和host name信息矫夯。這在互聯(lián)網環(huán)境下是十分有用的,我們可以根據用戶session的IP信息做出相應的反應和處理吊洼。
  • Inactivity/Expiration Support - 我們可以通過touch()方法來延遲Session的過期训貌。
  • Transparent Web Use - Shiro基于Servlet 2.5實現(xiàn)了對HttpSession的完全支持。這就意味著我們可以適用Shiro Session在web 應用中冒窍,而不用更改任何其他代碼递沪。
  • Can be used for SSO - 基于以上的:基于POJO, 可存儲在任意數(shù)據源综液, 可跨客戶端共享的特性款慨,我們可以用其實現(xiàn)一個基本的SSO。

在Shiro中谬莹,session的生命周期都在SessionManager中進行管理檩奠。

SessionManager類繼承結構

可以看到,Shiro的SecuityManager實現(xiàn)了SessionManager接口附帽,使其具有了管理session的能力埠戳,在Shiro中芳撒,Session的具體管理工作给郊,最終都實際委托給了默認的實現(xiàn)方案DefaultSessionManager進行處理。

DefaultSessionManager

其中荒适,有以下兩個屬性慢显,在session的生命周期管理中起到了重要作用:

//session工廠類爪模,負責創(chuàng)建一個新的session對象
private SessionFactory sessionFactory;

//復雜對session進行CRUD的基本操作DAO類
protected SessionDAO sessionDAO;  

下面從一個session的創(chuàng)建、存活荚藻、過期的生命周期從源碼層面來分析其設計方案屋灌。

首先,我們通過如下代碼獲取一個Session對象:

Subject currentUser = SecurityUtils.getSubject();

Session session = currentUser.getSession();
session.setAttribute("someKey", "aValue");
String value = (String) session.getAttribute("someKey");

而Subject的session創(chuàng)建過程為:

public Session getSession(boolean create) {
    //如果當前Subject的session為空应狱,且create=true共郭,則新建一個session
    if (this.session == null && create) {
        //如果配置的不允許新建session,則拋出異常
        //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的創(chuàng)建委托給sessionManager執(zhí)行
        Session session = this.securityManager.start(sessionContext);
        this.session = decorate(session);
    }
    return this.session;
}

session初始化完成后疾呻,會調用sessionDAO.create()方法對新建的session進行分配sessionID和持久化的步驟除嘹。

sessionID的分配也是體現(xiàn)了Shiro中所有組件都使用接口的方式的設計理念,下面我們對其進行一個分析岸蜗。

SessionDAO類繼承結構

處于最上層的SessionDAO接口定義了一個SessionDAOd的最基礎的方法尉咕,包括create為新建的session分配id和持久化,readSession根據id查找session璃岳,update更新session年缎, delete刪除session悔捶,getActiveSessions獲取所有正在生效的session。

而AbstractSessionDAO則在SessionDAO的基礎上单芜,實現(xiàn)了sessionID的分配方案蜕该。

AbstractSessionDAO

通過注入不同的sessionID生成方案,我們可以對sessionID的分配方案進行自定義的差異化配置洲鸠。Shiro默認實現(xiàn)了兩種ID生成方案堂淡。

SessionIdGenerator類繼承結構
  • 基于JAVA UUID:
public Serializable generateId(Session session) {
    return UUID.randomUUID().toString();
}
  • 基于SHA1PRNG的隨機算法

繼續(xù)回到SessionDAO的session持久化創(chuàng)建過程,通過可配置的sessionID分配方案分配完成sessionID后扒腕,會將session持久化到對應的數(shù)據源中绢淀。

這就有兩種選擇

  • 單機共享的MemorySessionDAO

內部使用一個ConcurrentMap<Serializable, Session> sessions來存儲session

protected Serializable doCreate(Session session) {
    Serializable sessionId = generateSessionId(session);
    assignSessionId(session, sessionId);
    storeSession(sessionId, session);
    return sessionId;
}

protected Session storeSession(Serializable id, Session session) {
    if (id == null) {
        throw new NullPointerException("id argument cannot be null.");
    }
    //以sessionID為key,session對象為值存入ConcurrentMap中
    return sessions.putIfAbsent(id, session);
}
  • 通過注入CacheManager實現(xiàn)session的透明化管理

通過向CachingSessionDAO注入一個CacheManager對象袜匿,由CacheManager提供Cache的獲取方案更啄,我們可以實現(xiàn)將session的管理交給CacheManager稚疹。


當然我們也可以通過繼承AbstractSessionDAO居灯,實現(xiàn)其中具體的session的CRUD方法,來進行自定義數(shù)據源的session管理工作内狗。
如下怪嫌,通過繼承,我們成功將session持久化到了memcache數(shù)據源中柳沙。

import java.io.Serializable;
import java.util.Collection;
import java.util.Collections;

import net.spy.memcached.MemcachedClient;

import org.apache.shiro.session.Session;
import org.apache.shiro.session.UnknownSessionException;
import org.apache.shiro.session.mgt.eis.AbstractSessionDAO;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;

/**
 * 維持一個登錄會話的實現(xiàn)類岩灭,將會話信息存儲在緩存層
 */
public class LoginSessionDAO extends AbstractSessionDAO {

    private static final Logger LOGGER = LoggerFactory.getLogger(LoginSessionDAO.class);

    @Autowired
    private MemcachedClient client;

    // session信息存儲在memcache中的前綴
    private String prefix;

    // 過期時間(單位:秒)
    private long expTime;

    @Override
    public void delete(Session session) {
        if (session == null || session.getId() == null) {
            LOGGER.error("[Session is null or session is null]");
            return;
        }
        String key = genSessionId(session.getId());
        boolean result = client.delete(key);
        if (LOGGER.isDebugEnabled()) {
            LOGGER.debug("[delete session {}] key={}", result ? "success" : "fail", key);
        }
    }

    
    @Override
    public Collection<Session> getActiveSessions() {
        // 暫不支持
        return Collections.emptyList();
    }

    
    @Override
    public void update(Session session) throws UnknownSessionException {
        if (session == null || session.getId() == null) {
            LOGGER.error("[Session is null or session is null]");
            return;
        }
        String key = genSessionId(session.getId());
    
        // 將session對象序列化,采用java的對象序列化方式
        client.set(key, JavaObjectSerializer.toByteArray(session), expTime * 1000);
    }

    
    @Override
    protected Serializable doCreate(Session session) {
        Serializable sessionId = generateSessionId(session);
        assignSessionId(session, sessionId);
        // JSON.DEFAULT_GENERATE_FEATURE &=
        // ~SerializerFeature.SkipTransientField
        // .getMask();
        String key = genSessionId(sessionId);
        
        // 將session對象序列化赂鲤,采用java的對象序列化方式
        boolean result = client.set(key, JavaObjectSerializer.toByteArray(session), expTime * 1000);
        if (LOGGER.isDebugEnabled()) {
            LOGGER.debug("[create session {}] key={}", result ? "success" : "fail", key);
        }
        return sessionId;
    }

    
    @Override
    protected Session doReadSession(Serializable sessionId) {
        if (sessionId == null) {
            LOGGER.error("[SessionId is null]");
            return null;
        }
        
        Object sessionData = client.get(genSessionId(sessionId));
        if (sessionData == null){
            return null;
        }else{
            return (Session) JavaObjectSerializer.toObject(sessionData);
        }
    }

    
    private String genSessionId(Serializable sessionId) {
        return prefix + sessionId;
    }

}

Session Listeners

在上面我們介紹Shiro Session的特性時噪径,提到我們可以通過session listener的方式,來監(jiān)聽session的生命周期全過程数初,那么Shiro是怎么實現(xiàn)的呢找爱?

見SessionManager的繼承體系圖中,AbstractSessionManager定義了session的過期時間相關屬性的設置和獲取方法泡孩,而AbstractNativeSessionManager則定義和實現(xiàn)了session生命周期監(jiān)聽器的相關功能车摄。

//監(jiān)聽器列表
private Collection<SessionListener> listeners;

public Session start(SessionContext context) {
   ……
    notifyStart(session);//session創(chuàng)建完畢,通知監(jiān)聽器
  ……
}

//遍歷監(jiān)聽器列表仑鸥,調用onStart方法
protected void notifyStart(Session session) {
    for (SessionListener listener : this.listeners) {
        listener.onStart(session);
    }
}

```java

SessionListener接口定義了session的完整生命周期的對應的動作吮播,通過實現(xiàn)SessionListener接口,我們可以對session的生命周期變化做出相應的動作響應眼俊。

![SessionListener](http://upload-images.jianshu.io/upload_images/3159214-63b8a2f31f37f0b6.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)


#### Session過期時間和過期策略
Session的默認過期時間在```org.apache.shiro.session.mgt.AbstractSessionManager#DEFAULT_GLOBAL_SESSION_TIMEOUT```有配置意狠,為30min。

Session必須在校驗到其已經失效時疮胖,從存儲系統(tǒng)中進行刪除环戈,這保證了我們的session存儲數(shù)據源不會隨著時間的流逝誊役,而被大量已過期的無用session占滿。

為了性能考慮谷市,SessionManager只在根據sessionID獲取session時會檢查session的有效狀態(tài)蛔垢。那么當一個會話在建立之后,從此就再也沒有心得請求與服務器進行交互迫悠,此時這個session因為不會再經過有效性校驗的過程了鹏漆,那么該session就將一直存在于存儲系統(tǒng)中。此時成該會話為```orphans session```创泄,我將其以為**孤立會話**艺玲。

為了避免大量的孤立會話榨干存儲資源,Shiro提供了一種定期檢查的機制來對已過期的session進行刪除鞠抑。

當然如果我們是將session持久化到緩存數(shù)據庫中去饭聚,如redis, memcache搁拙,通過緩存數(shù)據庫的過期機制秒梳,可以保證session的過期剔除的特性。

Shiro的默認配置為使用```ExecutorServiceSessionValidationScheduler```來定期清理過期session箕速,其內部使用JDK的```ScheduledExecutorService```作為線程任務管理器來管理清理任務酪碘。

```java
public void enableSessionValidation() {
    if (this.interval > 0l) {
        this.service = Executors.newSingleThreadScheduledExecutor(new ThreadFactory() {  
            private final AtomicInteger count = new AtomicInteger(1);

            public Thread newThread(Runnable r) {  
                Thread thread = new Thread(r);  
                thread.setDaemon(true);  
                thread.setName(threadNamePrefix + count.getAndIncrement());
                return thread;  
            }  
        });                  
        //每隔interval時間執(zhí)行一次run()定義的任務
        this.service.scheduleAtFixedRate(this, interval, interval, TimeUnit.MILLISECONDS);
    }
    this.enabled = true;
}

public void run() {
    if (log.isDebugEnabled()) {
        log.debug("Executing session validation...");
    }
    long startTime = System.currentTimeMillis();
    //將任務轉交給sessionManager進行session校驗
    this.sessionManager.validateSessions();
    long stopTime = System.currentTimeMillis();
    if (log.isDebugEnabled()) {
        log.debug("Session validation completed successfully in " + (stopTime - startTime) + " milliseconds.");
    }
}

Session屬性改變時的持久化過程

session對象的類繼承結構

其中,SimpleSession存儲了session的基本屬性信息盐茎,包括sessionID兴垦,過期時間,上次訪問時間字柠,host探越,屬性信息等。

SimpleSession

在通過Subject新建session時窑业,根據基本的上下文信息钦幔,新建的是一個SimpleSession簡單對象,并不具備對象持久化的相關操作数冬。

public Session createSession(SessionContext initData) {
    if (initData != null) {
        String host = initData.getHost();
        if (host != null) {
            return new SimpleSession(host);
        }
    }
    return new SimpleSession();
}

但是在新建完成簡單SimpleSession完成的返回路徑中节槐,會對SimpleSession的功能進行增強,這其中就用到了代理的設計模式拐纱。

public Session start(SessionContext context) {
    Session session = createSession(context);
    applyGlobalSessionTimeout(session);
    onStart(session, context);
    notifyStart(session);
    //Don't expose the EIS-tier Session object to the client-tier:
    //對SimpleSession對象進行代理增強铜异,使其在屬性進行了改變的時候,能夠對更新相應的持久化存儲數(shù)據
    return createExposedSession(session, context);
}

protected Session createExposedSession(Session session, SessionContext context) {
    return new DelegatingSession(this, new DefaultSessionKey(session.getId()));
}

而其中對session對象所有的查找和更新操作都是通過其sessionManager根據sessionID在數(shù)據源中進行查找得到的最新結果秸架,并將更新結果update到數(shù)據源中揍庄。

public Collection<Object> getAttributeKeys() throws InvalidSessionException {
        return sessionManager.getAttributeKeys(key);
    }

所以session的每一次查找或更新都會經過一次配置的數(shù)據源的查找或更新。

Session & Subject的狀態(tài)

如果我們需要構建有狀態(tài)的應用程序东抹,比如我們需要在用戶首次登錄成功后蚂子,維持其登錄狀態(tài)沃测,在登錄的有效期內都擁有其對應的訪問授權權限。
Shiro使用Subject對應的Session來存儲Subject的身份信息食茎,如Subject identity(PrincipalCollection)和認證狀態(tài)(subject.isAuthenticated())蒂破,以便于在后面的連接和請求中使用。

應用程序可以從下次請求中獲取sessionID别渔,通過sessionID查找到Subject授權信息和Session信息附迷。

Serializable sessionId = //get from the inbound request or remote method invocation payload 
Subject requestSubject = new Subject.Builder().sessionId(sessionId).buildSubject();
public Subject login(Subject subject, AuthenticationToken token) throws AuthenticationException {
    AuthenticationInfo info;
    try {
        info = authenticate(token);
    } catch (AuthenticationException ae) {
        ……
    }

    //方法中會將認證授權信息存儲在session中
    Subject loggedIn = createSubject(token, info, subject);
    
    //如果token設置了rememberme=true,且配置了rememberMeManager哎媚,則對登錄的principal加密后信息進行保存
    onSuccessfulLogin(token, info, loggedIn);

    return loggedIn;
}

public Subject createSubject(SubjectContext subjectContext) {
    ……

    //save this subject for future reference if necessary:
    //(this is needed here in case rememberMe principals were resolved and they need to be stored in the
    //session, so we don't constantly rehydrate the rememberMe PrincipalCollection on every operation).
    //Added in 1.2:
    save(subject);

    return subject;
}

protected void save(Subject subject) {
    this.subjectDAO.save(subject);
}

//作為一個session的屬性喇伯,持久化保存在session中
protected void saveToSession(Subject subject) {
    //performs merge logic, only updating the Subject's session if it does not match the current state:
    mergePrincipals(subject);
    mergeAuthenticationState(subject);
}

protected void onSuccessfulLogin(AuthenticationToken token, AuthenticationInfo info, Subject subject) {
    rememberMeSuccessfulLogin(token, info, subject);
}

protected void rememberMeSuccessfulLogin(AuthenticationToken token, AuthenticationInfo info, Subject subject) {
    RememberMeManager rmm = getRememberMeManager();
    if (rmm != null) {
        try {
            rmm.onSuccessfulLogin(subject, token, info);
        } catch (Exception e) {
            if (log.isWarnEnabled()) {
                String msg = "Delegate RememberMeManager instance of type [" + rmm.getClass().getName() +
                        "] threw an exception during onSuccessfulLogin.  RememberMe services will not be " +
                        "performed for account [" + info + "].";
                log.warn(msg, e);
            }
        }
    } else {
        if (log.isTraceEnabled()) {
            log.trace("This " + getClass().getName() + " instance does not have a " +
                    "[" + RememberMeManager.class.getName() + "] instance configured.  RememberMe services " +
                    "will not be performed for account [" + info + "].");
        }
    }
}

我們也可以禁用Shiro對Subject授權信息的session保存方式,這樣我們每次請求都需要重新進行授權驗證拨与。

[main]
...
securityManager.subjectDAO.sessionStorageEvaluator.sessionStorageEnabled = false
...

上面說到稻据,我們可以全局禁用通過session的方式來存儲Subject的授權信息,那么考慮如下情況:

  • 如果是人類用戶登錄請求授權买喧,我們需要維持用戶的登錄信息捻悯,這時需要上述的Suject session特性;

  • 如果是機器后臺調用(如API調用)岗喉,這類請求具有很大的不連續(xù)性秋度,那么我們就不需要在session中存儲Subject的授權信息;

  • 如果通過某些特定渠道登錄的用戶需要存儲授權信息钱床,某些不需要呢。

如果我們需要實現(xiàn)上述所說的埠居,某些情況下需要查牌,某些情況下不需要存儲Subject授權信息,可以實現(xiàn)SessionStorageEvaluator接口來對情況進行自定義滥壕。

public Subject save(Subject subject) {
    //是否需要在session中存儲subject信息的計算算法
    if (isSessionStorageEnabled(subject)) {
        saveToSession(subject);
    } else {
        log.trace("Session storage of subject state for Subject [{}] has been disabled: identity and " +
                "authentication state are expected to be initialized on every request or invocation.", subject);
    }

    return subject;
}

protected boolean isSessionStorageEnabled(Subject subject) {
    return getSessionStorageEvaluator().isSessionStorageEnabled(subject);
}
    //實現(xiàn)自己的計算方案
    public boolean isSessionStorageEnabled(Subject subject) {
        boolean enabled = false;
        if (WebUtils.isWeb(Subject)) {
            HttpServletRequest request = WebUtils.getHttpRequest(subject);
            //set 'enabled' based on the current request.
        } else {
            //not a web request - maybe a RMI or daemon invocation?
            //set 'enabled' another way...
        }

        return enabled;
    }
[main]
...
sessionStorageEvaluator = com.mycompany.shiro.subject.mgt.MySessionStorageEvaluator
securityManager.subjectDAO.sessionStorageEvaluator = $sessionStorageEvaluator

...

示例配置代碼:
https://github.com/zhuke1993/shiro_example

參考資料:
https://www.infoq.com/articles/apache-shiro
https://shiro.apache.org/get-started.html
https://shiro.apache.org/session-management.html

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
  • 序言:七十年代末纸颜,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子绎橘,更是在濱河造成了極大的恐慌胁孙,老刑警劉巖,帶你破解...
    沈念sama閱讀 218,941評論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件称鳞,死亡現(xiàn)場離奇詭異涮较,居然都是意外死亡,警方通過查閱死者的電腦和手機冈止,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,397評論 3 395
  • 文/潘曉璐 我一進店門狂票,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人熙暴,你說我怎么就攤上這事闺属』哦ⅲ” “怎么了?”我有些...
    開封第一講書人閱讀 165,345評論 0 356
  • 文/不壞的土叔 我叫張陵掂器,是天一觀的道長亚皂。 經常有香客問我,道長国瓮,這世上最難降的妖魔是什么孕讳? 我笑而不...
    開封第一講書人閱讀 58,851評論 1 295
  • 正文 為了忘掉前任,我火速辦了婚禮巍膘,結果婚禮上厂财,老公的妹妹穿的比我還像新娘。我一直安慰自己峡懈,他們只是感情好璃饱,可當我...
    茶點故事閱讀 67,868評論 6 392
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著肪康,像睡著了一般荚恶。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上磷支,一...
    開封第一講書人閱讀 51,688評論 1 305
  • 那天谒撼,我揣著相機與錄音,去河邊找鬼雾狈。 笑死廓潜,一個胖子當著我的面吹牛,可吹牛的內容都是我干的善榛。 我是一名探鬼主播辩蛋,決...
    沈念sama閱讀 40,414評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼移盆!你這毒婦竟也來了悼院?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 39,319評論 0 276
  • 序言:老撾萬榮一對情侶失蹤咒循,失蹤者是張志新(化名)和其女友劉穎据途,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體叙甸,經...
    沈念sama閱讀 45,775評論 1 315
  • 正文 獨居荒郊野嶺守林人離奇死亡颖医,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 37,945評論 3 336
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了蚁署。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片便脊。...
    茶點故事閱讀 40,096評論 1 350
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出哪痰,到底是詐尸還是另有隱情遂赠,我是刑警寧澤,帶...
    沈念sama閱讀 35,789評論 5 346
  • 正文 年R本政府宣布晌杰,位于F島的核電站跷睦,受9級特大地震影響,放射性物質發(fā)生泄漏肋演。R本人自食惡果不足惜抑诸,卻給世界環(huán)境...
    茶點故事閱讀 41,437評論 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望爹殊。 院中可真熱鬧蜕乡,春花似錦、人聲如沸梗夸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,993評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽反症。三九已至辛块,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間铅碍,已是汗流浹背润绵。 一陣腳步聲響...
    開封第一講書人閱讀 33,107評論 1 271
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留胞谈,地道東北人尘盼。 一個月前我還...
    沈念sama閱讀 48,308評論 3 372
  • 正文 我出身青樓,卻偏偏與公主長得像呜魄,于是被迫代替她去往敵國和親悔叽。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 45,037評論 2 355

推薦閱讀更多精彩內容

  • 1.簡介 Apache Shiro是Java的一個安全框架爵嗅。功能強大,使用簡單的Java安全框架笨蚁,它為開發(fā)人員提供...
    H_Man閱讀 3,171評論 4 48
  • Apache Shiro Apache Shiro 是一個強大而靈活的開源安全框架睹晒,它干凈利落地處理身份認證,授權...
    羅志贇閱讀 3,228評論 1 49
  • Apache Shiro是Java的一個安全框架括细。目前伪很,使用Apache Shiro的人越來越多,因為它相當簡單奋单,...
    愛動腦的程序員閱讀 453評論 0 1
  • 一锉试、架構 要學習如何使用Shiro必須先從它的架構談起,作為一款安全框架Shiro的設計相當精妙览濒。Shiro的應用...
    ITsupuerlady閱讀 3,533評論 4 32
  • 我看著你走遠 仿佛走的不是你 是我的心呆盖。
    陸墨百沐嶼閱讀 264評論 0 1