JAVA安全框架Apache Shiro浅析

96
zhuke
0.6 2017.08.29 21:30* 字数 3656

构建一个互联网应用,权限校验管理是很重要的安全措施,这其中主要包含:

  • 认证 - 用户身份识别,即登录
  • 授权 - 访问控制
  • 密码加密 - 加密敏感数据防止被偷窥
  • 会话管理 - 与用户相关的时间敏感的状态信息

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执行授权和登录过程。

同时,AuthenticationTokenUsernamePasswordToken的默认实现,如果我们程序是基本的通过用户名+密码的登录方式,可以直接使用该类作为用户登录凭证的提交方式。
当然我们也可以通过implement AuthenticationToken的方式来实现自定义的登录方式和特殊的必需登录数据的索取。

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来计算上述的匹配关系。

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进行处理。

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://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必须在校验到其已经失效时,从存储系统中进行删除,这保证了我们的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,属性信息等。

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对象进行代理增强,使其在属性进行了改变的时候,能够对更新相应的持久化存储数据
    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

技术笔记