简介
网上关于Spring Security的攻略可以说是五花八门,每一篇文章似乎都不同,但是好像都有一定的道理,本文的初衷是让读者能够清晰明了的了解(本文只是初步了解)Spring Security并且能够使用它。所以线上代码的Git的地址,毕竟Talk is Cheap。代码本身屏蔽了数据存储的细节,例如redis和DB层,都是一句注释带过,本文的目的也仅仅只是探讨Spring Security,项目能够一键运行才是关键,下面是git地址需要的自取
权限认证绕不开的技术框架便是Spring Security,这是个让人又爱又恨的框架,一方面它很设计很全面,另一方面它对新手玩家也很不友好,如果没有阅读它的源码理解它的思想,很难基于它做一些扩展,达到我们实际工作中需要的效果。
关于Spring Security的概念,本文不再赘述。
如果你是以下目的来阅读本文,那么看完本文之后大概率你会对Spring Security有个初步的了解,至少,你能够知道这究竟是一个什么东西,并且知道如何去正确使用它
-
我是学生,正在学习这方面的知识,想深入了解源码
-
老板让我做一个权限认证功能,让我考虑用Spring Security,目前我还不知道这东西是什么
-
公司有项目使用的是Spring Security,需要我来维护它,我对这个框架不清楚,无从下手
快速开始
想要快速开始一个Spring Security项目非常简单,新建一个SpringBoot项目,然后引入它的starter就可以了
Maven的pom文件配置
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
</dependencies>
启动项目会在控制台日志看到一串uuid,这个东西就是我们默认什么也不配置系统为我们分配的密码,你可以理解就是Spring Security原本自带的demo
访问http://localhost:8080/login,会出现Spring Security如下的页面,用户名输入user,密码输入上面控制台打印的uuid,一个简单的Spring Security就完成啦!
到这里小伙伴们可能会有如下疑问了
-
我只是引入了jar,它就默认给我写了个页面,它的代码在哪里?
-
控制台的密码在哪里生成的?为什么用户名是user而不是其它的admin?它是在哪里去定义的这个东西
-
输入账号密码后,系统是如何校验账号密码的正确性的?
-
这看起来只是个demo,距离我想要的还差的很远,真实情况都是前后端分离,我要这个页面没有意义
基于上述的问题,让我们从源码角度一一拆分解答
前言
开始源码分析之前,我想要先明确一下,阅读Spring Security源码的重要性,对于一些其他的简单框架,我们可以先使用,再了解底层实现原理,或者我压根儿不需要了解源码和原理,能实现就可以。但是Spring Security不行,要想真正的熟练运用它,你必须对它源码有一定的深入研究,因为,它提供了很多的扩展接口,如果你不先去了解它,那么你在搜索引擎上搜索到的解决方案往往不是你想要的,或者你根本搜索不到,也无法扩展
我们先基于上面提出的问题逐一分析,基于问题来阅读源码
源码分析
从疑问入手
问题一:页面代码在哪里
先说说访问/login页面,是通过一个叫做DefaultLoginPageGeneratingFilter的拦截器实现的,它继承了Spring-Web包的GenericFilterBean类,而我们知道GenericFilterBean类实现的Filter类,其实本质上也是Servlet的过滤器,没错Spring Security的核心实现几乎都是基于过滤器来实现的
我们搜索到这个类之后,既然是过滤器,那当然是要去查看doFilter方法,这里逻辑比较简单,if的逻辑是
如果是匹配了登录url(/login)或者登录失败或者退出登录成功,那么都通过generateLoginPageHtml方法去生成html页面返回给客户端,这里generateLoginPageHtml里面就解释了我们第一个问题,页面就是通过这个Filter来生成的。至于generateLoginPageHtml方法里面东西我们不用太过关心,因为现实情况我们也不会使用它的页面
问题二:用户名密码如何生成?
第二个问题是,控制台的密码在哪里生成的?为什么用户名是user而不是其它的admin?它是在哪里去定义的这个东西,在回答这个问题前其实应该要先把问题三给解决了,因为单单通过控制台的日志去搜索,你是无法定位到具体位置的。但是我们可以换个思路,如果我能够把登录流程捋顺,知道它是在什么时候校验账号密码的,再反向去寻找其自动生成密码的逻辑,不就行了吗?这也是代码的一个小技巧吧。
问题三:登录流程是如何执行的?
先来一个经典的登录流程时序图,相信网上也有很多类似的图,但是如果不结合源码来看,初学者很难知道这些类是做什么的,更别提记住他们了。
这里我用自己的语言把核心流程的类做了一个翻译,它们依次分为过滤器、权限认证管理器、权限认证处理器、以及用户处理器,这几大角色,它们按照上述时序图所表现的一样,从前往后依次调用
用源码解答疑问
过滤器UsernamePasswordAuthenticationFilter
说到过滤器其实和上文中的DefaultLoginPageGeneratingFilter有异曲同工之妙,但是又有本质上的却别。DefaultLoginPageGeneratingFilter返回的是一个登录页面,而UsernamePasswordAuthenticationFilter却是处理用户登录流程的过滤器。我们可以看到他们都是去过滤了包含"/login"的url进行处理,如下图所示
DefaultLoginPageGeneratingFilter的过滤
UsernamePasswordAuthenticationFilter的过滤
那么它们又是如何区分各自的职责的呢?这里作者的处理是通过http的请求方式来区分的,get请求的就是返回登录首页,post请求就是去处理登录流程逻辑。这里的源码就不贴出来了,大家下来自己看源码的时候去验证一下
回到UsernamePasswordAuthenticationFilter类,它的实现还是比较简单的,首先他继承了一个叫AbstractAuthenticationProcessingFilter的抽象类,在抽象类里面去重写了doFilter,在doFilter方法里面主要是做了3件事情
-
调用子类实现的抽象方法attemptAuthentication去做认证
-
successfulAuthentication方法,登录校验成功重定向到目标url地址
-
unsuccessfulAuthentication方法,登录认证失败跳转到"/login?error"登录失败页面
基于successfulAuthentication以及unsuccessfulAuthentication方法都是基于目前的实现都是前后端没有分离的,这里不细说,后面的进阶版会基于这两个方法进行改造。这里主要讲一下核心的attemptAuthentication方法
它里面创建一个UsernamePasswordAuthenticationToken对象,然后将用户名以及密码从HttpServletRequest中取出来,放进去。这里UsernamePasswordAuthenticationToken本质上实现的是Authentication接口,这个接口是贯穿整个登录流程的一个媒介,这里我个人理解算做一个上下文Context也是可以的。
Authentication类提供了一些方法,在用户名密码登录的模式下,principal就是账号,credentials就是密码
至于authorities是账号权限相关的东西(真实项目账号都是伴随有角色以及权限功能的),这里不细讲,在后面的文章中会涉及到。
在创建了这个Authentication(携带用户名密码)后,便可以通过this.getAuthenticationManager().authenticate(authRequest)方法去调用时序图中的“权限认证管理器”ProviderManager中的authenticate方法进行权限认证。
权限认证管理器ProviderManager
ProviderManager类实现的是AuthenticationManager接口,我们先来看一下这个接口中作者的注释。
这里我总结一下,大概意思就是说,这是一个用于处理Authentication请求的处理器,authentication方法认证成功后会返回一个完全填充好的Authentication类,如果异常会抛出注释中所罗列出来的异常。
然后我们进入它的实现来分析,这段代码的逻辑也比较浅显易懂,主要关注的是Providers的这个循环,
如果provider的supports方法匹配则,调用provider.authenticate方法进行权限认证,也就是说AuthenticationManager本身不进行权限认证的基础操作,这也是为什么我把他定位叫做权限认证管理器。
权限认证处理器DaoAuthenticationProvider
在过滤器UsernamePasswordAuthenticationFilter中传入的是UsernamePasswordAuthenticationToken类型的Authentication,所以就需要找到处理这个类型的Provider。
第一次看源码的同学,我们可以跑起来debug一下,debug的位置放在AuthenticationProvider的authentication方法上
程序往下运行会跳转到AbstractUserDetailsAuthenticationProvider这个抽象类中,可以看一下这个抽象类中的supports方法,正是支持UsernamePasswordAuthenticationToken类型的Authentication
而这个抽象类只有一个子类,那就是DaoAuthenticationProvider,但其实核心的代码都是在抽象类中实现的(其实我们可以注意到Spring派系的源码有很多抽象类,它们大多都是基于设计模式中的模板方法实现的,目的就是方便扩展,无论是给使用者扩展也好,还是源码本身要升级版本也好,核心的代码是不必要进行调整的)
那么在这个抽象类中的核心方法authenticate中,我们需要关注的点是,
-
它通过retrieveUser方法获得一个UserDetails,
-
调用additionalAuthenticationChecks方法来校验用户的密码
-
然后通过createSuccessAuthentication又封装一个UsernamePasswordAuthenticationToken返回
还记得上面权限认证管理器ProviderManager方法中作者注释所说的话吗,恰好就是传入一个Authentication然后返回一个完全填充的Authentication,具体的源码如下图所示
回到retrieveUser方法中,它的核心实现就是在DaoAuthenticationProvider子类中了
到这里应该有一些搜过一些网上资料的同学会看到一些熟悉的类了,UserDetails和UserDetailsService类,网上的一些资料会教你实现UserDetails重写一些自己需要的一些定制化的属性,以及实现UserDetailService接口重写loadUserByUsername方法来从数据库或者缓存中查询用户信息返回,那么这里就是具体的调用地方了。
得到UserDetails后,这里会additionalAuthenticationChecks方法来对用户的密码进行校验,如下图所示,如果校验不通过则会抛出一个经典的异常BadCredentialsException,credentials是什么?就是密码
密码的校验这里使用了一个PasswordEncoder来校验密码是否一致,因为通常我们生产项目里,密码都是加密的,这里也提供了一个扩展,使用者可以根据自己的需求对PasswordEncoder进行不同的实现。
登录流程走到这里其实已经算是完结了,但是我们还有一个问题没解决,那就是问题二:用户名密码如何生成,这个问题的答案需要从这里默认使用的UserDetailsService中寻找,这里第一次看源码的也可以采用debug的方式,断点打在loadUserByUsername上,可以发现实际调用的是InMemoryUserDetailsManager类中的loadUserByUsername方法
用户处理器InMemoryUserDetailsManager
这里如下图所示,是从它本身的一个usersMap中通过用户名查找的用户信息,那么这个users又是在哪里初始化的呢?
users做为这个类的一个成员变量,是在这个类初始化的时候赋值的,既然是SpringBoot项目,自然是通过@Configuration来初始化的。具体实现的类是UserDetailsServiceAutoConfiguration,如下图所示
从这里可以发现user的值是通过一个名为SecurityProperties的属性类赋值的
这个propertiy的前缀是spring.security,意味着我们也可以在yml文件中手动配置,来到他的一个内部类里一切就都真相大白了,user默认是“user”,而密码是一串uuid。
请求过滤器
登录流程完结之后,返回的Authencation会以session的形式存在,当页面再次发起请求时,会对session中的内容进行校验,这里我们不再细分析这块的源码,因为真实场景我们一般不会使用这种session形式来实现,基于前后端分离的形式,我们一般都是通过Header中携带token的形式来实现,如果你对这块源码感兴趣的话,这里提供两个类可以入手,SecurityContextPersistenceFilter以及FilterSecurityInterceptor
总结
本文对Spring Security的一个基础流程的源码进行了剖析,当然要使用它,这些也还是远远不够的,但是核心的流程熟悉了之后,无非就是基于他的这一些接口和抽象类进行扩展。只要知道了原理,扩展起来就很简单了。
本来是进阶版想和这篇文章一起写完的,但是由于篇幅已经够长了,就留到下一篇进行讲解。