Spring Security简介
是什么?
是一个安全管理框架
小白:“spring security安全框架不那么麻烦,学习的时候可以把整体分开,各个击破,明确自己这么做的目的是什么,整个学习过程就没那么累了“
核心功能是什么?
-
认证
-
授权
认证
spring security提供了:
还可以依靠第三方依赖来自定义
小白:“什么是认证?”
小黑:“告诉我们你是谁的过程”
小白:”细说“
小黑:“说白了就是验证你的输入的用户名密码和我们数据库中你事先注册的用户名密码进行比较,返回
true
认证成功,false
认证失败。这是核心过程,剩下的更加细节的过程,就不再这里细说了”小白:“哦,看起来也简单啊,但
spring security
提供了如此多的认证方式,看起来好复杂啊。。。”小黑:“你放心,用到什么学什么就好,百分之90的用户,只要表单认证就行,再多点就到了OAuth2认证还有CAS认证(单点登录)。默认还需要开启
RememberMe
自动认证功能,不然你浏览器关闭后又得重新登录,其他的按需学习便可”小白:“那还是很复杂,不过我已经知道以后要重点学习哪些了,谢谢”
授权
无论采用了上面哪种认证方式,都不影响在Spring Security中使用授权功能。SpringSecurity
支持基于URL的请求授权、支持方法访问授权、支持SpEL访问控制、支持域对象安全(ACL),同时也支持动态权限配置、支持RBAC权限模型等,总之,我们常见的权限管理需求,Spring Security基本上都是支持的。
小白:”简单说明,授权是什么?是你可以做什么?“
小黑:”是的,万变不离其宗,spring security亦是如此,只不过spring security默认提供了两种授权套餐罢了,而且其中一种(ACL),很少人使用“
其他
SpringSecurity
还提供了HTTP防火墙功能, 拦截大量非法请求, 防止网络攻击.
小白:”那整个学习过程就围绕着认证和授权?那这也不比shiro和sa-token高明多少呀?“
小黑:”spring security真正的利器在于他的 security,你只要使用spring security那便默认启动了大量安全策略“
小白:“具体都有哪些?”
小黑:“比如跨站攻击(跨站session攻击和跨站脚本攻击),固定会话攻击等等”
小白:“这些都什么跟什么呀?我都不知道还有这些攻击方法?”
小黑:“对的,spring security可以让你在不知道有这些攻击方式的情况下,默认启动了它自己的HTTP防火墙,而且默认是严格模式”
小白:“原来如此”
整体架构
认证和授权
认证
public interface Authentication extends Principal, Serializable {
Collection<? extends GrantedAuthority> getAuthorities();
// 获取用户密码
Object getCredentials();
// 获取用户携带的详细信息, 可能是当前请求之类等
Object getDetails();
// 用来获取当前用户,例如是一个用户名或者一个用户对象。
Object getPrincipal();
// 当前用户是否认证成功。
boolean isAuthenticated();
void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;
}
当用户使用用户名/密码登录或使用 Remember-me
登录时,都会对应一个不同的Authentication
实例。
小白:“这个类问题很大啊,需要特别关注”
小黑:“嗯,这是一个接口,说明有实现类”
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-iq7pjZ7o-1675433541463)(null)]小黑:”我给你标注出来了,spring security默认使用的那个实现类,还有几个比较重要的后续还会重点介绍“
Spring Security中的认证工作主要由AuthenticationManager
接口来负责,下面来看一下该接口的定义:
public interface AuthenticationManager {
Authentication authenticate(Authentication authentication) throws AuthenticationException;
}
AuthenticationManager
只有一个authenticate
方法可以用来做认证,该方法有三个不同的返回值:
- 返回
Authentication
,表示认证成功。 - 抛出
AuthenticationException
异常,表示用户输入了无效的凭证。 - 返回null,表示不能断定。
AuthenticationManager
最主要的实现类是ProviderManager
,ProviderManager
管理了众多的AuthenticationProvider
实例,AuthenticationProvider
有点类似于AuthenticationManager
,但是AuthenticationProvider
多了一个 supports
方法用来判断是否支持给定的Authentication
类型。
小黑:”记住这点,
Manager
类似于 集合,Provider
类似于集合的元素,所以Manager
管理着Provider
“小黑:”
Manager
的功能更多的是查找,查找合适的Provider
去执行认证过程,所以这里不会去获取账户和密码“
由于Authentication
拥有众多不同的实现类,这些不同的实现类又由不同的AuthenticationProvider
来处理,所以AuthenticationProvider
会有一个supports
方法,用来判断当前的Authentication Provider
是否支持对应的Authentication
。
在一次完整的认证流程中,可能会同时存在多个AuthenticationProvider
(例如,项目同时支持form
表单登录和短信验证码登录),多个AuthenticationProvider
统一由ProviderManager
来管理。同时,ProviderManager
具有一个可选的parent
,如果所有的 AuthenticationProvider
都认证失败,那么就会调用parent
进行认证。parent
相当于一个备用认证方式,即各个AuthenticationProvider
都无法处理认证问题的时候,就由parent
出场收拾残局。
小白:整个过程好复杂,给出的流程我也看不懂啊,怎么办?
小黑:很简单,我们抱着目的去分析源码才行,否则你将变成为了看源码而看源码,这样分析源码是一点作用也没有
小白:哦哦,我知道了,但是我要怎么抓目的呢?
小黑:你可以在大脑中想象,如果让你自己实现授权过程,你要怎么做?
小白:先略过注册过程,我会这么做:
- 收到用户的账户和密码后的请求使用过滤器拦截下来
- 读取请求中的参数,获取账户和密码,包装成单独的对象,也就是我们前面看到的
Authentication
- 去数据库中查找数据库中的账户密码
- 做匹配,匹配完成
- 如果成功保存认证信息,下次请求到来的时候,可以跟着这个认证信息判断该用户是否已登录,或者该用户是否有权限访问我们的资源(比如url或者method等)
我能想到的基本上就这些了,小黑你说spring security又是怎么做的?
小黑:嗯嗯,已经整体过程没有问题,核心点你也讲了,那我就说说 ss(spring security) 怎么实现的密码匹配的过程吧
-
获取
request
中的账号和密码。ss是这么做的-
obtainUsername() // 拿到用户名 obtainPassword() // 拿到密码 request.getParameter(this.usernameParameter) // 这个参数是常量 SPRING_SECURITY_FORM_USERNAME_KEY 也就是 字符串 username,ss默认认为前端用户名将会被存储到 username 中, 同样的 密码是 SPRING_SECURITY_FORM_PASSWORD_KEY 常量值为 password
-
ss把上面的两个变量包装成了一个对象
UsernamePasswordAuthenticationToken
,也就是你说的Authentication
对象的子类,只不过除了用户名和密码还多了权限,扩展属性等
-
-
接着应该就是拿到数据库账户密码的过程了
- 拿着我们从请求中拿到的用户名和密码到ss中认证,也就是调用
AbstractUserDetailsAuthenticationProvider
的authenticate
的方法,该方法的authentication
参数里面就存储着客户的用户密码 - 接着调用
DaoAuthenticationProvider.retrieveUser
方法从内存/数据库中读取用户密码
- 拿着我们从请求中拿到的用户名和密码到ss中认证,也就是调用
小白:”诶,
retrieveUser
上面的代码为什么会有红框框中的这两段代码调用?“小白:“而且为什么刚刚好在查询数据库用户名和代码的过程中?”
小黑:“我问你一个问题,如果我是黑客,如果想检索你数据库中是否存在
zhazha
这个用户时,我要怎么做?”小白:“不知道”
小黑:“非常简单,记录用户名密码登录到服务端然后响应给你的时间”
小白:“啊?这就能识别出来数据库中是否该用户名的数据?”
小黑:“对,在登录到响应的过程中,如果数据库中查询到
zhazha
之后,需要将黑客数据的密码进行一次PasswordEncoder
加密”小白:“嗯?为什么?”
小黑:“你没玩过MD5密码匹配么?每次匹配都是MD5之后的代码,所以黑客的密码也需要进行一次加密”
小白:“那跟时间没什么关系吧?”
小黑: ”如果是MD5可能没有关系,但如果是
PasswordEncoder
就有关系了,该类加密校验需要时间,开销很大,通常需要1秒,就这一秒的时间,黑客就能判断出来你数据库中有什么账户了“小白:”嗯?“
小黑:”对的,找到数据库中的数据,需要1s,没找到不需要1s,如此时间足以判断
zhazha
是否在我们的数据库中了“小白:”哦,确实危险,黑客不一定需要拿到密码,只要拿到用户名就可以通过撞库等方式拿到我的密码了“
小黑:”所以啊,才会在源码中看到,明明数据库中已经没有账户密码了,还要再搞个字符串进行加密,然后再把这个无关紧要的加密后密码拿去和黑客输入的密码进行匹配,此时不管匹配成功还是失败,都不会走到成功认证的函数中“
小黑:”最后,这种攻击方式很多人叫 计时攻击 是旁路攻击的一种,有人也叫边信道攻击(Side-channel attack)“
小白:”现在我们拿到了数据库中的账户密码,准备比较了?“
小黑:”是的,在
DaoAuthenticationProvider#additionalAuthenticationChecks
方法中进行了密码匹配“小白:”等等,为什么不匹配用户名?直接匹配密码?“
小黑:”你傻啊,我们根据什么查的数据库?不就是用户名么?“
小白:”抱歉我犯傻了,哈哈哈“
protected void additionalAuthenticationChecks(UserDetails userDetails,
UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
if (authentication.getCredentials() == null) {
this.logger.debug("Failed to authenticate since no credentials provided");
throw new BadCredentialsException(this.messages
.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
}
String presentedPassword = authentication.getCredentials().toString();
if (!this.passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
this.logger.debug("Failed to authenticate since password does not match stored value");
throw new BadCredentialsException(this.messages
.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
}
}
小白:”第一个参数是数据库中的用户密码,第二个参数是前端传递过来的用户密码, 使用
passwordEncoder.matches
进行比较“小白:“爱了,爱了,看起来和我说的流程差不多,我多学学,也能搞个认证授权框架”
小黑:“是的,企业中也有很多人自己写一套这样的框架,但是ss并没有表面那么简单,不然也不会这么复杂了”
小白:“那保存认证后信息的过程呢?”
小黑:“这个过程在这里
AbstractAuthenticationProcessingFilter#successfulAuthentication
”小黑:“看到上面三行代码了么?我们的认证信息就是被保存到
SecurityContextHolder
上下文了,该上下文通过ThreadLocal<SecurityContext>
实现”小白:”那ss还做了什么?我看后面还有一堆代码“
小黑:”是的,后面还有
rememberMe
功能和转发/重定向的功能,这些后面再讲“
授权
小白: " Spring security的授权看起来很简单啊?"
小黑: “是的,授权本身就是一个非常简单的过程。一,你要拿到当前用户的权限。二,你要拿到你要访问资源的权限。两相对比就知道你有没有权限去访问那个资源了。”
当完成认证后,接下来就是授权了。在Spring Security的授权体系中,有两个关键接口:
AccessDecisionManager
AccessDecisionVoter
小白: “上面这个
manager
是怎么回事?和认证的manager
一样吗?”
小黑: “看到了吗?其实是一样的。
Manager
就是类似于集合。集合里面的元素就是投票器。Manager
可以根据投票器AccessDecisionVoter
的supports
函数去判断资源是否支持我们的这个投票器。支持的投票器,会将他的资源所需要的权限和当前用户所有的权限进行比较。如果符合的话就直接返回成功的状态,否则的话会继续遍历下一个投票器,判断下一个投票器是否满足条件。”小白: “那这不就是一个集合的遍历吗?挺简单的。”
小白: “等一下等一下这些资源的权限都保存在哪里?”
小黑: " Spring security提供了一个接口
SecurityMetadataSource
, 这个接口提供了很多自定义的实现。而是Spring security默认使用的是这个结果的实现类DefaultFilterInvocationSecurityMetadataSource
, 这里面提供了一个map
来存储。"
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Nwk1jQuh-1675433540717)(null)]
小黑: “这个实现类的内容主要是从
HttpSecurity
拿到的, 也就是下图的部分,这里记录了哪一些url
需要授权。在spring security中这边都被配置为RequestMatcher
匹配器。”
小黑: “
SecurityMetadataSource
我们可以自己实现这个接口。”
public interface SecurityMetadataSource extends AopInfrastructureBean {
// 这个函数才是我们最需要的函数。这个参数就是方法资源或者是URL资源。
Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException;
// 在启动期间返回所有的ConfigAttributes, 通常这个方法是不需要实现的,所以我们一般都返回空。
Collection<ConfigAttribute> getAllConfigAttributes();
// 这里有一个class,这个class呢,主要就是判断是基于URL还是基于方法的。 FilterInvocation or MethodInvocation
boolean supports(Class<?> clazz);
}
小黑: “实现这个接口也是相当的简单,但是这个接口需要有一个属性,就是
RequestMatcher
请求配置器。然后剩下的其他内容可以从数据库中读取,只要按照要求配置好接口函数的返回值就行了。”小黑: “上面的那个请求配置器接口的实现的有很多,我们这里只需要一种
AntPathMatcher
, 这种配置器可以匹配请求url
跟资源的地址是否符合?”
antPathMatcher.match(menu.getPattern(), requestURI)
小黑: “具体的实现方式我就不一一讲解了,大体思路就是直接读取所有菜单的目录。然后使用匹配器去匹配这个目录。将匹配目录的权限全部罗列出来,存放在集合中。”
@Override
public Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException {
// 提取当前URL地址
String requestURI = ((FilterInvocation) object).getRequest().getRequestURI();
// 拿到所有菜单, 该菜单里有匹配规则 pattern
List<Menu> allMenu = menuService.getAllMenu();
// 做匹配
for (Menu menu : allMenu) {
if (antPathMatcher.match(menu.getPattern(), requestURI)) {
// 匹配成功, 拿到 role 列表, 也就是我们的权限
if (menu.getRoleList() == null || menu.getRoleList().isEmpty()) {
continue;
}
String[] roles = menu.getRoleList().stream().map(Role::getName).toArray(String[]::new);
return SecurityConfig.createList(roles);
}
}
// 都匹配不上, 返回 null
// 需要修改 AbstractSecurityInterceptor 对象的 rejectPublicInvocations属性了, 默认为false , 表示当 getAttributes 返回 null时, 允许访问受保护对象
return null;
}
小白: “这不就是另一个key value结构吗?”
小黑: “没错,就是这么简单”
小黑: “接下来我们开始讲解投票器。”
小黑: “如果所有的投票器都遍历过发现没有满足的话,直接抛出异常
AccessDeniedException
”
public interface AccessDecisionVoter<S> {
int ACCESS_GRANTED = 1;
int ACCESS_ABSTAIN = 0;
int ACCESS_DENIED = -1;
boolean supports(ConfigAttribute attribute);
boolean supports(Class<?> clazz);
int vote(Authentication authentication, S object, Collection<ConfigAttribute> attributes);
}
小白: “我一看这也是一个类似于key value的结构?”
小黑: “对的,这也是一个key value结构,并且呢有两种key。”
小黑: “可以看到投票就有三种。第一种是授权,第二种是缺省,第三种是无条件。分别表示1, 0, -1。”
小黑: “前面的两个
supports
方法可以看得出来是根据参数去判断是否支持。投票函数有三个参数,第一个是当前登录的用户。中间一个参数是资源。这个资源可能有两种,一种是方法, 还有一种是URL, 第三个参数表示第二个数所需的权限。”
小黑: “在投票函数里头就会看到。代码根据
Authentication
参数拿到用当前用户的所有权限。投票器默认返回值是无权限访问,所以在投票器内遍历判断,可以修改这个默认状态,为授权和缺省”
小黑: “从上面图片你应该会看到
ConfigAttribute
类型和GrantedAuthority
类型的字符上进行比较。也可以看到ConfigAttribute
类的作用,其实就是保存当前资源所需要的权限”小白: “我看起来好复杂呀,有没有简单点的版本?”
小黑: “emmm, 实在看不懂,就看下面的流程图吧。”
spring security过滤器列表
SpringSecurity
的过滤器列表:
开发者所见到的Spring Security提供的功能,都是通过这些过滤器来实现的,这些过滤器按照既定的优先级排列,最终形成一个过滤器链。开发者也可以自定义过滤器,并通过@Order
注解去调整自定义过滤器在过滤器链中的位置。
小白: " Spring security有那么多的过滤器,我难道要一个一个都记下来吗?"
小黑: “无稽之谈,你这说的就是废话。这些东西肯定是不用记的,记下来就是傻子。只要我们在使用的时候拿出来看看就行了。”
需要注意的是,默认过滤器并不是直接放在 Web 项目的原生过滤器链中,而是通过一个FilterChainProxy
来统一管理。Spring Security 中的过滤器链通过FilterChainProxy
嵌入到Web项目的原生过滤器链中,如下图所示。
在Spring Security 中,这样的过滤器链不仅仅只有一个,可能会有多个,如下图所示。
当存在多个过滤器链时,多个过滤器链之间要指定优先级,当请求到达后,会从FilterChainProxy
进行分发,先和哪个过滤器链匹配上,就用哪个过滤器链进行处理。当系统中存在多个不同的认证体系时,那么使用多个过滤器链就非常有效。
FilterChainProxy
作为一个顶层管理者,将统一管理Security Filter。FilterChainProxy
本身将通过Spring框架提供的DelegatingFilterProxy
整合到原生过滤器链中,所以上图所示的框架还可以做进一步的优化,如下图所示。
登录数据保存
如果不使用 Spring Security
这一类的安全管理框架,大部分的开发者可能会将登录用户数据保存在Session
中,
事实上,Spring Security也是这么做的.
但是,为了使用方便, Spring Security在此基础上还做了一些改进,其中最主要的一个变化就是线程绑定。
当用户登录成功后,Spring Security会将登录成功的用户信息保存到SecurityContextHolder
中。SecurityContextHolder
中的**数据保存默认是通过ThreadLocal
**来实现的,使用ThreadLocal
创建的变量只能被当前线程访问,不能被其他线程访问和修改,也就是用户数据和请求线程绑定在一起。
当登录请求处理完毕后,Spring Security 会将SecurityContextHolder
中的数据拿出来保存到Session
中,同时将SecurityContextHolder
中的数据清空。
以后每当有请求到来时,Spring Security就会先从Session
中取出用户登录数据,保存到SecurityContextHolder
中,方便在该请求的后续处理过程中使用
同时在请求结束时将SecurityContextHolder
中的数据拿出来保存到Session
中,然后将SecurityContextHolder
中的数据清空。
这一策略非常方便用户在 Controller
或者Service
层获取当前登录用户数据
小白: “有个问题就是,如果你的用户数据是绑定在线程中的话。那现有其他线下来怎么办?比如说创建一个新的线程。”
小黑: " Spring security提供了一另外一个实现
InheritableThreadLocalSecurityContextHolderStrategy
,该类是SecurityContextHolder
属性的一个策略。"
public static final String MODE_THREADLOCAL = "MODE_THREADLOCAL";
public static final String MODE_INHERITABLETHREADLOCAL = "MODE_INHERITABLETHREADLOCAL";
public static final String MODE_GLOBAL = "MODE_GLOBAL";
private static final String MODE_PRE_INITIALIZED = "MODE_PRE_INITIALIZED";
public static final String SYSTEM_PROPERTY = "spring.security.strategy";
private static String strategyName = System.getProperty(SYSTEM_PROPERTY);
小黑: “从这里可以看出,如果我们要修改这个策略的话,可以直接在java变量里设置一个key =
spring.security.strategy
value =MODE_INHERITABLETHREADLOCAL
的值就可以达到修改策略的效果了,当然还有别的办法。”
Spring Security对此也提供了相应的解决方案,如果开发者使用@Async
注解来开启异步任务的话,那么只需要添加如下配置,使用Spring Security提供的异步任务代理,就可以在异步任务中从 SecurityContextHolder
里边获取当前登录用户的信息:
小白: “那使用哪一个方法呢?”
小黑: “我推荐使用下面一个方法。”
c final String SYSTEM_PROPERTY = “spring.security.strategy”;
private static String strategyName = System.getProperty(SYSTEM_PROPERTY);
> 小黑: "从这里可以看出,如果我们要修改这个策略的话,可以直接在java变量里设置一个key = `spring.security.strategy` value = `MODE_INHERITABLETHREADLOCAL`的值就可以达到修改策略的效果了,当然还有别的办法。"
Spring Security对此也提供了相应的解决方案,如果开发者使用`@Async`注解来开启异步任务的话,那么只需要添加如下配置,使用Spring Security提供的异步任务代理,就可以在异步任务中从 `SecurityContextHolder`里边获取当前登录用户的信息:
[外链图片转存中...(img-upeKy0p1-1675433537828)]
> 小白: "那使用哪一个方法呢?"
>
> 小黑: "我推荐使用下面一个方法。"