背景
最近在使用SpringBoot MockMvc进行controller层的单元测试,在测试的场景中需要用户先进行登录,用户登录使用的安全框架是apache shiro,在使用的过程中发现,使用MockHttpSession无法再用户登录后获取到shiro的session。
解决过程
对于需要模拟用户登录的场景,我们一般的做法是先调用用户的登录接口,然后获取到session,然后使用同样的session进行操作,那么,自然而然想到的就是MockHttpSession,这是一个常规方案,不做详细的追溯,然后这个方案无效。因此,还是要回到源码去解决问题。
在shiro中,我们是其实是先获取到Subject,然后再从Subject中获取session的,代码如下:
Subject subject = SecurityUtils.getSubject();
getSubject()
方法实际是从一个本地线程上下文中获取Subject,
public static Subject getSubject() {
Subject subject = ThreadContext.getSubject();
if (subject == null) {
subject = (new Subject.Builder()).buildSubject();
ThreadContext.bind(subject);
}
return subject;
}
ThreadContext其实是ThreadLocal的一层包装,有兴趣的读者可以自行再继续看源代码。这里我们可以看到,如果获取不到Subject,会新建一个进行绑定,但是,在我们的场景里面,用户是先进行登录的,那么,登录之后的操作肯定是从已经绑定到的subject中获取,问题指向了Subject在何时绑定?
在考虑的时候,去shiro官网看了下单元测试的解法,这边也贴一下。shiro官网中定义了一个基类如下:
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.UnavailableSecurityManagerException;
import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.subject.support.SubjectThreadState;
import org.apache.shiro.util.LifecycleUtils;
import org.apache.shiro.util.ThreadState;
import org.junit.AfterClass;
/**
* Abstract test case enabling Shiro in test environments.
*/
public abstract class AbstractShiroTest {
private static ThreadState subjectThreadState;
public AbstractShiroTest() {
}
/**
* Allows subclasses to set the currently executing {@link Subject} instance.
*
* @param subject the Subject instance
*/
protected void setSubject(Subject subject) {
clearSubject();
subjectThreadState = createThreadState(subject);
subjectThreadState.bind();
}
protected Subject getSubject() {
return SecurityUtils.getSubject();
}
protected ThreadState createThreadState(Subject subject) {
return new SubjectThreadState(subject);
}
/**
* Clears Shiro's thread state, ensuring the thread remains clean for future test execution.
*/
protected void clearSubject() {
doClearSubject();
}
private static void doClearSubject() {
if (subjectThreadState != null) {
subjectThreadState.clear();
subjectThreadState = null;
}
}
protected static void setSecurityManager(SecurityManager securityManager) {
SecurityUtils.setSecurityManager(securityManager);
}
protected static SecurityManager getSecurityManager() {
return SecurityUtils.getSecurityManager();
}
@AfterClass
public static void tearDownShiro() {
doClearSubject();
try {
SecurityManager securityManager = getSecurityManager();
LifecycleUtils.destroy(securityManager);
} catch (UnavailableSecurityManagerException e) {
//we don't care about this when cleaning up the test environment
//(for example, maybe the subclass is a unit test and it didn't
// need a SecurityManager instance because it was using only
// mock Subject instances)
}
setSecurityManager(null);
}
}
这个基类主要的就是设置SecurityManager和Subject,在使用时,使用如下方式:
import org.apache.shiro.config.IniSecurityManagerFactory;
import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.util.Factory;
import org.junit.After;
import org.junit.BeforeClass;
import org.junit.Test;
public class ExampleShiroIntegrationTest extends AbstractShiroTest {
@BeforeClass
public static void beforeClass() {
//0. Build and set the SecurityManager used to build Subject instances used in your tests
// This typically only needs to be done once per class if your shiro.ini doesn't change,
// otherwise, you'll need to do this logic in each test that is different
Factory<SecurityManager> factory = new IniSecurityManagerFactory("classpath:test.shiro.ini");
setSecurityManager(factory.getInstance());
}
@Test
public void testSimple() {
//1. Build the Subject instance for the test to run:
Subject subjectUnderTest = new Subject.Builder(getSecurityManager()).buildSubject();
//2. Bind the subject to the current thread:
setSubject(subjectUnderTest);
//perform test logic here. Any call to
//SecurityUtils.getSubject() directly (or nested in the
//call stack) will work properly.
}
@AfterClass
public void tearDownSubject() {
//3. Unbind the subject from the current thread:
clearSubject();
}
}
这个用例在spring环境下,不需要从ini中获取SecurityManager,直接注入即可。参考这段代码,实际我们要做的是在setSubject之后,使用Subject登录,然后进行MockMvc的操作。那么testSimple函数可以这么写:
//1. Build the Subject instance for the test to run:
Subject subjectUnderTest = new Subject.Builder(getSecurityManager()).buildSubject();
//2. Bind the subject to the current thread:
setSubject(subjectUnderTest);
Subject subject = SecurityUtils.getSubject();
UserPasswordToken token = new UserPasswordToken("admin", "test");
subject.login(token);
mockMvc.perform(post("/public/auth/getVerifyCode")
.contentType(MediaType.APPLICATION_FORM_URLENCODED_VALUE)
.param("phoneNumber", "18812345678")).andDo(print())
.andExpect(status().isOk());
在调用这个测试方法的时候,我们发现实际在应用中获取到的Subject并不是我们在单元测试方法中绑定的Subject,这说明MockMvc在模拟请求时另行绑定了Subject,那么现在我们就考虑到shiro是什么时候对应用进行操作的?
答案是ShiroFilter。 ShiroFilter承担着shiro框架最核心的工作,它对servlet的filterchain进行了代理,在接受到请求时,会先执行shiro自己的filter链,再执行servlet容器的filter链。ShiroFilter继承自AbstractShiroFilter,在AbstractShiroFilter的doFilterInternal方法中,我们看到了如下的一段代码:
final ServletRequest request = prepareServletRequest(servletRequest, servletResponse, chain);
final ServletResponse response = prepareServletResponse(request, servletResponse, chain);
final Subject subject = createSubject(request, response);
熟悉的味道,CreateSubject!在shiro执行自己的链的时候,它会去创建一个Subject,这就解释了MockMvc在模拟请求时另行绑定Subject的现象。那么,createSubject具体是如何执行的呢?用户登录之后又是如何获取到对应的session呢?是这一段代码:
//Resolve an associated Session (usually based on a referenced session ID), and place it in the context before
//sending to the SubjectFactory. The SubjectFactory should not need to know how to acquire sessions as the
//process is often environment specific - better to shield the SF from these details:
context = resolveSession(context);
在resolveSession方法中会根据上下文中request的cookie获取到JSESSIONID,从而读取存储的session,进行subject的关联,最终获取sessionid的方法是在org.apache.shiro.web.session.mgt.DefaultWebSessionManager#getSessionIdCookieValue。
经过上面的分析,我们可以知道,要绑定对应的Subject,其实就是要能关联到登录的session,也就是说在MockMvc中要带上对应的Cookie,那代码可以如下:
mockMvc.perform(post("/public/auth/getVerifyCode")
.contentType(MediaType.APPLICATION_FORM_URLENCODED_VALUE)
.param("phoneNumber", "18812345678").cookie(cookie)).andDo(print())
.andExpect(status().isOk());
至于cookie如何获取,mockMvc将result print出来之后应该就一目了然了,读者可以自行尝试。
有问题,可联系wgy@live.cn。