我一开始对于SpringSecurity也不是很熟悉,但是Security 作为一个Spring家族中的安全管理框架,而且每个项目都有授权、认证、鉴权等功能,所以很有必要对Spring Security了解清楚。在说明各个流程之前,找了一张我觉得思路比较清晰的流程图,我只截取了其中关于认证这一部分的内容。
在入门Java后端开发的时候,登录注册的功能一般是我们最先接触到的,通过三大组件之一的Filter实现在访问路径中进行过滤并验证登录信息,可以轻松的实现登录等操作。而由上图可以清楚的看到Security的认证过程也是从Filter起步的, 然后经过各式各样的组件实现了我们的认证等操作。我们就可以顺着这个思路去了解SpringSecurity的认证过程。
在分析认证过程之前,需要先理解这三个概念,理解完之后才能更好去理解Security的认证流程。在Security中每一个用户被称作一个主体(Principal),每一个用户即一个主体,每一个主体包含了经验证后而获得系统访问权限的用户、设备或其他系统,但是在Security中并不是直接使用Principal,而是将主体信息进一步封装成为一个Authentication。
在Authentication中包括了主体权限列表、主体凭据、主体详细信息,以及主体是否验证成功等信息,其中主体携带信息可以包含当前请求的上下文。因为我们身份验证一般都是基于用户名+密码的形式进行,所以在Security中提供UsernamePasswordAuthenticationToken来默认代指这这些验证。
在Security中Authentication的流动是在各个AuthenticationProvider之间。在Security中AuthenticationProvider被称为验证过程,而一次完整的认证过程又包含了多次的验证过程。AuthenticaitonProvider的接口内容其实很简单,主要包括两个方法 验证方法(验证成功返回Authentication,验证失败则抛出异常)、是否支持验证当前的Authentication类型。
因为一次认证过程包括了多个AuthenticationProvider,所以肯定会有一个角色来管理AuthenticationProvider,这就是所谓的ProviderManager【继承AuthenticationManager】,在ProviderManage的属性定义就可以看出,所有的AuthenticationProvider都是放在一个List集合中,每次认证都去集合中进行找对应的AuthenticationProvider进行认证。除此之外还有一点需要注意的是在ProviderManager中还存在父类的AuthenticationManager,子容器校验完以后父类的AuthenticationManager也需要进一步校验。
在ProviderManager中最重要的方法莫过于继承自父类的authenticate方法,首先会去循环遍历所有的AuthenticationProvider,判断当前传入的Authentication是否能被AuthenticationProvider所判断,能够判断则调用其中的authentication方法并返回一个新的Authentication并将其内容copy(将Authentication中Detail设置到Authenticaiton的Detail中)到传入的Authentication。如果验证失败,将抛出的异常赋给对应的参数。如果返回的结果为空且父类Manger不为空,则会调父类的验证,并将返回正常的结果或者异常返回给对应的值。然后再来判断。返回结果为空则抛出异常,并在抛出之前将异常事件交给EventPublisher进行发布,反之发布验证成功的事件,并返回验证的结果。如果配置中认证成功删除密码,则需要将密码进行删除。
理解完这三个概念我们再来看看验证过程的由来。
UsernamePasswordAuthenticationFilter从字面上很容易理解为——用户名密码认证过滤器,是实现下面的AuthenticationProvider、ProviderManager这些的统一调用的地方,可以理解将其理解为一个容器,容器里面就进行各种的用户信息认证的操作。
根据源文件可以清楚的看到 UsernamePasswordAuthenticationFilter其实是继承了AbstractAuthenticationProcssingFilter,在后者的定义中,我们清晰的看到这个抽象类其实并不简单,其中包含了身份验证处理器、验证成功/失败处理器、记住我管理服务、会话验证管理等等,从这里也可以看到后面的所有操作其实都是在这个可以理解为容器的对象中进行实现。后者其实可以看到是实现GenericFiltersBean,所以本质上也就是一个Servlet过滤器。在这个抽象类的doFilter方法可以大概分为三个部分(具体内容如下图所示):调用子类的attemptAuthentication方法进行身份验证、身份校验失败后的操作、身份校验成功的操作。
身份校验失败后会首先清空线程本地 SecurityContext
中的内容,并告知RemberService中校验失败、然后调用AuthenticationFailureHandler这个扩展接口,调用自定义的认证失败的操作,所以我们可以通过继承这个接口,实现一些相应的操作,例如发送邮件、短信以及指定跳转的地址等。
身份认证成功后,会将当前的认证结果Authentication【包括了主体的权限列表、主体凭据、主体详细信息】更新到SecurityContext这个当前线程中,方便我们在后续取用户的信息。在认证成功中也有类似的扩展接口AuthenticationSuccessHandler以供我们进行不同业务的操作。这里也会调用RemberService并告知校验成功。除此之外有一个特殊的操作——把某个事件告诉的所有与这个事件相关的监听器【通过 ApplicationEventPublisher】。
除了认证成功和失败后,UsernamePasswordAuthenticationFilter就是针对性的实现了attemptAuthentication这个方法,在这个方法中主要有三个过程在上图中已经标出,这里着重说明一下第二步和第三步。
为生成的Authentication设置详细的信息——之所以需要着重分析这一步,是因为我们在这一步上可以看到获取当前请求的额外信息。转到setDetails方法中,可以看到是调用父类的AuthenticationDetailSource进行这个构建details信息,它本身是一个接口,所以具体设置肯定也是交给具体的类实现,也说明可以通过这个标准接口构建具有用户详细信息的内容。在Security中默认的WebAuthenticationDetailsSource即只会当前用户的sessionId和IP地址。所以这也不够,因此我们需要继承这两个已达到最终获取额外信息的目的——在构造方法中拿到request即可拿到我们要的信息。
在UsernamePasswordAuthenticationFilter中的第三个步骤也就是我们最开始说的AuthenticationManager进行对应的Authenticaiton的验证。
在上面我们知道AuthenticationProvider就是一次验证的过程,那么里面的具体的过程又是什么?在这个抽象类的方法中我们其实只需要分析三个方法即下图中圈出的三个方法。
其中的addtionalAuthenticaionCheck是抽象类特意扩展出的方法用于附加的认证过程。retrieveUser即检索用户,也就是在数据库中根据用户名去找到对应的用户,这两个方法都是抽象方法需要具体的实现类实现,下面会具体展开介绍。
authenticate这个方法来源接口Authentication,AbstrcutUserDetailAuthenticationProvider对其进一步的实现。首先会根据用户名称,去用户缓存中寻找,如果用户不存在,则直接去数据库中进行查找也就是调用retrieveUser这个方法,如果返回为空或查找过程中抛出异常则整体抛出异常信息。
接下来会先验证用户账号是否可用,包括是否锁定、是否不能使用以及是否过期。这些都是通过接口UserDetail调用,因此我们自己的用户对象需要用到这些验证就必须实现该接口。 验证账户信息是否可用之后,会再来调用另一个抽象方法addtionalAuthenticaionCheck进行扩展验证。我们看到源码中调用了两次判断账户可用和扩展验证,是因为用户的信息可能来源于缓存中,而缓存中的数据是有延迟的,所以在第一次失败后,会判断是否开起了缓存,开起了缓存会重新从数据库中获取用户信息再来验证。
初步验证完成以后,还要校验用户的密码是否过期,通过这些认证之后,会将用户的信息放入缓存中,然后返回成功的Authentication。
上述的这些步骤,也说明了AbstrcutUserDetailAuthenticationProvider这个抽象类默认实现了基本的验证流程, 通过继承并实现retrieveUser和additionalAuthenticationChecks两个抽象方法即可自定义核心认证过程,灵活性非常高。当然在Security中帮我们默认实现了一个实现该接口的一个对象——DaoAuthenticationProvider。
首先看其中的additionalAuthenticationChecks方法其实就是判断传来的用户密码和当前获取对象真正的密码是否一致。
其次,retrieveUser方法中其实就是通过调用接口UserDetailService中的loadUserByUsername方法获取当前用户,这个方法在我们自己实现登录的时候就需要去实现该方法找到对应的用户信息。在我们没有指定自己的数据库时,其实Security也有两个类默认的实现了这个方法——JdbcDaoImpl和InMemoryUserDetailsManager。这里说一下JdbcDaoImpl这种方式,Security有默认的表结构(在/org/springframework/security/core/userdetails/jdbc/users.ddl内),使用这种方式需要将Security中的表结构拷贝到对应数据库即可。
现在再回过头来看开篇的那张图,我们就可以很清晰的看到Security的一个认证流程(其中的PasswordEncoder是在SpringSecurity5.0版本后必须指定加密方式,不指定会直接提示There is no PasswordEncoder mapped for the id "null")。而且在分析的过程中,我们可以清楚的看到在Security中有很多的扩展,特别灵活,我们很简单的继承某些抽象类或实现接口,就可以实现用户的认证。这也告诫我们以后在设计系统的时候并不能将方法固定,应该更加灵活,让程序扩展性跟强。
参考文章/书籍:
《史上最简单的Spring Security教程(十):AuthenticationFailureHandler高级用法》
《Spring Security 之 AbstractAuthenticationProcessingFilter 源码解析》
《Spring Security实战》,陈木鑫