JAVA安全框架Apache Shiro浅析
构建一个互联网应用,权限校验管理是很重要的安全措施,这其中主要包含:
- 认证 - 用户身份识别,即登录
- 授权 - 访问控制
- 密码加密 - 加密敏感数据防止被偷窥
- 会话管理 - 与用户相关的时间敏感的状态信息
Shiro对以上功能都进行了很好的支持,而且十分易于使用,且可运行在注入WEB, IOC, EJB等环境中。
在Shiro中,有以下几个核心概念。
1. Subject
对于一个应用的权限校验模块来说,首先要考虑的就是“当前操作的用户是谁”, “是否允许该用户进行某项操作”。因为应用接口都是基于用户的某个基本操作来构建的,所以我们构建一个应用的权限模块,是基于用户的概念来构建的。
Shiro的Subject概念就很好地基于用户概念做了抽象。Subject在Shiro中表示当前执行操作的用户,这个用户概念不仅仅是指由真实人类发起的某项请求,也可以使一个后台线程、一个后台帐户或者是其他实体对象。
例如在Shiro中,我们可以通过如下代码获得一个Subject对象:
Subject currentUser = SecurityUtils.getSubject();
在获取了Subject对象之后,就可以执行包括登录、登出、获取会话、权限校验等操作。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与应用安全数据间的桥梁。当用户需要授权登录时,Shiro使用Realms获取授权验证所必须的安全数据。所以,从本质上将,Realm实质上是一个安全相关的DAO,它封装了数据源的连接细节,并在需要时将相关数据提供给Shiro。当配置Shiro时,你必须至少指定一个Realm,用于认证和(或)授权。配置多个Realm是可以的,但是至少需要一个。
Apache Shiro提供多种认证数据源的支持,包括从JDBC, JNDI, LDAP等数据源获取认证信息。
Realms继承结构4.AuthenticationToken
AuthenticationToken
是用户Subject提交的有关登录主体和凭证的基本信息组合,这个token会通过Authenticator#authenticate(AuthenticationToken)
提交给Authenticator
,由Authenticator
执行授权和登录过程。
同时,AuthenticationToken
有UsernamePasswordToken
的默认实现,如果我们程序是基本的通过用户名+密码的登录方式,可以直接使用该类作为用户登录凭证的提交方式。
当然我们也可以通过implement 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执行具体的登录验证工作
Subject subject = securityManager.login(this, token);
……
}
securityManager又将具体的授权验证任务交给Authenticator
执行:
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的实现,对于互联网程序,通常情况我们将用户名和密码信息存储在数据库中,在做授权验证的时候,从数据库中取出用户名和密码进行比对。
下面将对Shiro的JDBCRealm进行分析。
JDBCRealm
其中定义了获取存储在数据库中的用户名|密码|盐值的相关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};
具体执行授权验证的代码为:
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();//获取数据库连接
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
内部执行配置的authenticationQuery查找指定用户名的密码信息
private String[] getPasswordForUser(Connection conn, String username) throws SQLException {
PreparedStatement ps = null;
ResultSet rs = null;
try {
执行配置的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
来计算上述的匹配关系。
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
实现,来进行用户名和密码的授权校验。
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的实现来进行密码的匹配校验
return passwordService.passwordsMatch(submittedPassword, formatted);
}
程序通过实现PasswordService
来进行自定义的密码校验过程。
public interface PasswordService {
String encryptPassword(Object plaintextPassword) throws IllegalArgumentException;
boolean passwordsMatch(Object submittedPlaintext, String encrypted);
}
Session
Shiro提供一个权限的企业级Session解决方案,可以运行在简单的命令行或者是智能手机平台上,也可以工作在大型的集群应用上。
以往我们需要使用Session的一些特性支持时,往往只能将服务部署在web容器或者EJB的Session特性。
Shiro的Session管理方案比上述两种方案都更简单,而且他可以运行在任何应用中,与容器无关。
即使我们将应用部署在Servlet或者EJB容器中,Shiro Session的许多特性仍然值得我们使用它。
- POJO/J2SE based (IoC friendly) - 在Shiro的应用框架中,所有都是基于接口的。这使得我们可以很简单快速地配置所有有关session的组件(通过JSON, YAML, Spring XML etc.)。同时我们也能通过继承Shiro的基本组件,实现我们自定义的session方案。
- Easy Custom Session Storage - 因为session对象是基于POJO的,所以session数据可以很简单方便地存储在任意数据源中。比如:文件系统、分布式缓存、关系型数据库等。
- Container-Independent Clustering - Shiro Session可以很方便地和目前成熟的缓存方案进行结合,比如 Ehcache + Terracotta, Coherence, GigaSpaces, et。这意味着我们可以通过配置session存储集群,使之和应用的部署容器无关。
- 跨客户端访问 - 当我们使用EJB或者web 的session的时候,当我们要获取session对象时,必须要在容器内才能获得。Shiro通过在统一数据源(EhCache, redis, memcache etc)获取到Session,可以实现跨客户端共享session数据。比如,一个java swing客户端可以看到和共享web客户端的同一用户的session数据。
- Event Listeners - 事件监听机制允许我们监听session生命周期的全过程,并在相应事件发生时做出对应的反应。比如我们可以再一个用户session过期时更新其对应的状态信息。
- Host Address Retention - Shiro Session保留了Session初始化时的原始IP和host name信息。这在互联网环境下是十分有用的,我们可以根据用户session的IP信息做出相应的反应和处理。
- Inactivity/Expiration Support - 我们可以通过
touch()
方法来延迟Session的过期。- Transparent Web Use - Shiro基于Servlet 2.5实现了对HttpSession的完全支持。这就意味着我们可以适用Shiro Session在web 应用中,而不用更改任何其他代码。
- Can be used for SSO - 基于以上的:基于POJO, 可存储在任意数据源, 可跨客户端共享的特性,我们可以用其实现一个基本的SSO。
在Shiro中,session的生命周期都在SessionManager中进行管理。
SessionManager类继承结构可以看到,Shiro的SecuityManager实现了SessionManager接口,使其具有了管理session的能力,在Shiro中,Session的具体管理工作,最终都实际委托给了默认的实现方案DefaultSessionManager
进行处理。
其中,有以下两个属性,在session的生命周期管理中起到了重要作用:
//session工厂类,负责创建一个新的session对象
private SessionFactory sessionFactory;
//复杂对session进行CRUD的基本操作DAO类
protected SessionDAO sessionDAO;
下面从一个session的创建、存活、过期的生命周期从源码层面来分析其设计方案。
首先,我们通过如下代码获取一个Session对象:
Subject currentUser = SecurityUtils.getSubject();
Session session = currentUser.getSession();
session.setAttribute("someKey", "aValue");
String value = (String) session.getAttribute("someKey");
而Subject的session创建过程为:
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的创建委托给sessionManager执行
Session session = this.securityManager.start(sessionContext);
this.session = decorate(session);
}
return this.session;
}
session初始化完成后,会调用sessionDAO.create()方法对新建的session进行分配sessionID和持久化的步骤。
sessionID的分配也是体现了Shiro中所有组件都使用接口的方式的设计理念,下面我们对其进行一个分析。
SessionDAO类继承结构处于最上层的SessionDAO接口定义了一个SessionDAOd的最基础的方法,包括create为新建的session分配id和持久化,readSession根据id查找session,update更新session, delete删除session,getActiveSessions获取所有正在生效的session。
而AbstractSessionDAO则在SessionDAO的基础上,实现了sessionID的分配方案。
AbstractSessionDAO通过注入不同的sessionID生成方案,我们可以对sessionID的分配方案进行自定义的差异化配置。Shiro默认实现了两种ID生成方案。
SessionIdGenerator类继承结构- 基于JAVA UUID:
public Serializable generateId(Session session) {
return UUID.randomUUID().toString();
}
- 基于
SHA1PRNG
的随机算法
继续回到SessionDAO的session持久化创建过程,通过可配置的sessionID分配方案分配完成sessionID后,会将session持久化到对应的数据源中。
这就有两种选择
- 单机共享的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
实现session的透明化管理
通过向CachingSessionDAO注入一个
CacheManager
对象,由CacheManager提供Cache的获取方案,我们可以实现将session的管理交给CacheManager。
当然我们也可以通过继承AbstractSessionDAO
,实现其中具体的session的CRUD方法,来进行自定义数据源的session管理工作。
如下,通过继承,我们成功将session持久化到了memcache数据源中。
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;
/**
* 维持一个登录会话的实现类,将会话信息存储在缓存层
*/
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的方式,来监听session的生命周期全过程,那么Shiro是怎么实现的呢?
见SessionManager的继承体系图中,AbstractSessionManager
定义了session的过期时间相关属性的设置和获取方法,而AbstractNativeSessionManager
则定义和实现了session生命周期监听器的相关功能。
//监听器列表
private Collection<SessionListener> listeners;
public Session start(SessionContext context) {
……
notifyStart(session);//session创建完毕,通知监听器
……
}
//遍历监听器列表,调用onStart方法
protected void notifyStart(Session session) {
for (SessionListener listener : this.listeners) {
listener.onStart(session);
}
}
```java
SessionListener接口定义了session的完整生命周期的对应的动作,通过实现SessionListener接口,我们可以对session的生命周期变化做出相应的动作响应。
![SessionListener](http:https://img.haomeiwen.com/i3159214/63b8a2f31f37f0b6.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
#### Session过期时间和过期策略
Session的默认过期时间在```org.apache.shiro.session.mgt.AbstractSessionManager#DEFAULT_GLOBAL_SESSION_TIMEOUT```有配置,为30min。
Session必须在校验到其已经失效时,从存储系统中进行删除,这保证了我们的session存储数据源不会随着时间的流逝,而被大量已过期的无用session占满。
为了性能考虑,SessionManager只在根据sessionID获取session时会检查session的有效状态。那么当一个会话在建立之后,从此就再也没有心得请求与服务器进行交互,此时这个session因为不会再经过有效性校验的过程了,那么该session就将一直存在于存储系统中。此时成该会话为```orphans session```,我将其以为**孤立会话**。
为了避免大量的孤立会话榨干存储资源,Shiro提供了一种定期检查的机制来对已过期的session进行删除。
当然如果我们是将session持久化到缓存数据库中去,如redis, memcache,通过缓存数据库的过期机制,可以保证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时间执行一次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,属性信息等。
在通过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对象进行代理增强,使其在属性进行了改变的时候,能够对更新相应的持久化存储数据
return createExposedSession(session, context);
}
protected Session createExposedSession(Session session, SessionContext context) {
return new DelegatingSession(this, new DefaultSessionKey(session.getId()));
}
而其中对session对象所有的查找和更新操作都是通过其sessionManager根据sessionID在数据源中进行查找得到的最新结果,并将更新结果update到数据源中。
public Collection<Object> getAttributeKeys() throws InvalidSessionException {
return sessionManager.getAttributeKeys(key);
}
所以session的每一次查找或更新都会经过一次配置的数据源的查找或更新。
Session & Subject的状态
如果我们需要构建有状态的应用程序,比如我们需要在用户首次登录成功后,维持其登录状态,在登录的有效期内都拥有其对应的访问授权权限。
Shiro使用Subject对应的Session来存储Subject的身份信息,如Subject identity(PrincipalCollection
)和认证状态(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调用),这类请求具有很大的不连续性,那么我们就不需要在session中存储Subject的授权信息;
-
如果通过某些特定渠道登录的用户需要存储授权信息,某些不需要呢。
如果我们需要实现上述所说的,某些情况下需要,某些情况下不需要存储Subject授权信息,可以实现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);
}
//实现自己的计算方案
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