構(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)證信息。
4.AuthenticationToken
AuthenticationToken
是用戶Subject提交的有關(guān)登錄主體和憑證的基本信息組合,這個(gè)token會(huì)通過Authenticator#authenticate(AuthenticationToken)
提交給Authenticator
,由Authenticator
執(zhí)行授權(quán)和登錄過程。
同時(shí),AuthenticationToken
有UsernamePasswordToken
的默認(rèn)實(shí)現(xiàn),如果我們程序是基本的通過用戶名+密碼的登錄方式,可以直接使用該類作為用戶登錄憑證的提交方式。
當(dāng)然我們也可以通過implement AuthenticationToken的方式來實(shí)現(xiàn)自定義的登錄方式和特殊的必需登錄數(shù)據(jù)的索取。
下面從認(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);
}
}
而通過以上對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;
}
在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)系。
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)行管理。
可以看到,Shiro的SecuityManager實(shí)現(xiàn)了SessionManager接口,使其具有了管理session的能力,在Shiro中,Session的具體管理工作,最終都實(shí)際委托給了默認(rèn)的實(shí)現(xiàn)方案DefaultSessionManager
進(jìn)行處理。
其中,有以下兩個(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接口定義了一個(gè)SessionDAOd的最基礎(chǔ)的方法,包括create為新建的session分配id和持久化,readSession根據(jù)id查找session,update更新session, delete刪除session,getActiveSessions獲取所有正在生效的session。
而AbstractSessionDAO則在SessionDAO的基礎(chǔ)上,實(shí)現(xiàn)了sessionID的分配方案。
通過注入不同的sessionID生成方案,我們可以對sessionID的分配方案進(jìn)行自定義的差異化配置。Shiro默認(rèn)實(shí)現(xiàn)了兩種ID生成方案。
- 基于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)。

#### 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í)的持久化過程
其中,SimpleSession
存儲了session的基本屬性信息,包括sessionID,過期時(shí)間,上次訪問時(shí)間,host,屬性信息等。
在通過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