Spring MockMvc模拟shiro登录

背景

最近在使用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。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值