JAVA安全框架Apache Shiro淺析

構(gòu)建一個(gè)互聯(lián)網(wǎng)應(yīng)用,權(quán)限校驗(yàn)管理是很重要的安全措施,這其中主要包含:

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

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

在Shiro中,有以下幾個(gè)核心概念。

1. Subject
對于一個(gè)應(yīng)用的權(quán)限校驗(yàn)?zāi)K來說,首先要考慮的就是“當(dāng)前操作的用戶是誰”, “是否允許該用戶進(jìn)行某項(xiàng)操作”。因?yàn)閼?yīng)用接口都是基于用戶的某個(gè)基本操作來構(gòu)建的,所以我們構(gòu)建一個(gè)應(yīng)用的權(quán)限模塊,是基于用戶的概念來構(gòu)建的。

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

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

Subject currentUser = SecurityUtils.getSubject();

在獲取了Subject對象之后,就可以執(zhí)行包括登錄、登出、獲取會(huì)話、權(quán)限校驗(yàn)等操作。Shiro的簡單易用的API,使得我們在程序的任何地方都能很方便地獲取當(dāng)前登錄用戶,并進(jìn)行登錄用戶的各項(xiàng)基本操作。

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,里面包含用戶信息、角色、權(quán)限、url權(quán)限信息。SecurityManager通常是單例的,因?yàn)樾陆ㄐ枰x取ini文件配置是耗時(shí)的,而且其只存儲相關(guān)配置信息。
SecurityManager則管理所有用戶的安全操作,它是Shiro框架的核心。一旦其初始化配置完成,我們就不會(huì)再調(diào)用其相關(guān)API了,而是將精力集中在了Subject相關(guān)的權(quán)限操作上了。

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充當(dāng)了Shiro與應(yīng)用安全數(shù)據(jù)間的橋梁。當(dāng)用戶需要授權(quán)登錄時(shí),Shiro使用Realms獲取授權(quán)驗(yàn)證所必須的安全數(shù)據(jù)。所以,從本質(zhì)上將,Realm實(shí)質(zhì)上是一個(gè)安全相關(guān)的DAO,它封裝了數(shù)據(jù)源的連接細(xì)節(jié),并在需要時(shí)將相關(guān)數(shù)據(jù)提供給Shiro。當(dāng)配置Shiro時(shí),你必須至少指定一個(gè)Realm,用于認(rèn)證和(或)授權(quán)。配置多個(gè)Realm是可以的,但是至少需要一個(gè)。

Apache Shiro提供多種認(rèn)證數(shù)據(jù)源的支持,包括從JDBC, JNDI, LDAP等數(shù)據(jù)源獲取認(rèn)證信息。

Realms繼承結(jié)構(gòu)

4.AuthenticationToken
AuthenticationToken是用戶Subject提交的有關(guān)登錄主體和憑證的基本信息組合,這個(gè)token會(huì)通過Authenticator#authenticate(AuthenticationToken)提交給Authenticator,由Authenticator執(zhí)行授權(quán)和登錄過程。

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

AuthenticationToken繼承結(jié)構(gòu)


下面從認(rèn)證授權(quán)的全過程,來介紹Shiro的授權(quán)認(rèn)證過程:

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();從當(dāng)前線程獲取一個(gè)Subject授權(quán)對象,如果不存在,則新建一個(gè)。

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

可以看到,這里的Subject對象信息是儲存在ThreadContext中的,那么我們對這個(gè)ThreadContext做一個(gè)簡單分析。

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

//存儲線程獨(dú)占的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í)行具體的登錄驗(yàn)證工作
        Subject subject = securityManager.login(this, token);
        ……
    }

securityManager又將具體的授權(quán)驗(yàn)證任務(wù)交給Authenticator執(zhí)行:

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

Authenticator則會(huì)查找配置的所有realms,根據(jù)realms配置的授權(quán)驗(yàn)證方案進(jìn)行授權(quán)驗(yàn)證:

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調(diào)用過程

而通過以上對Realm的分析,我們知道Shiro有多個(gè)Realm的實(shí)現(xiàn),對于互聯(lián)網(wǎng)程序,通常情況我們將用戶名和密碼信息存儲在數(shù)據(jù)庫中,在做授權(quán)驗(yàn)證的時(shí)候,從數(shù)據(jù)庫中取出用戶名和密碼進(jìn)行比對。

下面將對Shiro的JDBCRealm進(jìn)行分析。


JDBCRealm

其中定義了獲取存儲在數(shù)據(jù)庫中的用戶名|密碼|鹽值的相關(guān)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í)行授權(quán)驗(yàn)證的代碼為:

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ù)據(jù)庫連接

            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內(nèi)部執(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來計(jì)算上述的匹配關(guān)系。

CredentialsMatcher類繼承結(jié)構(gòu)
SimpleCredentialsMatcher

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

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推薦的用戶名密碼校驗(yàn)的最佳實(shí)踐,因?yàn)樗ㄟ^注入一個(gè)程序自定義的PasswordService實(shí)現(xiàn),來進(jìn)行用戶名和密碼的授權(quán)校驗(yà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;
        //調(diào)用注入的passwordService的實(shí)現(xiàn)來進(jìn)行密碼的匹配校驗(yàn)
        return passwordService.passwordsMatch(submittedPassword, formatted);
    }

程序通過實(shí)現(xiàn)PasswordService來進(jìn)行自定義的密碼校驗(yàn)過程。

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

Session

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

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

Shiro的Session管理方案比上述兩種方案都更簡單,而且他可以運(yùn)行在任何應(yīng)用中,與容器無關(guān)。

即使我們將應(yīng)用部署在Servlet或者EJB容器中,Shiro Session的許多特性仍然值得我們使用它。

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

在Shiro中,session的生命周期都在SessionManager中進(jìn)行管理。

SessionManager類繼承結(jié)構(gòu)

可以看到,Shiro的SecuityManager實(shí)現(xiàn)了SessionManager接口,使其具有了管理session的能力,在Shiro中,Session的具體管理工作,最終都實(shí)際委托給了默認(rèn)的實(shí)現(xiàn)方案DefaultSessionManager進(jìn)行處理。

DefaultSessionManager

其中,有以下兩個(gè)屬性,在session的生命周期管理中起到了重要作用:

//session工廠類,負(fù)責(zé)創(chuàng)建一個(gè)新的session對象
private SessionFactory sessionFactory;

//復(fù)雜對session進(jìn)行CRUD的基本操作DAO類
protected SessionDAO sessionDAO;  

下面從一個(gè)session的創(chuàng)建、存活、過期的生命周期從源碼層面來分析其設(shè)計(jì)方案。

首先,我們通過如下代碼獲取一個(gè)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) {
    //如果當(dāng)前Subject的session為空,且create=true,則新建一個(gè)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初始化完成后,會(huì)調(diào)用sessionDAO.create()方法對新建的session進(jìn)行分配sessionID和持久化的步驟。

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

SessionDAO類繼承結(jié)構(gòu)

處于最上層的SessionDAO接口定義了一個(gè)SessionDAOd的最基礎(chǔ)的方法,包括create為新建的session分配id和持久化,readSession根據(jù)id查找session,update更新session, delete刪除session,getActiveSessions獲取所有正在生效的session。

而AbstractSessionDAO則在SessionDAO的基礎(chǔ)上,實(shí)現(xiàn)了sessionID的分配方案。

AbstractSessionDAO

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

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

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

這就有兩種選擇

  • 單機(jī)共享的MemorySessionDAO

內(nèi)部使用一個(gè)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實(shí)現(xiàn)session的透明化管理

通過向CachingSessionDAO注入一個(gè)CacheManager對象,由CacheManager提供Cache的獲取方案,我們可以實(shí)現(xiàn)將session的管理交給CacheManager。


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

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;

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

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

    @Autowired
    private MemcachedClient client;

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

    // 過期時(shí)間(單位:秒)
    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的特性時(shí),提到我們可以通過session listener的方式,來監(jiān)聽session的生命周期全過程,那么Shiro是怎么實(shí)現(xiàn)的呢?

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

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

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

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

```java

SessionListener接口定義了session的完整生命周期的對應(yīng)的動(dòng)作,通過實(shí)現(xiàn)SessionListener接口,我們可以對session的生命周期變化做出相應(yīng)的動(dòng)作響應(yīng)。

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


#### Session過期時(shí)間和過期策略
Session的默認(rèn)過期時(shí)間在```org.apache.shiro.session.mgt.AbstractSessionManager#DEFAULT_GLOBAL_SESSION_TIMEOUT```有配置,為30min。

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

為了性能考慮,SessionManager只在根據(jù)sessionID獲取session時(shí)會(huì)檢查session的有效狀態(tài)。那么當(dāng)一個(gè)會(huì)話在建立之后,從此就再也沒有心得請求與服務(wù)器進(jìn)行交互,此時(shí)這個(gè)session因?yàn)椴粫?huì)再經(jīng)過有效性校驗(yàn)的過程了,那么該session就將一直存在于存儲系統(tǒng)中。此時(shí)成該會(huì)話為```orphans session```,我將其以為**孤立會(huì)話**。

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

當(dāng)然如果我們是將session持久化到緩存數(shù)據(jù)庫中去,如redis, memcache,通過緩存數(shù)據(jù)庫的過期機(jī)制,可以保證session的過期剔除的特性。

Shiro的默認(rèn)配置為使用```ExecutorServiceSessionValidationScheduler```來定期清理過期session,其內(nèi)部使用JDK的```ScheduledExecutorService```作為線程任務(wù)管理器來管理清理任務(wù)。

```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時(shí)間執(zhí)行一次run()定義的任務(wù)
        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();
    //將任務(wù)轉(zhuǎn)交給sessionManager進(jìn)行session校驗(yàn)
    this.sessionManager.validateSessions();
    long stopTime = System.currentTimeMillis();
    if (log.isDebugEnabled()) {
        log.debug("Session validation completed successfully in " + (stopTime - startTime) + " milliseconds.");
    }
}

Session屬性改變時(shí)的持久化過程

session對象的類繼承結(jié)構(gòu)

其中,SimpleSession存儲了session的基本屬性信息,包括sessionID,過期時(shí)間,上次訪問時(shí)間,host,屬性信息等。

SimpleSession

在通過Subject新建session時(shí),根據(jù)基本的上下文信息,新建的是一個(gè)SimpleSession簡單對象,并不具備對象持久化的相關(guān)操作。

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

但是在新建完成簡單SimpleSession完成的返回路徑中,會(huì)對SimpleSession的功能進(jìn)行增強(qiáng),這其中就用到了代理的設(shè)計(jì)模式。

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對象進(jìn)行代理增強(qiáng),使其在屬性進(jìn)行了改變的時(shí)候,能夠?qū)Ω孪鄳?yīng)的持久化存儲數(shù)據(jù)
    return createExposedSession(session, context);
}

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

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

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

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

Session & Subject的狀態(tài)

如果我們需要構(gòu)建有狀態(tài)的應(yīng)用程序,比如我們需要在用戶首次登錄成功后,維持其登錄狀態(tài),在登錄的有效期內(nèi)都擁有其對應(yīng)的訪問授權(quán)權(quán)限。
Shiro使用Subject對應(yīng)的Session來存儲Subject的身份信息,如Subject identity(PrincipalCollection)和認(rèn)證狀態(tài)(subject.isAuthenticated()),以便于在后面的連接和請求中使用。

應(yīng)用程序可以從下次請求中獲取sessionID,通過sessionID查找到Subject授權(quán)信息和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) {
        ……
    }

    //方法中會(huì)將認(rèn)證授權(quán)信息存儲在session中
    Subject loggedIn = createSubject(token, info, subject);
    
    //如果token設(shè)置了rememberme=true,且配置了rememberMeManager,則對登錄的principal加密后信息進(jìn)行保存
    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);
}

//作為一個(gè)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授權(quán)信息的session保存方式,這樣我們每次請求都需要重新進(jìn)行授權(quán)驗(yàn)證。

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

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

  • 如果是人類用戶登錄請求授權(quán),我們需要維持用戶的登錄信息,這時(shí)需要上述的Suject session特性;

  • 如果是機(jī)器后臺調(diào)用(如API調(diào)用),這類請求具有很大的不連續(xù)性,那么我們就不需要在session中存儲Subject的授權(quán)信息;

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

如果我們需要實(shí)現(xiàn)上述所說的,某些情況下需要,某些情況下不需要存儲Subject授權(quán)信息,可以實(shí)現(xiàn)SessionStorageEvaluator接口來對情況進(jìn)行自定義。

public Subject save(Subject subject) {
    //是否需要在session中存儲subject信息的計(jì)算算法
    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);
}
    //實(shí)現(xiàn)自己的計(jì)算方案
    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

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

推薦閱讀更多精彩內(nèi)容

  • 1.簡介 Apache Shiro是Java的一個(gè)安全框架。功能強(qiáng)大,使用簡單的Java安全框架,它為開發(fā)人員提供...
    H_Man閱讀 3,181評論 4 47
  • Apache Shiro Apache Shiro 是一個(gè)強(qiáng)大而靈活的開源安全框架,它干凈利落地處理身份認(rèn)證,授權(quán)...
    羅志贇閱讀 3,249評論 1 49
  • Apache Shiro是Java的一個(gè)安全框架。目前,使用Apache Shiro的人越來越多,因?yàn)樗喈?dāng)簡單,...
    愛動(dòng)腦的程序員閱讀 456評論 0 1
  • 一、架構(gòu) 要學(xué)習(xí)如何使用Shiro必須先從它的架構(gòu)談起,作為一款安全框架Shiro的設(shè)計(jì)相當(dāng)精妙。Shiro的應(yīng)用...
    ITsupuerlady閱讀 3,546評論 4 32
  • 我看著你走遠(yuǎn) 仿佛走的不是你 是我的心。
    陸墨百沐嶼閱讀 266評論 0 1