一、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实现方式有三种:
使用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); } }