源码分析之Spring Security 和 Shiro 请求处理流程
2019年05月06日

一、Spring Security登录执行流程


1、首先用ServletFilter拦截器(AbstractAuthenticationProcessingFilter)

对应UsernamePasswordAuthenticationFilter

  •     拦截到登录的请求(通常是form Login,比如 /login + POST )

  •     解析出登录信息principal和credentials(对应username和password),封装成Authentication(对应UsernamePasswordAuthenticationToken


2、获得AuthenticationProvider执行provider.authenticate(Authentication authentication)方法

对应DaoAuthenticationProvider:(注意,可以有多个AuthenticationProvider,包括同级的和父子级别,具体参见ProviderManager)

  •     判断是否已经认证过(username是否存在于userCache中);

  •     查询用户username是否存在,然后查询密码是否正确,如果OK,构造一个UserDetails user;

  •     重新构造一个登录成功的UsernamePasswordAuthenticationToken信息,包括调用authoritiesMapper转换user的Authorities信息。


如果不想用user+password模式,可以替换上面的UsernamePasswordAuthenticationFilter类。

替换方法为 (addFilterBefore):

httpSecurity
    .authorizeRequests()
    .antMatchers("/", "/*.html", "/**/*.css", "/**/*.js")
    .and()
    .addFilterBefore(myFilter(), BasicAuthenticationFilter.class);


二、Shiro安全控制及登录执行流程

首先,它会注册一个主 servlet filter,名字叫 ShiroFilterFactoryBean

PS:下面是我查看一次请求的所有tomcat filter的结果:

[name=corsFilter, filterClass=springweb.filter.CorsFilter], 
[name=characterEncodingFilter, filterClass=springboot.web.servlet.filter.OrderedCharacterEncodingFilter], 
[name=hiddenHttpMethodFilter, filterClass=springboot.web.servlet.filter.OrderedHiddenHttpMethodFilter], 
[name=formContentFilter, filterClass=springboot.web.servlet.filter.OrderedFormContentFilter], 
[name=requestContextFilter, filterClass=springboot.web.servlet.filter.OrderedRequestContextFilter], 
[name=delegatingFilterProxy, filterClass=springweb.filter.DelegatingFilterProxy], 
[name=xssFilter, filterClass=org.jretty.fast.core.xss.XssFilter], 
[name=webStatFilter, filterClass=com.alibaba.druid.support.http.WebStatFilter], 
[name=shiroFilter, filterClass=org.apache.shiro.spring.web.ShiroFilterFactoryBean$SpringShiroFilter]
null

可见,shiroFilter位于所有filter的末尾。

    这个shiroFilter是里面还会注册多个内部的filter(存放在list中),这些filter都会继承抽象类 PathMatchingFilter(继承自 OncePerRequestFilter )。Shiro默认注册的filter如下:

public enum DefaultFilter {

    anon(AnonymousFilter.class),
    authc(FormAuthenticationFilter.class),
    authcBasic(BasicHttpAuthenticationFilter.class),
    logout(LogoutFilter.class),
    noSessionCreation(NoSessionCreationFilter.class),
    perms(PermissionsAuthorizationFilter.class),
    port(PortFilter.class),
    rest(HttpMethodPermissionFilter.class),
    roles(RolesAuthorizationFilter.class),
    ssl(SslFilter.class),
    user(UserFilter.class);
}

    在shiro配置时,需要配置拦截的uri,以及对应的filter,例如下面的配置:

filterMap.put("/public/**", "anon");
filterMap.put("/*.html", "anon");
filterMap.put("/sys/login", "anon");
filterMap.put("/favicon.ico", "anon");

filterMap.put("/sys/logout/**", "logout");
filterMap.put("/**", "authc");

    如无filterChain的特殊配置,每个filter都是一个独立的filterChain(被shiro封装成ProxiedFilterChain),每个请求,只会被一个filterChain处理,换句话说,在如无filterChain特殊配置,每个请求只会被第一个匹配到的filter处理,后面的filter不会执行。ProxiedFilterChain的处理逻辑如下:

public void doFilter(ServletRequest request, ServletResponse response) throws IOException, ServletException {
    if (this.filters == null || this.filters.size() == this.index) {
        //we've reached the end of the wrapped chain, so invoke the original one:
        log.trace("Invoking original filter chain.");
        this.orig.doFilter(request, response);
    } else {
        log.trace("Invoking wrapped filter at index [" + this.index + "]");
        this.filters.get(this.index++).doFilter(request, response, this);
    }
}

    每个请求进来,会尝试依次匹配这些path,如果匹配了,对应的filterChain就会执行。通常filterChain里面只有一个filter,比如这个名为 anno的filter,它执行完之后,就会调用 servlet的original filter,从而执行后面的其他servlet filter,根据我前面给出的servlet filter列表,shiroFilter已经是最后一个filter,所以最终这个url就会顺利地通过shiro,然后被执行。anno filter的代码如下:

public class AnonymousFilter extends PathMatchingFilter {

    @Override
    protected boolean onPreHandle(ServletRequest request, ServletResponse response, Object mappedValue) {
        // Always return true since we allow access to anyone
        return true;
    }

}

    可见它是十分高效的,像这样的配置:filterMap.put("/public/**", "anon"),这个/public/下面的内容,会快速地通过shiro,shiro直接返回true,不会对它做任何处理。


    好了,明白上面的原理后,登录流程就容易说明了,shiro默认有一个filter,叫FormAuthenticationFilter,它会去匹配登录的uri,一旦拦截就会执行。这个FormAuthenticationFilter的流程,就不多说了,无非就是从Http Request中 提取出 username和password,然后执行login逻辑,如下所示:

protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
    AuthenticationToken token = createToken(request, response);
    if (token == null) {
        String msg = "createToken method implementation returned null. A valid non-null AuthenticationToken " +
                "must be created in order to execute a login attempt.";
        throw new IllegalStateException(msg);
    }
    try {
        Subject subject = getSubject(request, response);
        subject.login(token);
        return onLoginSuccess(token, subject, request, response);
    } catch (AuthenticationException e) {
        return onLoginFailure(token, e, request, response);
    }
}

    首先,把username和password封装成 AuthenticationToken,然后提交给 Subject实现类(DelegatingSubject)去执行login,这个方法实际上是这个类的:

public class DefaultSecurityManager extends SessionsSecurityManager {...}

    这个类会调用 RealmSecurityManager 去验证账号密码,成功之后会返回一个携带成功信息(SubjectContext)的Subject类。


    需要注意的是,Subject恐怕是Shiro中最重要的设计,DelegatingSubject类有个非常重要的方法叫 getSession(),它会调用 SessionManager 去创建或者获取session,注意这个Session是 shiro Session(org.apache.shiro.session.Session)并不是 tomcat session(org.apache.catalina.Session),shiro默认的实现类是DefaultWebSessionManager,它是将session存于客户端的cookie中,因此它跟 tomcat的session,没有一点关系。

    PS:通常的session实现方式有三种:

  1. 使用web服务器自带的session存储(是在内存中的,可以选择持久化到本地文件中的)。

  2. 使用外挂的存储来保存session(比如redis、甚至数据库)

  3. 使用客户端的cookie来保存session

    因为这个Shiro Session的设计,我还踩过一个坑:我用的Keycloak单点登录平台,它是基于服务器Session来控制的,登录时,会在服务器Session中记录登录信息,单点退出时,会通知所有服务器清除Session,然而由于Shiro使用的是自己的独立的session,它根本感知不到服务器的session状态,所以它不能单点退出。


    题外话:查看tomcat的Request源码(org.apache.catalina.connector.Request),我发现 上面shiro的Subject,就相当于 tomcat的这个 Request,例如Request有个方法也是获取session的:

public class Request implements HttpServletRequest {

    protected Session doGetSession(boolean create) {
        ...
        session = manager.findSession(requestedSessionId);
        ...
    }
    
    /**
     * @return the principal that has been authenticated for this Request.
     */
    public Principal getUserPrincipal() {
        if (userPrincipal instanceof TomcatPrincipal) {
        ...
        return userPrincipal;
    }
    
    /**
     * @return <code>true</code> if the authenticated user principal
     * possesses the specified role name.
     *
     * @param role Role name to be validated
     */
    @Override
    public boolean isUserInRole(String role) {
        Realm realm = context.getRealm();
        if (realm == null) {
            return false;
        }
        // Check for a role defined directly as a <security-role>
        return realm.hasRole(getWrapper(), userPrincipal, role);
    }
    
    @Override
    public void login(String username, String password) throws ServletException {
        getContext().getAuthenticator().login(username, password, this);
    }

    @Override
    public void logout() throws ServletException {
        getContext().getAuthenticator().logout(this);
    }
}