以项目驱动学习,以实践检验真知
前言
关于认证和授权,R之前已经写了两篇文章:
在这两篇文章中我们没有使用安全框架就搞定了认证和授权功能,并理解了其核心原理。R在之前就说过,核心原理掌握了,无论什么安全框架使用起来都会非常容易!那么本文就讲解如何使用主流的安全框架Spring Security来实现认证和授权功能。
当然,本文并不只是对框架的使用方法进行讲解,还会剖析Spring Security的源码,看到最后你就会发现你掌握了使用方法的同时,还对框架有了深度的理解!如果没有看过前两篇文章的,强烈建议先看一下,因为安全框架只是帮我们封装了一些东西,背后的原理是不会变的。
本文所有代码都放在了Github上,克隆下来即可运行!
提纲挈领
Web系统中登录认证(Authentication)的核心就是凭证机制,无论是Session
还是JWT
,都是在用户成功登录时返回给用户一个凭证,后续用户访问接口需携带凭证来标明自己的身份。后端会对需要进行认证的接口进行安全判断,若凭证没问题则代表已登录就放行接口,若凭证有问题则直接拒绝请求。这个安全判断都是放在过滤器里统一处理的:
登录认证是对用户的身份进行确认,权限授权(Authorization)是对用户能否访问某个资源进行确认,授权发生都认证之后。 认证一样,这种通用逻辑都是放在过滤器里进行的统一操作:
LoginFilter
先进行登录认证判断,认证通过后再由AuthFilter
进行权限授权判断,一层一层没问题后才会执行我们真正的业务逻辑。
Spring Security对Web系统的支持就是基于这一个个过滤器组成的过滤器链:
用户请求都会经过Servlet
的过滤器链,在之前两篇文章中我们就是通过自定义的两个过滤器实现了认证授权功能!而Spring Security也是做的同样的事完成了一系列功能:
在Servlet
过滤器链中,Spring Security向其添加了一个FilterChainProxy
过滤器,这个代理过滤器会创建一套Spring Security自定义的过滤器链,然后执行一系列过滤器。我们可以大概看一下FilterChainProxy
的大致源码:
1 | java复制代码@Override |
我们可以看一下Spring Security默认会启用多少过滤器:
这里面我们只需要重点关注两个过滤器即可:UsernamePasswordAuthenticationFilter
负责登录认证,FilterSecurityInterceptor
负责权限授权。
💡Spring Security的核心逻辑全在这一套过滤器中,过滤器里会调用各种组件完成功能,掌握了这些过滤器和组件你就掌握了Spring Security!这个框架的使用方式就是对这些过滤器和组件进行扩展。
一定要记住这句话,带着这句话去使用和理解Spring Security,你会像站在高处俯瞰,整个框架的脉络一目了然。
刚才我们总览了一下全局,现在我们就开始进行代码编写了。
要使用Spring Security肯定是要先引入依赖包(Web项目其他必备依赖我在之前文章中已讲解,这里就不过多阐述了):
1 | xml复制代码<dependency> |
依赖包导入后,Spring Security就默认提供了许多功能将整个应用给保护了起来:
📝要求经过身份验证的用户才能与应用程序进行交互
📝创建好了默认登录表单
📝生成用户名为user
的随机密码并打印在控制台上
📝CSRF
攻击防护、Session Fixation
攻击防护
📝等等等等……
在实际开发中,这些默认配置好的功能往往不符合我们的实际需求,所以我们一般会自定义一些配置。配置方式很简单,新建一个配置类即可:
1 | java复制代码@EnableWebSecurity |
在该类中重写WebSecurityConfigurerAdapter
的方法就能对Spring Security进行自定义配置。
登录认证
依赖包和配置类准备好后,接下来我们要完成的第一个功能那自然是登录认证,毕竟用户要使用我们系统第一步就是登录。之前文章介绍了Session
和JWT
两种认证方式,这里我们来用Spring Security实现这两种认证。
最简单的认证方式
不管哪种认证方式和框架,有些核心概念是不会变的,这些核心概念在安全框架中会以各种组件来体现,了解各个组件的同时功能也就跟着实现了功能。
我们系统中会有许多用户,确认当前是哪个用户正在使用我们系统就是登录认证的最终目的。这里我们就提取出了一个核心概念:当前登录用户/当前认证用户。整个系统安全都是围绕当前登录用户展开的!这个不难理解,要是当前登录用户都不能确认了,那A下了一个订单,下到了B的账户上这不就乱套了。这一概念在Spring Security中的体现就是 💡Authentication
,它存储了认证信息,代表当前登录用户。
我们在程序中如何获取并使用它呢?我们需要通过 💡SecurityContext
来获取Authentication
,看了之前文章的朋友大概就猜到了这个SecurityContext
就是我们的上下文对象!
这种在一个线程中横跨若干方法调用,需要传递的对象,我们通常称之为上下文(Context)。上下文对象是非常有必要的,否则你每个方法都得额外增加一个参数接收对象,实在太麻烦了。
这个上下文对象则是交由 💡SecurityContextHolder
进行管理,你可以在程序任何地方使用它:
1 | java复制代码Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); |
可以看到调用链路是这样的:SecurityContextHolder
👉SecurityContext
👉Authentication
。
SecurityContextHolder
原理非常简单,就是和我们之前实现的上下文对象一样,使用ThreadLocal
来保证一个线程中传递同一个对象!源码我就不贴了,具体可看之前文章写的上下文对象实现。
现在我们已经知道了Spring Security中三个核心组件:
📝Authentication
:存储了认证信息,代表当前登录用户
📝SeucirtyContext
:上下文对象,用来获取Authentication
📝SecurityContextHolder
:上下文管理对象,用来在程序任何地方获取SecurityContext
他们关系如下:
Authentication
中那三个玩意就是认证信息:
📝Principal
:用户信息,没有认证时一般是用户名,认证后一般是用户对象
📝Credentials
:用户凭证,一般是密码
📝Authorities
:用户权限
现在我们知道如何获取并使用当前登录用户了,那这个用户是怎么进行认证的呢?总不能我随便new一个就代表用户认证完毕了吧。所以我们还缺一个生成Authentication
对象的认证过程!
认证过程就是登录过程,不使用安全框架时咱们的认证过程是这样的:
查询用户数据👉判断账号密码是否正确👉正确则将用户信息存储到上下文中👉上下文中有了这个对象则代表该用户登录了
Spring Security的认证流程也是如此:
1 | java复制代码Authentication authentication = new UsernamePasswordAuthenticationToken(用户名, 用户密码, 用户的权限集合); |
和不使用安全框架一样,将认证信息放到上下文中就代表用户已登录。上面代码演示的就是Spring Security最简单的认证方式,直接将Authentication
放置到SecurityContext
中就完成认证了!
这个流程和之前获取当前登录用户的流程自然是相反的:Authentication
👉SecurityContext
👉SecurityContextHolder
。
是不是觉得,就这?这就完成认证啦?这也太简单了吧。对于Spring Security来说,这样确实就完成了认证,但对于我们来说还少了一步,那就是判断用户的账号密码是否正确。用户进行登录操作时从会传递过来账号密码,我们肯定是要查询用户数据然后判断传递过来的账号密码是否正确,只有正确了咱们才会将认证信息放到上下文对象中,不正确就直接提示错误:
1 | java复制代码// 调用service层执行判断业务逻辑 |
这样才算是一个完整的认证过程,和不使用安全框架时的流程是一样的哦,只是一些组件之前是我们自己实现的。
这里查询用户信息并校验账号密码是完全由我们自己在业务层编写所有逻辑,其实这一块Spring Security也有组件供我们使用:
AuthenticationManager认证方式
💡AuthenticationManager
就是Spring Security用于执行身份验证的组件,只需要调用它的authenticate
方法即可完成认证。Spring Security默认的认证方式就是在UsernamePasswordAuthenticationFilter
这个过滤器中调用这个组件,该过滤器负责认证逻辑。
我们要按照自己的方式使用这个组件,先在之前配置类配置一下:
1 | java复制代码@EnableWebSecurity |
这里我们写上完整的登录接口代码:
1 | java复制代码@RestController |
注意,这里流程和之前说的流程是完全一样的,只是用户身份验证改成了使用
AuthenticationManager
来进行。
AuthenticationManager
的校验逻辑非常简单:
根据用户名先查询出用户对象(没有查到则抛出异常)👉将用户对象的密码和传递过来的密码进行校验,密码不匹配则抛出异常
这个逻辑没啥好说的,再简单不过了。重点是这里每一个步骤Spring Security都提供了组件:
📝是谁执行 根据用户名查询出用户对象 逻辑的呢?用户对象数据可以存在内存中、文件中、数据库中,你得确定好怎么查才行。这一部分就是交由💡UserDetialsService
处理,该接口只有一个方法loadUserByUsername(String username)
,通过用户名查询用户对象,默认实现是在内存中查询。
📝那查询出来的 用户对象 又是什么呢?每个系统中的用户对象数据都不尽相同,咱们需要确认我们的用户数据是啥样的才行。Spring Security中的用户数据则是由💡UserDetails
来体现,该接口中提供了账号、密码等通用属性。
📝对密码进行校验大家可能会觉得比较简单,if、else
搞定,就没必要用什么组件了吧?但框架毕竟是框架考虑的比较周全,除了if、else
外还解决了密码加密的问题,这个组件就是💡PasswordEncoder
,负责密码加密与校验。
我们可以看下AuthenticationManager
校验逻辑的大概源码:
1 | java复制代码public Authentication authenticate(Authentication authentication) throws AuthenticationException { |
UserDetialsService
👉UserDetails
👉PasswordEncoder
,这三个组件Spring Security都有默认实现,这一般是满足不了我们的实际需求的,所以这里我们自己来实现这些组件!
加密器PasswordEncoder
首先是PasswordEncoder
,这个接口很简单就两个重要方法:
1 | java复制代码public interface PasswordEncoder { |
你可以实现此接口定义自己的加密规则和校验规则,不过Spring Security提供了很多加密器实现,我们这里选定一个就好。可以在之前所说的配置类里进行如下配置:
1 | java复制代码@Bean |
因为密码加密是我前面文章少数没有介绍的功能,所以这里额外提一嘴。往数据库中添加用户数据时就要将密码进行加密,否则后续进行密码校验时从数据库拿出来的还是明文密码,是无法通过校验的。比如我们有一个用户注册的接口:
1 | java复制代码@Autowired |
这样数据库中存储的密码都是已加密的了:
用户对象UserDetails
该接口就是我们所说的用户对象,它提供了用户的一些通用属性:
1 | java复制代码public interface UserDetails extends Serializable { |
实际开发中我们的用户属性各种各样,这些默认属性必然是满足不了,所以我们一般会自己实现该接口,然后设置好我们实际的用户实体对象。实现此接口要重写很多方法比较麻烦,我们可以继承Spring Security提供的org.springframework.security.core.userdetails.User
类,该类实现了UserDetails
接口帮我们省去了重写方法的工作:
1 | java复制代码public class UserDetail extends User { |
业务对象UserDetailsService
该接口很简单只有一个方法:
1 | java复制代码public interface UserDetailsService { |
咱们自己的用户业务类该接口即可完成自己的逻辑:
1 | java复制代码@Service |
AuthenticationManager
校验所调用的三个组件我们就已经做好实现了!
不知道大家注意到没有,当我们查询用户失败时或者校验密码失败时都会抛出Spring Security的自定义异常。这些异常不可能放任不管,Spring Security对于这些异常都是在ExceptionTranslationFilter
过滤器中进行处理(可以回顾一下前面的过滤器截图),而💡AuthenticationEntryPoint
则专门处理认证异常!
认证异常处理器AuthenticationEntryPoint
该接口也只有一个方法:
1 | java复制代码public interface AuthenticationEntryPoint { |
我们来自定义一个类实现我们自己的错误处理逻辑:
1 | java复制代码public class MyEntryPoint implements AuthenticationEntryPoint { |
用户传递过来账号密码👉认证校验👉异常处理,这一整套流程的组件我们就都给定义完了!现在只差最后一步,就是在Spring Security配置类里面进行一些配置,才能让这些生效。
配置
Spring Security对哪些接口进行保护、什么组件生效、某些功能是否启用等等都需要在配置类中进行配置,注意看代码注释:
1 | java复制代码@EnableWebSecurity |
其中用的最多的就是configure(HttpSecurity http)
方法,可以通过HttpSecurity
进行许多配置。当我们重写这个方法时,就已经关闭了默认的表单登录方式,然后我们再配置好启用哪些组件、指定哪些接口需要认证,就搞定了!
假设现在我们有一个/API/test
接口,在没有登录的时候调用该接口看下效果:
我们登录一下:
然后再调用测试接口:
可以看到未登录时测试接口是无法正常访问的,会按照我们在EntryPoint
中的逻辑返回错误提示。
总结和补充
有人可能会问,用AuthenticationManager
认证方式要配置好多东西啊,我就用之前说的那种最简单的方式不行吗?当然是可以的啦,用哪种方式都随便,只要完成功能都行。其实不管哪种方式我们的认证的逻辑代码一样都没少,只不过一个是我们自己业务类全部搞定,一个是可以集成框架的组件。这里也顺带再总结一下流程:
- 用户调进行登录操作,传递账号密码过来👉登录接口调用
AuthenticationManager
- 根据用户名查询出用户数据👉
UserDetailService
查询出UserDetails
- 将传递过来的密码和数据库中的密码进行对比校验👉
PasswordEncoder
- 校验通过则将认证信息存入到上下文中👉将
UserDetails
存入到Authentication
,将Authentication
存入到SecurityContext
- 如果认证失败则抛出异常👉由
AuthenticationEntryPoint
处理
刚才我们讲的认证方式都是基于session
机制,认证后Spring Security会将包含了认证信息的SecurityContext
存入到session
中,Key为HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY
。也就是说,你完全可以通过如下方式获取SecurityContext
:
1 | java复制代码SecurityContext securityContext= (SecurityContext)session.getAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY) |
当然,官方还是不推荐这样直接操作的,因为统一通过SecurityContextHolder
操作更利于管理!使用SecurityContextHolder
除了获取当前用户外,退出登录的操作也是很方便的:
1 | java复制代码@GetMapping("/logout") |
session
认证咱们就讲解到此,接下来咱们讲解JWT
的认证。
JWT集成
关于JWT
的介绍和工具类等我在前面文章已经讲的很清楚了,这里我就不额外说明了,直接带大家实现代码。
采用JWT
的方式进行认证首先做的第一步就是在配置类里禁用掉session
:
1 | java复制代码// 禁用session |
注意,这里的禁用是指Spring Security不采用
session
机制了,不代表你禁用掉了整个系统的session
功能。
然后我们再修改一下登录接口,当用户登录成功的同时,我们需要生成token
并返回给前端,这样前端才能访问其他接口时携带token
:
1 | java复制代码@Autowired |
业务层方法:
1 | java复制代码public UserVO login(LoginParam param) { |
我们执行一下登录操作:
我们可以看到登录成功时接口会返回token
,后续我们再访问其它接口时需要将token
放到请求头中。这里我们需要自定义一个认证过滤器,来对token
进行校验:
1 | java复制代码@Component |
过滤器中的逻辑和之前介绍的最简单的认证方式逻辑是一致的,每当一个请求来时我们都会校验JWT
进行认证,上下文对象中有了Authentication
后续过滤器就会知道该请求已经认证过了。
咱们这个自定义的过滤器需要替换掉Spring Security默认的认证过滤器,这样我们的过滤器才能生效,所以我们需要进行如下配置:
1 | java复制代码// 将我们自定义的认证过滤器插入到默认的认证过滤器之前 |
我们可以断点调试看一下现在的过滤器是怎样的:
可以看到我们自定义的过滤器已经在过滤器链中,因为没有启用表单认证所以UsernamePasswordAuthenticationFilter
不会生效。
携带token
访问接口时可以查看效果:
登录认证到此就讲解完毕了,接下来我们一鼓作气来实现权限授权!
权限授权
菜单权限主要是通过前端渲染,数据权限主要靠SQL
拦截,和Spring Security没太大耦合,就不多展开了。我们来梳理一下接口权限的授权的流程:
- 当一个请求过来,我们先得知道这个请求的规则,即需要怎样的权限才能访问
- 然后获取当前登录用户所拥有的权限
- 再校验当前用户是否拥有该请求的权限
- 用户拥有这个权限则正常返回数据,没有权限则拒绝请求
完成了登录认证功能后,想必大家已经有点感觉:Spring Security将流程功能分得很细,每一个小功能都会有一个组件专门去做,我们要做的就是去自定义这些组件!Spring Security针对上述流程也提供了许多组件。
Spring Security的授权发生在FilterSecurityInterceptor
过滤器中:
- 首先调用的是💡
SecurityMetadataSource
,来获取当前请求的鉴权规则 - 然后通过
Authentication
获取当前登录用户所有权限数据:💡GrantedAuthority
,这个我们前面提过,认证对象里存放这权限数据 - 再调用💡
AccessDecisionManager
来校验当前用户是否拥有该权限 - 如果有就放行接口,没有则抛出异常,该异常会被💡
AccessDeniedHandler
处理
我们可以来看一下过滤器里大概的源码:
1 | java复制代码public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { |
父类的beforeInvocation
大概源码如下:
1 | java复制代码protected InterceptorStatusToken beforeInvocation(Object object) { |
老生常谈,核心流程都是一样的。我们接下来自定义这些组件,以完成我们自己的鉴权逻辑。
鉴权规则源SecurityMetadataSource
该接口我们只需要关注一个方法:
1 | java复制代码public interface SecurityMetadataSource { |
ConfigAttribute
就是我们所说的鉴权规则,该接口只有一个方法:
1 | java复制代码public interface ConfigAttribute { |
在之前文章中我们授权的实现全是靠着资源id
,用户id
关联角色id
,角色id
关联资源id
,这样用户就相当于关联了资源,而我们接口资源在数据库中的体现是这样的:
这里还是一样,我们照样以资源id
作为权限的标记。接下咱们就来自定义SecurityMetadataSource
组件:
1 | java复制代码@Component |
注意,我们这里返回的ConfigAttribute
鉴权规则,就是我们的资源id
。
用户权限GrantedAuthority
该组件代表用户所拥有的权限,和ConfigAttribute
一样也只有一个方法,该方法返回的字符串就是代表着权限
1 | java复制代码public interface GrantedAuthority extends Serializable { |
将GrantedAuthority
和ConfigAttribute
一对比,就知道用户是否拥有某个权限了。
Spring Security对GrantedAuthority
有一个简单实现SimpleGrantedAuthority
,对咱们来说够用了,所以我们额外再新建一个实现。我们要做的就是在UserDetialsService
中,获取用户对象的同时也将权限数据查询出来:
1 | java复制代码@Override |
这样当认证完毕时,Authentication
就会拥有用户信息和权限数据了。
授权管理AccessDecisionManager
终于要来到我们真正的授权组件了,这个组件才最终决定了你有没有某个权限,该接口我们只需关注一个方法:
1 | java复制代码public interface AccessDecisionManager { |
该方法接受了这几个参数后完全能做到权限校验了,我们来实现自己的逻辑:
1 | java复制代码@Component |
授权错误处理器AccessDeniedHandler
该组件和之前的认证异常处理器一样,只有一个方法用来处理异常,只不过这个是用来处理授权异常的。我们直接来实现:
1 | java复制代码public class MyDeniedHandler implements AccessDeniedHandler { |
配置
组件都定义好了,那我们接下来就是最后一步咯,就是让这些组件生效。我们的鉴权规则源组件SecurityMetadataSource
和授权管理组件AccessDecisionManager
必须通过鉴权过滤器FilterSecurityInterceptor
来配置生效,所以我们得自己先写一个过滤器,这个过滤器的核心代码基本按照父类的写就行,主要就是属性的配置:
1 | java复制代码@Component |
过滤器定义好了,我们回到Spring Security配置类让这个过滤器插入到原有的鉴权过滤器之前就一切都搞定啦:
1 | java复制代码http.addFilterBefore(authFilter, FilterSecurityInterceptor.class); |
我们可以来看下效果,没有权限的情况下访问接口:
有权限的情况下访问接口:
总结
整个Spring Security就讲解完毕了,我们对两个过滤器、N多个组件进行了自定义实现,从而达到了我们的功能。这里做了一个思维导图方便大家理解:
别看组件这么多,认证授权的核心流程和一些概念是不会变的,什么安全框架都万变不离其宗。比如Shiro
,其中最基本的概念Subject
就代表当前用户,SubjectManager
就是用户管理器……
在我前两篇文章中有人也谈到用安全框架还不如自己手写,确实,手写可以最大灵活度按照自己的想法来(并且也不复杂),使用安全框架反而要配合框架的定式,好像被束缚了。那安全框架对比手写有什么优势呢?我觉得优势主要有如下两点:
- 一些功能开箱即用,比如Spring Security的加密器,非常方便
- 框架的定式既是束缚也是规范,无论谁接手你的项目,一看到熟悉的安全框架就能立马上手
讲解到这里就结束了,本文所有代码、SQL
语句都放在Github,克隆下来即可运行。
转载请联系公众号【RudeCrab】开启白名单
本文转载自: 掘金