深入Spring Security-获取认证机制核心原理讲解
文/朱季谦
本文基于Springboot+Vue+Spring Security框架而写的原创笔记,demo代码参考《Spring Boot+Spring Cloud+Vue+Element项目实战:手把手教你开发权限管理系统》一书。能力有限,存在不足还请指出,本文仅当做学习笔记。
在神秘的Web系统世界里,有一座名为Spring Security的山谷,它高耸入云,蔓延千里,鸟飞不过,兽攀不了。这座山谷只有一条逼仄的道路可通。然而,若要通过这条道路前往另一头的世界,就必须先拿到一块名为token的令牌,只有这样,道路上戍守关口的士兵才会放行。
想要获得这个token令牌,必须带着一把有用的userName钥匙和password密码,进入到山谷深处,找到藏匿宝箱的山洞(数据库),若能用钥匙打开其中一个宝箱,就证明这把userName钥匙是有用的。正常情况下,宝箱里会有一块记录各种信息的木牌,包含着钥匙名和密码,其密码只有与你所携带的密码检验一致时,才能继续往前走,得到的通行信息将会在下一个关口处做认证,进而在道路尽头处的JWT魔法屋里获得加密的token令牌。
慢着,既然山谷关口处有士兵戍守,令牌又在山谷当中,在还没有获得令牌的情况下,又怎么能进入呢?
设置关口的军官早已想到这种情况,因此,他特意设置了一条自行命名为“login”的道路,没有令牌的外来人员可从这条道路进入山谷,去寻找传说中的token令牌。这条道路仅仅只能进入到山谷,却无法通过山谷到达另一头的世界,因此,它更像是一条专门为了给外来人员获取token令牌而开辟出来的道路。
这一路上都会有各种关口被士兵把守检查,只有都一一通过了,才能继续往前走,路上会遇到一位名为ProviderManager的管理员,他管理着所有信息提供者Provider......
那么,在游戏开始之前,我们先了解下戍守山谷的军官是如何设置这道权限关口的......
关口的自定义设置主要有三部分:通过钥匙username获取到宝箱方法,宝箱里的UserDetails通行信息,关口通行过往检查SecurityConfig设置。
1.宝箱里的通行信息:
/**
* 安全用户模型
*
* @author zhujiqian
* @date 2020/7/30 15:27
*/
public class JwtUserDetails implements UserDetails {
private static final long serialVersionUID = 1L; private String username;
private String password;
private String salt;
private Collection<? extends GrantedAuthority> authorities; JwtUserDetails(String username, String password, String salt, Collection<? extends GrantedAuthority> authorities) {
this.username = username;
this.password = password;
this.salt = salt;
this.authorities = authorities;
} @Override
public String getUsername() {
return username;
} @JsonIgnore
@Override
public String getPassword() {
return password;
} public String getSalt() {
return salt;
} @Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
} @JsonIgnore
@Override
public boolean isAccountNonExpired() {
return true;
} @JsonIgnore
@Override
public boolean isAccountNonLocked() {
return true;
} @JsonIgnore
@Override
public boolean isCredentialsNonExpired() {
return true;
} @JsonIgnore
@Override
public boolean isEnabled() {
return true;
} }
这里JwtUserDetails实现Spring Security 里的UserDetails类,这个类是长这样的,各个字段做了注释:
public interface UserDetails extends Serializable {
/**
*用户权限集,默认需要添加ROLE_前缀
*/
Collection<? extends GrantedAuthority> getAuthorities(); /**
*用户的加密密码,不加密会使用{noop}前缀
*/
String getPassword(); /**
*获取应用里唯一用户名
*/
String getUsername(); /**
*检查账户是否过期
*/
boolean isAccountNonExpired(); /**
*检查账户是否锁定
*/
boolean isAccountNonLocked(); /**
*检查凭证是否过期
*/
boolean isCredentialsNonExpired(); /**
*检查账户是否可用
*/
boolean isEnabled();
}
说明:JwtUserDetails自定义实现了UserDetails类,增加username和password字段,除此之外,还可以扩展存储更多用户信息,例如,身份证,手机号,邮箱等等。其作用在于可构建成一个用户安全模型,用于装载从数据库查询出来的用户及权限信息。
2.通过钥匙username获取到宝箱方法:
/**
* 用户登录认证信息查询
*
* @author zhujiqian
* @date 2020/7/30 15:30
*/
@Service
public class UserDetailsServiceImpl implements UserDetailsService { @Resource
private SysUserService sysUserService; @Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
SysUser user = sysUserService.findByName(username);
if (user == null) {
throw new UsernameNotFoundException("该用户不存在");
} Set<String> permissions = sysUserService.findPermissions(user.getName());
List<GrantedAuthority> grantedAuthorities = permissions.stream().map(AuthorityImpl::new).collect(Collectors.toList());
return new JwtUserDetails(user.getName(), user.getPassword(), user.getSalt(), grantedAuthorities);
}
}
这个UserDetailsServiceImpl类实现了Spring Security框架自带的UserDetailsService接口,它只有一个作用,即对用户登录认证信息的查询,而在它所实现的UserDetailsService接口里,只定义一个简单的loadUserByUsername方法:
public interface UserDetailsService {
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
根据loadUserByUsername方法名便能看出,这是一个可根据username用户名获取到User对象信息的方法,并返回一个UserDetails,即前头的“宝箱里的通行信息”。
综合以上代码,先用开头提到的宝箱意象做一个总结,即拿着userName这把钥匙,通过loadUserByUsername这个方法方向指引,可进入山洞(数据库),去寻找能打开的宝箱(在数据库里select查询userName对应数据),若能打开其中一个宝箱(即数据库里存在userName对应的数据),则获取宝箱里的通行信息(实现UserDetails的JwtUserDetails对象信息)。
3.关口通行过往检查设置
自定义的SecurityConfig配置类是SpringBoot整合Spring Security的关键灵魂所在。该配置信息会在springboot启动时进行加载。其中,authenticationManager() 会创建一个可用于传token做认证的AuthenticationManager对象,而AuthenticationManagerBuilder中的auth.authenticationProvider()则会创建一个provider提供者,并将userDetailsService注入进去,该userDetailsService的子类我们自定义实现了loadUserByUsername()方法。再做认证的过程中,只需找到注入了userDetailsService的provider对象,即可执行loadUserByUsername去根据username获取数据库里信息。
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter { @Resource
private UserDetailsService userDetailsService; @Override
public void configure(AuthenticationManagerBuilder auth) {
auth.authenticationProvider(new JwtAuthenticationProvider(userDetailsService));
} @Bean
@Override
public AuthenticationManager authenticationManager() throws Exception {
return super.authenticationManager();
} @Override
protected void configure(HttpSecurity httpSecurity) throws Exception {
//使用的是JWT,禁用csrf
httpSecurity.cors().and().csrf().disable()
//设置请求必须进行权限认证
.authorizeRequests()
//跨域预检请求
.antMatchers(HttpMethod.OPTIONS, "/**").permitAll()
//permitAll()表示所有用户可认证
.antMatchers( "/webjars/**").permitAll()
//首页和登录页面
.antMatchers("/").permitAll()
.antMatchers("/login").permitAll()
// 验证码
.antMatchers("/captcha.jpg**").permitAll()
// 其他所有请求需要身份认证
.anyRequest().authenticated();
//退出登录处理
httpSecurity.logout().logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler());
//token验证过滤器
httpSecurity.addFilterBefore(new JwtAuthenticationFilter(authenticationManager()), UsernamePasswordAuthenticationFilter.class);
}
}
此时,在一扇刻着“登录”二字的大门前,有一个小兵正在收拾他的包袱,准备跨过大门,踏上通往Spring Security山谷的道路。他背负着整个家族赋予的任务,需前往Security山谷,拿到token令牌,只有把它成功带回来,家族里的其他成员,才能有机会穿过这座山谷,前往另一头的神秘世界,获取到珍贵的资源。
这个小兵,便是我们这故事里的主角,我把他叫做线程,他将带着整个线程家族的希望,寻找可通往神秘系统世界的令牌。
线程把族长给予的钥匙和密码放进包袱,他回头看了一眼自己的家乡,然后挥了挥手,跨过“登录”这扇大门,勇敢地上路了。
线程来到戒备森严的security关口前,四周望了一眼,忽然发现关口旁立着一块显眼的石碑,上面刻着一些符号。他走上前一看,发现原来是当年军官设置的指令与对应的说明:
@Override
protected void configure(HttpSecurity httpSecurity) throws Exception {
//使用的是JWT,禁用csrf
httpSecurity.cors().and().csrf().disable()
//设置请求必须进行权限认证
.authorizeRequests()
//跨域预检请求
.antMatchers(HttpMethod.OPTIONS, "/**").permitAll()
//首页和登录页面
.antMatchers("/login").permitAll()
// 其他所有请求需要身份认证
.anyRequest().authenticated();
//退出登录处理
httpSecurity.logout().logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler());
//token验证过滤器
httpSecurity.addFilterBefore(new JwtAuthenticationFilter(authenticationManager()), UsernamePasswordAuthenticationFilter.class);
}
其中,permitAll()代表所有请求都可访问,当它设置成类似“.antMatchers("/login").permitAll()”的形式时,则代表该/login路径请求无需认证便可通过,,相反,代码anyRequest().authenticated()则意味着其他的所有请求都必须进行身份验证方能通过,否则,会被拒绝访问。
下面,将通过debug一步一步揭示,线程是如何认证的。
线程经过过滤器后,来到登录方法里,开始进行一系列的检查操作。
1.传入userName,password属性,封装成一个token对象。
进入到该对象里,可看到用户名会赋值给this.principal,密码赋值给this.credentials,其中setAuthenticated(false)意味着尚未进行认证。
注意一点是,UsernamePasswordAuthenticationToken继承了AbstractAuthenticationToken,而AbstractAuthenticationToken则实现Authentication,由传递关系可知,Authentication是UsernamePasswordAuthenticationToken的基类,故而UsernamePasswordAuthenticationToken是可以转换为Authentication,理解这一点,就能明白,为何接下来authenticationManager.authenticate(token)方法传进去的是UsernamePasswordAuthenticationToken,但在源码里,方法参数则为Authentication。
2.将username,password封装成token对象后,将通过Authentication authentication=authenticationManager.authenticate(token)方法进行认证,里面会执行一系列认证操作,需要看懂源码,才能知道这行代码背后藏着的密码,然,有一点是可以从表面上看懂的,即,若认证通过,将会返回认证成功的信息。
3.进入到这个AuthenticationManager里,发现该接口里只有一个方法:
Authentication authenticate(Authentication authentication)
throws AuthenticationException;
由此可知,它的具体实现,是通过实现类来操作的,它的主要实现类是ProviderManager。
正如前文提到的,ProviderManager是一个提供者的管理者,它底下管理了所有的信息提供者,首先我们得找到提供者的管理者ProviderManager,再去寻找能够匹配到的提供者,通过提供者,便可以获取到数据库里的信息,与登陆时所传入的信息做比较,若能比较成功,则证明登陆信息是对的。
4.ProviderManager类实现AuthenticationManager接口,故而重写了authenticate方法。
debug进去后
继续往下执行,通过getProviders() 获取到内部维护在List中的AuthenticationProvider遍历进行验证,若该提供者能支持传入的token进行验证,则继续往下执行。
其中,DaoAuthenticationProvider可执行本次验证。
DaoAuthenticationProvider是一个具体实现类,它继承AbstractUserDetailsAuthenticationProvider抽象类。
而AbstractUserDetailsAuthenticationProvider实现了AuthenticationProvider接口。
5.ProviderManager执行到result = provider.authenticate(authentication)时,其中provider是由AuthenticationProvider定义的,但AuthenticationProvider是一个接口,需由其子类具体实现。根据上面分析,可知,AbstractUserDetailsAuthenticationProvider会具体实现provider.authenticate(authentication)方法。debug进入到其authenticate方法当中:
5.1.第一步,先通过this.userCache.getUserFromCache(username)获取缓存里的信息(该类缓存一般是在xml文件里事先定义好,执行到这里再去获取,这种方法比较死板,很少用到吧。)
5.2 若缓存里没有UserDetails信息,将会继续往下执行,执行到retrieveUser方法,该方法的作用是,通过登录时传入的userName去数据库里做查询,若查询成功,便将数据库的User信息包装成UserDetails对象返回。注意一点是,一般新手接触到security框架,都会有一个疑问,即我登录时传入了username,是如何去获取到数据库里的用户信息的,其实,关键就是在这个方法里。
5.3 点击跳转到该方法,发现这是一个抽象方法,故而其具体实现将在子类中进行。
5.4 点击进入到其子类实现的方法当中,发现会进入前面提到AbstractUserDetailsAuthenticationProvider的子类DaoAuthenticationProvider,它也是一个AuthenticationProvider,即所谓的信息提供者。在DaoAuthenticationProvider类里,实现了父类的retrieveUser方法中,其中,有一个关键的方法loadUserByUserName()。
点进loadUserByUsername()方法里,会进入到UserDetailsService接口里,该接口只有loadUserByUsername一个方法,该方法具体在子类里实现。
这个接口被我们自定义重写了,即:
在DaoAuthenticationProvider类中,调用loadUserByUserName()方法时,最终会执行我们重写的loadUserByUsername()方法,该方法将会去数据库里查询username的信息,返回SysUser对象,最后SysUser对象转换成UserDetails,返回给DaoAuthenticationProvider对象里的UserDetails,跳转如下图:
5.5 DaoAuthenticationProvider的retirieveUser执行完后,会将数据库查询到的UserDetails返回给上一层,即AbstractUserDetailsAuthenticationProvider执行的retrieveUser()方法,得到的UserDetails赋值给user。
6.接下来就是各种检查,其中,有一个检查方法需要特别关注,即
注:additionalAuthenticationChecks()方法的作用是检查密码是否一致的,前面已根据username去数据库里查询出user数据,接下来就需要在该方法检查数据库里user的密码与登录时传入的密码是否一致了。
6.1 点击additionalAuthenticationChecks()跳转到方法里,发现AbstractUserDetailsAuthenticationProvider当中的additionalAuthenticationChecks是一个抽象方法,没有具体实现,它与前面的retrieveUser()方法一样,具体实现都在AbstractUserDetailsAuthenticationProvider的子类DaoAuthenticationProvider中具体实现。
6.2.跳转进入子类实现的方法,方法当中,先通过authentication.getCredentials().toString()从token对象中获取登录时输入的密码,再通过passwordEncoder.matches(presentedPassword, userDetails.getPassword())进行比较,即拿登录的密码与数据库里取出的密码做对比,执行到这一步,若两个密码一致时,即登录的username和password能与数据库里某个username和密码匹配,则可登录成功。
7.用户名与密码都验证通过后,则可继续执行下一步操作,中间还有几个检查方法读者若感兴趣,可自行研究。最后会把user赋值给一个principalToReturn对象,然后连同authentication还有user,一块传入到createSuccessAuthentication方法当中。
8.在createSuccessAuthentication方法里,会创建一个已经认证通过的token。
点进该token对象当中,可以看到,这次的setAuthenticated设置成了true,即意味着已经认证通过。
最后,将生成一个新的token,并以Authentication对象形式返回到最开始的地方。
执行到这一步,就可以把认证通过的信息进行存储了,到这里,就完成了核心的认证部分。
深入Spring Security-获取认证机制核心原理讲解的更多相关文章
- 深入Spring Security魔幻山谷-获取认证机制核心原理讲解(新版)
文/朱季谦 本文基于Springboot+Vue+Spring Security框架而写的原创学习笔记,demo代码参考<Spring Boot+Spring Cloud+Vue+Element ...
- Spring Security 接口认证鉴权入门实践指南
目录 前言 SpringBoot 示例 SpringBoot pom.xml SpringBoot application.yml SpringBoot IndexController SpringB ...
- 最简单易懂的Spring Security 身份认证流程讲解
最简单易懂的Spring Security 身份认证流程讲解 导言 相信大伙对Spring Security这个框架又爱又恨,爱它的强大,恨它的繁琐,其实这是一个误区,Spring Security确 ...
- Spring Cloud实战 | 第九篇:Spring Cloud整合Spring Security OAuth2认证服务器统一认证自定义异常处理
本文完整代码下载点击 一. 前言 相信了解过我或者看过我之前的系列文章应该多少知道点我写这些文章包括创建 有来商城youlai-mall 这个项目的目的,想给那些真的想提升自己或者迷茫的人(包括自己- ...
- SpringBoot Spring Security 核心组件 认证流程 用户权限信息获取详细讲解
前言 Spring Security 是一个安全框架, 可以简单地认为 Spring Security 是放在用户和 Spring 应用之间的一个安全屏障, 每一个 web 请求都先要经过 Sprin ...
- Spring Security(03)——核心类简介
目录 1.1 Authentication 1.2 SecurityContextHolder 1.3 AuthenticationManager和Authentication ...
- 学习Spring Boot:(二十八)Spring Security 权限认证
前言 主要实现 Spring Security 的安全认证,结合 RESTful API 的风格,使用无状态的环境. 主要实现是通过请求的 URL ,通过过滤器来做不同的授权策略操作,为该请求提供某个 ...
- Spring Security 安全认证
Spring Boot 使用 Mybatis 依赖 <dependency> <groupId>org.mybatis.spring.boot</groupId> ...
- Spring Security自定义认证页面(动态网页解决方案+静态网页解决方案)--练气中期圆满
写在前面 上一回我们简单分析了spring security拦截器链的加载流程,我们还有一些简单的问题没有解决.如何自定义登录页面?如何通过数据库获取用户权限信息? 今天主要解决如何配置自定义认证页面 ...
随机推荐
- C# winform 弹出窗体给父窗体传值
Winform程序有很多传值的方法,抱着学习的态度.利用委托注册事件的方法,给窗体统一添加事件: 首先定义一个Frm_Base: namespace 任意 { public partial class ...
- 2020-03-27:分布式锁的问题,假如a线程在获得锁的情况下 网络波动 极端情况是断网了,这种情况是怎么处理的
福哥答案2020-04-04:超时释放锁.
- Visual Studio 2017版本15.9现在可用
本文转自 https://blogs.msdn.microsoft.com/visualstudio/2018/11/19/visual-studio-2017-version-15-9-now-av ...
- C#LeetCode刷题之#217-存在重复元素(Contains Duplicate)
问题 该文章的最新版本已迁移至个人博客[比特飞],单击链接 https://www.byteflying.com/archives/3772 访问. 给定一个整数数组,判断是否存在重复元素. 如果任何 ...
- day1 linux常用命令(一)
- 设计模式:桥接模式及代码示例、桥接模式在jdbc中的体现、注意事项
0.背景 加入一个手机分为多种款式,不同款式分为不同品牌.这些详细分类下分别进行操作. 如果传统做法,需要将手机,分为不同的子类,再继续分,基本属于一个庞大的多叉树,然后每个叶子节点进行相同名称.但是 ...
- MySQL空间函数实现位置打卡
项目需求是跟用户当前位置判断是否在给定的地理位置范围内,符合位置限制才可以打卡,其中的位置范围是一个或多个不规则的多边形.如下图,判断用户是在清华还是北大. 图形获取区域坐标 因为项目前端使用微信小程 ...
- vue调起微信JSDK 扫一扫,相册等需要注意的事项
在VUE里面需要注意的第一个问题就是路由得设置成 2:第二个就是 跳转路由的时候 不要用this.$router.push 或者this.$router.replace 前者在ios 和安卓端都调不 ...
- 【Apollo】(2)--- Apollo架构设计
Apollo架构设计 上一篇博客有讲到:[Apollo](1)--- Apollo入门介绍篇 这篇来写Apollo的核心架构设计 一.整体架构 Apollo整体架构图,已由作者宋顺已经给出: 这幅图所 ...
- JavaScript学习系列博客_9_JavaScript中的if语句、switch语句
条件判断语句 - 条件判断语句也称为if语句 - 语法一: if(条件表达式){ 语句... } - 执行流程: if语句执行时,会先对条件表达式进行求值判断, 如果值为true,则执行if后的语句 ...