一、Spring Security登录执行流程
1、首先用ServletFilter拦截器(AbstractAuthenticationProcessingFilter)
对应UsernamePasswordAuthenticationFilter:
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实现方式有三种:
-
使用web服务器自带的session存储(是在内存中的,可以选择持久化到本地文件中的)。
-
使用外挂的存储来保存session(比如redis、甚至数据库)
-
使用客户端的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 ); } } |