1。认证流程分析 SpringSecurity中默认的一套登录流程是非常完善并且严谨的。但是项目需求非常多样化,很多时候,我们可能还需要对SpringSecinity登录流程进行定制,定制的前提是开发者先深刻理解SpringSecurity登录流程,然后在此基础之上,完成对登录流程的定制。本文将从头梳理SpringSecurity登录流程,并通过几个常见的登录定制案例,深刻地理解SpringSecurity登录流程。 本章涉及的主要知识点有:登录流程分析。配置多个数据源。添加登录验证码。1。1登录流程分析 要搞清楚SpringSecurity认证流程,我们得先认识与之相关的三个基本组件(Authentication对象在前面文章种己经做过介绍,这里不再赘述):AuthenticationManager、ProviderManager以及AuthenticationProvider,同时还要去了解接入认证功能的过滤器AbstractAuthenticationProcessingFilter,这四个类搞明白了,基本上认证流程也就清楚了,下面我们逐个分析一下。1。1。1AuthenticationManager 从名称上可以看出,AuthenticationManager是一个认证管理器,它定义了SpringSecurity过滤器要如何执行认证操作。AuthenticationManager在认证成功后,会返回一个Authentication对象,这个Authentication对象会被设置到SecurityContextHolder中。如果开发者不想用SpringSecurity提供的一套认证机制,那么也可以自定义认证流程,认证成功后,手动将Authentication存入SecurityContextHolder中。publicinterfaceAuthenticationManager{Authenticationauthenticate(Authenticationvar1)throwsAuthenticationException;} 从AuthenticationManager的源码中可以看到,AuthenticationManager对传入的Authentication对象进行身份认证,此时传入的Authentication参数只有用户名密码等简单的属性,如果认证成功,返回的Authentication的属性会得到完全填充,包括用户所具备的角色信息。AuthenticationManager是一个接口,它有着诸多的实现类,开发者也可以自定义AuthenticationManager的实现类,不过在实际应用中,我们使用最多的是ProviderManager,在SpringSecurity框架中,默认也是使用ProviderManager。1。1。2AuthenticationProvider SpringSecurity支持多种不同的认证方式,不同的认证方式对应不同的身份类型,AuthenticationProvider就是针对不同的身份类型执行具体的身份认证。例如,常见的DaoAuthenticationProvider用来支持用户名密码登录认证,RememberMeAuthenticationProvider用来支持记住我的认证。publicinterfaceAuthenticationProvider{Authenticationauthenticate(Authenticationvar1)throwsAuthenticationException;booleansupports(Classlt;?var1);}authenticate方法用来执行具体的身份忧证。supports方法用来判断当前AuthenticationProvider是否支持对应的身份类型。 当使用用户名密码的方式登录时,对应的AuthenticationProvider实现类是DaoAuthenticationProvider,而DaoAuthenticationProvider继承自AbstractUserDetailsAuthenticationProvider并且没有重写authenticate方法,所以具体的认证逻辑在AbstractUserDetailsAuthenticationProvider的authenticate方法中。我们就从AbstractUserDetailsAuthenticationProvider开始看起: 查看代码一开始先声明一个用户缓存对象userCache,默认情况下没有启用缓存对象。hideUserNotFoundExceptions表示是否隐藏用户名查找失败的异常,默认为true,为了确保系统安全,用户在登录失败时只会给出一个模糊提示,例如用户名或密码输入错误在SpringSecurity内部,如果用户名查找失败,则会抛出UsernameNotFoundException异常,但是该异常会被自动隐藏,转而通过一个BadCredentialsException异常来代替它,这样,开发者在处理登录失败异常时,无论是用户名输入错误还是密码输入错误,收到的总是BadCredentialsException,这样做的一个好处是可以避免新手程序员将用户名输入错误和密码输入错误两个异常分开提示。forcePrincipalAsString表示是否强制将Principal对象当成字符串来处理,默认是falser,Authentication中的Principal属性类型是一个Object,正常来说,通过Principal属性可以获取到当前登录用户对象(即UserDetails),但是如果forcePrincipalAsString设置为true,则Authentication中的Principal属性返回就是当前登录用户名,而不是用户对象。preAuthenticationChecks对象则是用于做用户状态检査,在用户认证过程中,需要检验用户状态是否正常,例如账户是否被锁定、账户是否可用、账户是否过期等。postAuthenticationChecks对象主要负责在密码校验成功后,检査密码是否过期。additionalAuthenticationChecks是一个抽象方法,主要就是校验密码,具体的实现在DaoAuthenticationProvider中。authenticate方法就是核心的校验方法了。在方法中,首先从登录数据中获取用户名,然后根据用户名去缓存中查询用户对象,如果査询不到,则根据用户名调用retrieveUser方法从数据库中加载用户;如果没有加载到用户,则抛出异常(用户不存在异常会被隐藏)。拿到用户对象之后,首先调用check方法进行用户状态检査,然后调用additionalAuthenticationChecks方法进行密码的校验操作,最后调用postAuthenticationChecks。check方法检査密码是否过期,当所有步骤都顺利完成后,调用createSuccessAuthentication方法创建一个认证后的UsernamePasswordAuthenticationToken对象并返回,认证后的对象中包含了认证主体、凭证以及角色等信息。 这就是AbstractUserDetailsAuthenticationProvider类的工作流程,有几个抽象方法是在DaoAuthenticationProvider中实现的,我们再来看一下DaoAuthenticationProvider中的定义: 查看代码首先定义了USERNOTFOUNDPASSWORD常量,这个是当用户查找失败时的默认密码;passwordEncoder是一个密码加密和比对工具,这个在后面会有专门的介绍,这里先不做过多解释;userNotFoundEncodedPassword变量则用来保存默认密码加密后的值;userDetailsService是一个用户查找工具,userDetailsService在前面己经讲过,这里不再赘述;userDetailsPasswordService则用来提供密码修改服务。在DaoAuthenticationProvider的构造方法中,默认就会指定PasswordEncoder,当然开发者也可以通过set方法自定义PasswordEncoder。additionalAuthenticationchecks方法主要进行密码校验,该方法第一个参数userDetails是从数据库中查询出来的用户对象,第二个参数authentication则是登录用户输入的参数。从这两个参数中分别提取出来用户密码,然后调用passwordEncoder。matches方法进行密码比对。retrieveUser方法则是获取用户对象的方法,具体做法就是调用UserDetailsServiceloadUserByUsername方法去数据库中查询。在retrieveUser方法中,有一个值得关注的地方。在该方法一开始,首先会调用prepareTimingAttackProtection方法,该方法的作用是使用PasswordEncoder对常量USERNOTFOUNDPASSWORD进行加密,将加密结果保存在userNotFoundEncodedPassword变量中。当根据用户名查找用户时,如果抛出了UsernameNotFoundException异常,则调用mitigateAgainstTimingAttack方法进行密码比对由有读者会说,用户都没查找到,怎么比对密码?需要注意,在调用mitigateAgainstTimingAttack方法进行密码比对时,使用了userNotFoundEncodedPassword变量作为默认密码和登录请求传来的用户密码进行比对,这是一个一开始就注定要失败的密码比对,那么为什么还要进行比对呢?这主要是为了避免旁道攻击(Sidechannelattack)。如果根据用户名査找用户失败,就直接抛出异常而不进行密码比对,那么黑客经过大量的测试,就会发现有的请求耗费时间明显小于其他请求,那么进而可以得出该请求的用户名是一个不存在的用户名(因为用户名不存在,所以不需要密码比对,进而节省时间),这样就可以获取到系统信息。为了避免这一问题,所以当用户查找失败时,也会调用mitigateAgainstTimingAttack方法进行密码比对,这样就可以迷惑黑客。createSuccessAuthentication方法则是在登录成功后,创建一个全新的UsernamePasswordAuthenticationToken对象,同时会判断是否需要进行密码升级,如果需要进行密码升级,就会在该方法中进行加密方案升級。通过对AbstractUserDetailsAuthenticationProvider和DaoAuthenticationProvider的讲解,相信你己经很明白AuthenticationProvider中的认证逻辑了。 在密码学中,旁道攻击(Sidechannelattack)又称侧信道攻击、边信道攻击。这种攻击方式不是暴力破解或者是研究加密算法的弱点。它是基于从密码系统的物理实现中获取信息,比如时间、功率消耗、电磁泄漏等,这些信息可被利用于对系统的进一步破解。1。1。3ProviderManager ProviderManager是AuthenticationManager的一个重要实现类,正在开始学习之前,我们先通过一幅图来了解一下ProviderManager和AuthenticationProvider之间的关系,如图31所示。 图31 在SpringSecurity中,由于系统可能同时支持多种不同的认证方式,例如同时支持用户名密码认证、RememberMe认证、手机号码动态认证等,而不同的认证方式对应了不同的AuthenticationProvider,所以一个完整的认证流程可能由多个AuthenticationProvider来提供。 多个AuthenticationProvider将组成一个列表,这个列表将由ProviderManager代理。换句话说,在ProviderManager中存在一个AuthenticationProvider列表,在ProviderManager中遍历列表中的每一个AuthenticationProvider去执行身份认证,最终得到认证结果。 ProviderManager本身也可以再配置一个AuthenticationManager作为parent,这样当ProviderManager认证失败之后,就可以进入到parent中再次进行认证。理论上来说,ProviderManager的parent可以是任意类型的AuthenticationManager,但是通常都是由ProviderManager来扮演parent的角色,也就是ProviderManager是ProviderManager的parent。 ProviderManager本身也可以有多个,多个ProviderManager共用同一个parent,当存在多个过滤器链的时候非常有用。当存在多个过滤器链时,不同的路径可能对应不同的认证方式,但是不同路径可能又会同时存在一些共有的认证方式,这些共有的认证方式可以在parent中统一处理。 根据上面的介绍,图32是ProviderManager和AuthenticationProvider关系图。 图32 我们重点看一下ProviderManager中的authenticate方法: 查看代码 这段源码的逻辑还是非常清晰的,我们分析一下:首先获取authentication对象的类型。分别定义当前认证过程抛出的异常、parent中认证时抛出的异常、当前认证结果以及parent中认证结果对应的变量。getProviders方法用来获取当前ProviderManager所代理的所有AuthenticationProvider对象,遍历这些AuthenticationProvider对象进行身份认证。判断当前AuthenticationProvider是否支持当前Authentication对象,要是不支持,则继续处理列表中的下一个AuthenticationProvider对象调用provider。authenticate方法进行身份认证,如果认证成功,返回认证后的Authentication对象,同时调用copyDetails方法给Authentication对象的details属性赋值,由于可能是多个AuthenticationProvider执行认证操作,所以如果抛出异常,则通过lastException变量来记录。在for循环执行完成后,如果result还是没有值,说明所有的AuthenticationProvider都认证失败,此时如果parent不为空,则调用parent的authenticate方法进行认证。接下来,如果result不为空,就将result中的凭证擦除,防止泄漏,如果使用了用户名密码的方式登录,那么所谓的擦除实际上就是将密码字段设置为null,同时将登录成功的事件发布出去(发布登录成功事件需要parentResult为null。如果parentResult不为null,表示在parent中已经认证成功了,认证成功的事件也己经在parent中发布出去了,这样会导致认证成功的事件重复发布)。如果用户认证成功,此时就将result返回,后面的代码也就不再执行了。如果前面没能返回result,说明认证失败。如果lastException为null,说明parent为null或者没有认证亦或者认证失败了但是没有抛出异常,此时构造ProviderNotFoundException异常赋值给lastException。如果parentException为null,发布认证失败事件(如果parentException不为null,则说明认证失败事件已经发布过了)。最后抛出lastException异常。 这就是ProviderManager中authenticate方法的身份认证逻辑,其他方法的源码要相对简单很多,在这里就不一一解释了, 现在,大家已经熟悉了Authentication、AuthenticationManager、AuthenticationProvider以及ProviderManager的工作原理了,接下来的问题就是这些组件如何跟登录关联起来?这就涉及一个重要的过滤器AbstractAuthenticationProcessingFilter。1。1。4AbstractAuthenticationProcessingFilter 作为SpringSecurity过滤器链中的一环,AbstractAuthenticationProcessingFilter可以用来处理任何提交给它的身份认证,图33描述了AbstractAuthenticationProcessingFilter的工作流程: 图33 图中显示的流程是一个通用的架构。 AbstractAuthenticationProcessingFilter作为一个抽象类,如果使用用户名密码的方式登录,那么它对应的实现类是UsernamePasswordAuthenticationFilter;构造出来的Authentication对象则是UsernamePasswordAuthenticationToken。至于AuthenticationManager,前面已经说过,一般情况下它的实现类就是ProviderManager,这里在ProviderManager中进行认证,认证成功就会进入认证成功的回调,否则进入认证失败的回调。因此,我们可以对上面的流程图再做进一步细化,如图34所示。 图34 前面第2章中所涉及的认证流程基本上就是这样,我们来大致梳理一下:当用户提交登录请求时,UsernamePasswordAuthenticationFilter会从当前请求HttpServletRequest中提取出登录用户名密码,然后创建出一个UsernamePasswordAuthenticationToken对象。UsernamePasswordAuthenticationToken对象将被传入ProviderManager中进行具体的认证操作。如果认证失败,则SecurityContextHolder中相关信息将被清除,登录失败回调也会被调用,如果认证成功,则会进行登录信息存储、Session并发处理、登录成功事件发布以及登录成功方法回调等操作。 这是认证的一个大致流程。接下来我们结合AbstractAuthenticationProcessingFilter和UsernamePasswordAuthenticationFilter的源码来看一下。 先来看AbstractAuthenticationProcessingFilter源码(部分核心代码): 查看代码首先通过requiresAuthentication方法来判断当前请求是不是登录认证请求,如果是认证请求,就执行接下来的认证代码;如果不是认证请求,则直接继续走剩余的过滤器即可。调用attemptAuthentication方法来获取一个经过认证后的Authentication对象,attemptAuthentication方法是一个抽象方法,具体实现在它的子类UsernamePasswordAuthenticationFilter中。认证成功后,通过sessionStrategy。onAuthentication方法来处理session并发问题。continueChainBeforeSuccessfulAuthentication变量用来判断请求是否还需要继续向下走,默认情况下该参数的值为false,即认证成功后,后续的过滤器将不再执行了。unsuccessfulAuthentication方法用来处理认证失败事宜,主要做了三件事:从SecurityContextHolder中清除数据;清除Cookie等信息;调用认证失败的回调方法。successfulAuthentication方法主要用来处理认证成功事宜,主要做了四件事:向SecurityContextHolder中存入用户信息;处理Cookie;发布认证成功事件,这个事件类型InteractiveAuthenticationSuccessEvent,表示通过一些自动交互的方式认证成功,例如通过RememberMe的方式登录;调用认证成功的回调方法。 这就是AbstractAuthenticationProcessingFilter大致上所做的事情,还有一个抽象方法attemptAuthentication是在它的继承类UsernamePasswordAuthenticationFilter中实现的,接下来我们来看下UsernamePasswordAuthenticationFilter类: 查看代码首先声明了默认情况下登录表单的用户名字段和密码字段,用户名字段的key默认是username,密码字段的key默认是password。当然,这两个字段也可以自定义,自定义的方式就是我们在SecurityConfig中配置的。usernameParameter(uname)和。passwordParameter(passwd)(参考前几节)在UsernamePasswordAuthenticationFilter过滤器构建的时候,指定了当前过滤器只用来处理登录请求,默认的登录请求是login,当然开发者也可以自行配置。接下来就是最重要的attemptAuthentication方法了,在该方法中,首先确认请求是post类型;然后通过obtainUsername和obtainPassword方法分别从请求中提取出用户名和密码,具体的提取过程就是调用request。getParameter方法;拿到登录请求传来的用户名密码之后,构造出一个authRequest,然后调用getAuthenticationManager()。authenticate方法进行认证,这就进入到我们前面所说的ProviderManager的流程中了,具体认证过程就不再赘述了。 以上就是整个认证流程。 搞懂了认证流程,那么接下来如果想要自定义一些认证方式,就会非常容易了,比如定义多个数据源、添加登录校验码等。下面,我们将通过两个案例,来活学活用上面所讲的认证流程。