Spring Security 解析(一) —— 授权过程
spring security 解析(一) —— 授权过程
在学习spring cloud 时,遇到了授权服务oauth 相关内容时,总是一知半解,因此决定先把spring security 、spring security oauth2 等权限、认证相关的内容、原理及设计学习并整理一遍。本系列文章就是在学习的过程中加强印象和理解所撰写的,如有侵权请告知。
项目环境:
- jdk1.8
- spring boot 2.x
- spring security 5.x
一、 一个简单的security demo
1、 自定义的userdetailsservice实现
自定义myuserdetailsuserservice类,实现 userdetailsservice 接口的 loaduserbyusername()方法,这里就简单的返回一个spring security 提供的 user 对象。为了后面方便演示spring security 的权限控制,这里使用authorityutils.commaseparatedstringtoauthoritylist("admin") 设置了user账号有一个admin的角色权限信息。实际项目中可以在这里通过访问数据库获取到用户及其角色、权限信息。
@component public class myuserdetailsuserservice implements userdetailsservice { @override public userdetails loaduserbyusername(string username) throws usernamenotfoundexception { // 不能直接使用 创建 bcryptpasswordencoder 对象来加密, 这种加密方式 没有 {bcrypt} 前缀, // 会导致在 matches 时导致获取不到加密的算法出现 // java.lang.illegalargumentexception: there is no passwordencoder mapped for the id "null" 问题 // 问题原因是 spring security5 使用 delegatingpasswordencoder(委托) 替代 nooppasswordencoder, // 并且 默认使用 bcryptpasswordencoder 加密(注意 delegatingpasswordencoder 委托加密方法bcryptpasswordencoder 加密前 添加了加密类型的前缀) https://blog.csdn.net/alinyua/article/details/80219500 return new user("user", passwordencoderfactories.createdelegatingpasswordencoder().encode("123456"), authorityutils.commaseparatedstringtoauthoritylist("admin")); } }
注意spring security 5 开始没有使用 nooppasswordencoder作为其默认的密码编码器,而是默认使用 delegatingpasswordencoder 作为其密码编码器,其 encode 方法是通过 密码编码器的名称作为前缀 + 委托各类密码编码器来实现encode的。
public string encode(charsequence rawpassword) { return "{" + this.idforencode + "}" + this.passwordencoderforencode.encode(rawpassword); }
这里的 idforencode 就是密码编码器的简略名称,可以通过
passwordencoderfactories.createdelegatingpasswordencoder()
内部实现看到默认是使用的前缀是 bcrypt 也就是 bcryptpasswordencoder
public class passwordencoderfactories { public static passwordencoder createdelegatingpasswordencoder() { string encodingid = "bcrypt"; map<string, passwordencoder> encoders = new hashmap(); encoders.put(encodingid, new bcryptpasswordencoder()); encoders.put("ldap", new ldapshapasswordencoder()); encoders.put("md4", new md4passwordencoder()); encoders.put("md5", new messagedigestpasswordencoder("md5")); encoders.put("noop", nooppasswordencoder.getinstance()); encoders.put("pbkdf2", new pbkdf2passwordencoder()); encoders.put("scrypt", new scryptpasswordencoder()); encoders.put("sha-1", new messagedigestpasswordencoder("sha-1")); encoders.put("sha-256", new messagedigestpasswordencoder("sha-256")); encoders.put("sha256", new standardpasswordencoder()); return new delegatingpasswordencoder(encodingid, encoders); } }
2、 设置spring security配置
定义springsecurityconfig 配置类,并继承websecurityconfigureradapter覆盖其configure(httpsecurity http) 方法。
@configuration @enablewebsecurity //1 public class springsecurityconfig extends websecurityconfigureradapter { @override protected void configure(httpsecurity http) throws exception { http.formlogin() //2 .and() .authorizerequests() //3 .antmatchers("/index","/").permitall() //4 .anyrequest().authenticated(); //6 } }
配置解析:
- @enablewebsecurity 查看其注解源码,主要是引用websecurityconfiguration.class 和 加入了@enableglobalauthentication 注解 ,这里就不介绍了,我们只要明白添加 @enablewebsecurity 注解将开启 security 功能。
- formlogin() 使用表单登录(默认请求地址为 /login),在spring security 5 里其实已经将旧版本默认的 httpbasic() 更换成 formlogin() 了,这里为了表明表单登录还是配置了一次。
- authorizerequests() 开始请求权限配置
- antmatchers() 使用ant风格的路径匹配,这里配置匹配 / 和 /index
- permitall() 用户可任意访问
- anyrequest() 匹配所有路径
- authenticated() 用户登录后可访问
3、 配置html 和测试接口
在 resources/static 目录下新建 index.html , 其内部定义一个访问测试接口的按钮
<!doctype html> <html lang="en" > <head> <meta charset="utf-8"> <title>欢迎</title> </head> <body> spring security 欢迎你! <p> <a href="/get_user/test">测试验证security 权限控制</a></p> </body> </html>
创建 rest 风格的获取用户信息接口
@restcontroller public class testcontroller { @getmapping("/get_user/{username}") public string getuser(@pathvariable string username){ return username; } }
4、 启动项目测试
1、访问 localhost:8080 无任何阻拦直接成功
2、点击测试验证权限控制按钮 被重定向到了 security默认的登录页面
3、使用 myuserdetailsuserservice定义的默认账户 user : 123456 进行登录后成功跳转到 /get_user 接口
二、 @enablewebsecurity 配置解析
还记得之前讲过 @enablewebsecurity 引用了 websecurityconfiguration 配置类 和 @enableglobalauthentication 注解吗? 其中 websecurityconfiguration 就是与授权相关的配置,@enableglobalauthentication 配置了 认证相关的我们下节再细讨。
首先我们查看 websecurityconfiguration 源码,可以很清楚的发现 springsecurityfilterchain() 方法。
@bean(name = abstractsecuritywebapplicationinitializer.default_filter_name) public filter springsecurityfilterchain() throws exception { boolean hasconfigurers = websecurityconfigurers != null && !websecurityconfigurers.isempty(); if (!hasconfigurers) { websecurityconfigureradapter adapter = objectobjectpostprocessor .postprocess(new websecurityconfigureradapter() { }); websecurity.apply(adapter); } return websecurity.build(); //1 }
这个方法首先会判断 websecurityconfigurers 是否为空,为空加载一个默认的 websecurityconfigureradapter对象,由于自定义的 springsecurityconfig 本身是继承 websecurityconfigureradapter对象 的,所以我们自定义的 security 配置肯定会被加载进来的(如果想要了解如何加载进来可以看下websecurityconfiguration.setfilterchainproxysecurityconfigurer() 方法)。
我们看下 websecurity.build() 方法实现 实际调用的是 abstractconfiguredsecuritybuilder.dobuild() 方法,其方法内部实现如下:
@override protected final o dobuild() throws exception { synchronized (configurers) { buildstate = buildstate.initializing; beforeinit(); init(); buildstate = buildstate.configuring; beforeconfigure(); configure(); buildstate = buildstate.building; o result = performbuild(); // 1 创建 defaultsecurityfilterchain (security filter 责任链 ) buildstate = buildstate.built; return result; } }
我们把关注点放到 performbuild() 方法,看其实现子类 httpsecurity.performbuild() 方法,其内部排序 filters 并创建了 defaultsecurityfilterchain 对象。
@override protected defaultsecurityfilterchain performbuild() throws exception { collections.sort(filters, comparator); return new defaultsecurityfilterchain(requestmatcher, filters); }
查看defaultsecurityfilterchain 的构造方法,我们可以看到有记录日志。
public defaultsecurityfilterchain(requestmatcher requestmatcher, list<filter> filters) { logger.info("creating filter chain: " + requestmatcher + ", " + filters); // 按照正常情况,我们可以看到控制台输出 这条日志 this.requestmatcher = requestmatcher; this.filters = new arraylist<>(filters); }
我们可以回头看下项目启动日志。可以看到下图明显打印了 这条日志,并且把所有 filter名都打印出来了。==(请注意这里打印的 filter 链,接下来我们的所有授权过程都是依靠这条filter 链展开 )==
那么还有个疑问: httpsecurity.performbuild() 方法中的 filters 是怎么加载的呢? 这个时候需要查看 websecurityconfigureradapter.init() 方法,这个方法内部 调用 gethttp() 方法返回 httpsecurity 对象(看到这里我们应该能想到 filters 就是这个方法中添加好了数据),具体如何加载的也就不介绍了。
public void init(final websecurity web) throws exception { final httpsecurity http = gethttp(); // 1 web.addsecurityfilterchainbuilder(http).postbuildaction(new runnable() { public void run() { filtersecurityinterceptor securityinterceptor = http .getsharedobject(filtersecurityinterceptor.class); web.securityinterceptor(securityinterceptor); } }); }
用了这么长时间解析 @enablewebsecurity ,其实最关键的一点就是创建了 defaultsecurityfilterchain 也就是我们常 security filter 责任链,接下来我们围绕这个 defaultsecurityfilterchain 中 的 filters 进行授权过程的解析。
三、 授权过程解析
security的授权过程可以理解成各种 filter 处理最终完成一个授权。那么我们再看下之前 打印的filter 链,这里为了方便,再次贴出图片
这里我们只关注以下几个重要的 filter :
- securitycontextpersistencefilter
- usernamepasswordauthenticationfilter (abstractauthenticationprocessingfilter)
- basicauthenticationfilter
- anonymousauthenticationfilter
- exceptiontranslationfilter
- filtersecurityinterceptor
1、securitycontextpersistencefilter
securitycontextpersistencefilter 这个filter的主要负责以下几件事:
- 通过 (securitycontextrepository)repo.loadcontext() 方法从请求session中获取 securitycontext(security 上下文 ,类似 applicaitoncontext ) 对象,如果请求session中没有默认创建一个 authentication(认证的关键对象,由于本节只讲授权,暂不介绍) 属性为 null 的 securitycontext 对象
- securitycontextholder.setcontext() 将 securitycontext 对象放入 securitycontextholder进行管理(securitycontextholder默认使用threadlocal 策略来存储认证信息)
- 由于在 finally 里实现 会在最后通过 securitycontextholder.clearcontext() 将 securitycontext 对象 从 securitycontextholder中清除
- 由于在 finally 里实现 会在最后通过 repo.savecontext() 将 securitycontext 对象 放入session中
httprequestresponseholder holder = new httprequestresponseholder(request, response); //从session中获取securitycontxt 对象,如果session中没有则创建一个 authtication 属性为 null 的securitycontext对象 securitycontext contextbeforechainexecution = repo.loadcontext(holder); try { // 将 securitycontext 对象放入 securitycontextholder进行管理 (securitycontextholder默认使用threadlocal 策略来存储认证信息) securitycontextholder.setcontext(contextbeforechainexecution); chain.dofilter(holder.getrequest(), holder.getresponse()); } finally { securitycontext contextafterchainexecution = securitycontextholder .getcontext(); // 将 securitycontext 对象 从 securitycontextholder中清除 securitycontextholder.clearcontext(); // 将 securitycontext 对象 放入session中 repo.savecontext(contextafterchainexecution, holder.getrequest(), holder.getresponse()); request.removeattribute(filter_applied); if (debug) { logger.debug("securitycontextholder now cleared, as request processing completed"); } }
我们在 securitycontextpersistencefilter 中打上断点,启动项目,访问 localhost:8080 , 来debug看下实现:
我们可以清楚的看到创建了一个authtication 为null 的 securitycontext对象,并且可以看到请求调用的filter链具体有哪些。接下来看下 finally 内部处理
你会发现这里的securitycontxt中的 authtication 是一个名为 anonymoususer (匿名用户)的认证信息,这是因为 请求调用到了 anonymousauthenticationfilter , security默认创建了一个匿名用户访问。
2、usernamepasswordauthenticationfilter (abstractauthenticationprocessingfilter)
看filter字面意思就知道这是一个通过获取请求中的账户密码来进行授权的filter,按照惯例,整理了这个filter的职责:
- 通过 requiresauthentication()判断 是否以post 方式请求 /login
- 调用 attemptauthentication() 方法进行认证,内部创建了 authenticated 属性为 false(即未授权)的usernamepasswordauthenticationtoken 对象, 并传递给 authenticationmanager().authenticate() 方法进行认证,认证成功后 返回一个 authenticated = true (即授权成功的)usernamepasswordauthenticationtoken 对象
- 通过 sessionstrategy.onauthentication() 将 authentication 放入session中
- 通过 successfulauthentication() 调用 authenticationsuccesshandler 的 onauthenticationsuccess 接口 进行成功处理( 可以 通过 继承 authenticationsuccesshandler 自行编写成功处理逻辑 )successfulauthentication(request, response, chain, authresult);
- 通过 unsuccessfulauthentication() 调用authenticationfailurehandler 的 onauthenticationfailure 接口 进行失败处理(可以通过继承authenticationfailurehandler 自行编写失败处理逻辑 )
我们再看下官方源码的处理逻辑:
// 1 abstractauthenticationprocessingfilter 的 dofilter 方法 public void dofilter(servletrequest req, servletresponse res, filterchain chain) throws ioexception, servletexception { httpservletrequest request = (httpservletrequest) req; httpservletresponse response = (httpservletresponse) res; // 2 判断请求地址是否是 /login 和 请求方式为 post (usernamepasswordauthenticationfilter 构造方法 确定的) if (!requiresauthentication(request, response)) { chain.dofilter(request, response); return; } authentication authresult; try { // 3 调用 子类 usernamepasswordauthenticationfilter 的 attemptauthentication 方法 // attemptauthentication 方法内部创建了 authenticated 属性为 false (即未授权)的 usernamepasswordauthenticationtoken 对象, 并传递给 authenticationmanager().authenticate() 方法进行认证, //认证成功后 返回一个 authenticated = true (即授权成功的) usernamepasswordauthenticationtoken 对象 authresult = attemptauthentication(request, response); if (authresult == null) { return; } // 4 将认证成功的 authentication 存入session中 sessionstrategy.onauthentication(authresult, request, response); } catch (internalauthenticationserviceexception failed) { // 5 认证失败后 调用 authenticationfailurehandler 的 onauthenticationfailure 接口 进行失败处理( 可以 通过 继承 authenticationfailurehandler 自行编写失败处理逻辑 ) unsuccessfulauthentication(request, response, failed); return; } catch (authenticationexception failed) { // 5 认证失败后 调用 authenticationfailurehandler 的 onauthenticationfailure 接口 进行失败处理( 可以 通过 继承 authenticationfailurehandler 自行编写失败处理逻辑 ) unsuccessfulauthentication(request, response, failed); return; } ...... // 6 认证成功后 调用 authenticationsuccesshandler 的 onauthenticationsuccess 接口 进行失败处理( 可以 通过 继承 authenticationsuccesshandler 自行编写成功处理逻辑 ) successfulauthentication(request, response, chain, authresult); }
从源码上看,整个流程其实是很清晰的:从判断是否处理,到认证,最后判断认证结果分别作出认证成功和认证失败的处理。
debug 调试下看 结果,这次我们请求 localhast:8080/get_user/test , 由于没权限会直接跳转到登录界面,我们先输入错误的账号密码,看下认证失败是否与我们总结的一致。
结果与预想时一致的,也许你会奇怪这里的提示为啥时中文,这就不得不说security 5 开始支持 中文,说明咋中国程序员在世界上越来越有地位了!!!
这次输入正确的密码, 看下返回的authtication 对象信息:
可以看到这次成功返回一个 authticated = ture ,没有密码的 user账户信息,而且还包含我们定义的一个admin权限信息。放开断点,由于security默认的成功处理器是simpleurlauthenticationsuccesshandler ,这个处理器会重定向到之前访问的地址,也就是 localhast:8080/get_user/test。 至此整个流程结束。不,我们还差一个,session,我们从浏览器cookie中看到 session:
3、basicauthenticationfilter
basicauthenticationfilter 与usernameauthticationfilter类似,不过区别还是很明显,basicauthenticationfilter 主要是从header 中获取 authorization 参数信息,然后调用认证,认证成功后最后直接访问接口,不像usernameauthticationfilter过程一样通过authenticationsuccesshandler 进行跳转。这里就不在贴代码了,想了解的同学可以直接看源码。不过有一点要注意的是,basicauthenticationfilter 的 onsuccessfulauthentication() 成功处理方法是一个空方法。
为了试验basicauthenticationfilter, 我们需要将 springsecurityconfig 中的formlogin()更换成httpbasic()以支持basicauthenticationfilter,重启项目,同样访问
localhast:8080/get_user/test,这时由于没权限访问这个接口地址,页面上会弹出一个登陆框,熟悉security4的同学一定很眼熟吧,同样,我们输入账户密码后,看下debug数据:
这时,我们就能够获取到 authorization 参数,进而解析获取到其中的账户和密码信息,进行认证,我们查看认证成功后返回的authtication对象信息其实是和usernamepasswordauthticationfilter中的一致,最后再次调用下一个filter,由于已经认证成功了会直接进入filtersecurityinterceptor 进行权限验证。
4、anonymousauthenticationfilter
这里为什么要提下 anonymousauthenticationfilter呢,主要是因为在security中不存在没有账户这一说法(这里可能描述不是很清楚,但大致意思是这样的),针对这个security官方专门指定了这个anonymousauthenticationfilter ,用于前面所有filter都认证失败的情况下,自动创建一个默认的匿名用户,拥有匿名访问权限。还记得 在讲解 securitycontextpersistencefilter 时我们看到得匿名 autication信息么?如果不记得还得回头看下哦,这里就不再叙述了。
5、exceptiontranslationfilter
exceptiontranslationfilter 其实没有做任何过滤处理,但别小看它得作用,它最大也最牛叉之处就在于它捕获authenticationexception 和accessdeniedexception,如果发生的异常是这2个异常 会调用 handlespringsecurityexception()方法进行处理。 我们模拟下 accessdeniedexception(无权限,禁止访问异常)情况,首先我们需要修改下 /get_user 接口:
- 在controller 上添加
@enableglobalmethodsecurity(prepostenabled =true) 启用security 方法级别得权限控制 - 在 接口上添加 @preauthorize("hasrole('user')") 只允许有user角色得账户访问(还记得我们默认得user 账户时admin角色么?)
@restcontroller @enableglobalmethodsecurity(prepostenabled =true) // 开启方法级别的权限控制 public class testcontroller { @preauthorize("hasrole('user')") //只允许user角色访问 @getmapping("/get_user/{username}") public string getuser(@pathvariable string username){ return username; } }
重启项目,重新访问 /get_user 接口,输入正确的账户密码,发现返回一个 403 状态的错误页面,这与我们之前将的流程时一致的。debug,看下处理:
可以明显的看到异常对象是 accessdeniedexception ,异常信息是不允许访问,我们再看下 accessdeniedexception 异常后的处理方法accessdeniedhandler.handle(),进入到了 accessdeniedhandlerimpl 的handle()方法,这个方法会先判断系统是否配置了 errorpage (错误页面),没有的话直接往 response 中设置403 状态码。
6、filtersecurityinterceptor
filtersecurityinterceptor 是整个security filter链中的最后一个,也是最重要的一个,它的主要功能就是判断认证成功的用户是否有权限访问接口,其最主要的处理方法就是 调用父类(abstractsecurityinterceptor)的 super.beforeinvocation(fi),我们来梳理下这个方法的处理流程:
- 通过 obtainsecuritymetadatasource().getattributes() 获取 当前访问地址所需权限信息
- 通过 authenticateifrequired() 获取当前访问用户的权限信息
- 通过 accessdecisionmanager.decide() 使用 投票机制判权,判权失败直接抛出 accessdeniedexception 异常
protected interceptorstatustoken beforeinvocation(object object) { ...... // 1 获取访问地址的权限信息 collection<configattribute> attributes = this.obtainsecuritymetadatasource() .getattributes(object); if (attributes == null || attributes.isempty()) { ...... return null; } ...... // 2 获取当前访问用户权限信息 authentication authenticated = authenticateifrequired(); try { // 3 默认调用affirmativebased.decide() 方法, 其内部 使用 accessdecisionvoter 对象 进行投票机制判权,判权失败直接抛出 accessdeniedexception 异常 this.accessdecisionmanager.decide(authenticated, object, attributes); } catch (accessdeniedexception accessdeniedexception) { publishevent(new authorizationfailureevent(object, attributes, authenticated, accessdeniedexception)); throw accessdeniedexception; } ...... return new interceptorstatustoken(securitycontextholder.getcontext(), false, attributes, object); }
整个流程其实看起来不复杂,主要就分3个部分,首选获取访问地址的权限信息,其次获取当前访问用户的权限信息,最后通过投票机制判断出是否有权。
三、 个人总结
整个授权流程核心的就在于这几次核心filter的处理,这里我用序列图来概况下这个授权流程
(ps: 如果图片展示不清楚,可访问项目的 github 地址)
本文介绍授权过程的代码可以访问代码仓库中的 security 模块 ,项目的github 地址 : https://github.com/bug9/spring-security
如果您对这些感兴趣,欢迎star、follow、收藏、转发给予支持!
推荐阅读
-
spring cloud config 配置中心快速实现过程解析
-
Spring Boot Security OAuth2 实现支持JWT令牌的授权服务器
-
Spring Security在标准登录表单中添加一个额外的字段
-
spring为java.util.Properties类型的属性进行赋值过程解析
-
基于Spring注解的上下文初始化过程源码解析(一)
-
C# 实现PPT 每一页转成图片过程解析
-
Mybaits 源码解析 (十二)----- Mybatis的事务如何被Spring管理?Mybatis和Spring事务中用的Connection是同一个吗?
-
Tomcat源码分析三:Tomcat启动加载过程(一)的源码解析
-
spring5 源码深度解析-----ApplicationContext容器refresh过程
-
Spring MVC源码(一) ----- 启动过程与组件初始化