【Spring】Spring Security

Spring Security 简介

Spring 是非常流行和成功的 Java 应用开发框架,Spring Security 正是 Spring 家族中的成员。Spring Security 基于 Spring 框架,提供了一套 Web 应用安全性的完整解决方案。

正如你可能知道的关于安全方面的两个主要区域是“认证”和“授权”(或者访问控制),一般来说,Web 应用的安全性包括 用户认证(Authentication)和用户授权(Authorization) 两个部分,这两点也是 Spring Security 重要核心功能。

  • 用户认证:指的是验证某个用户是否为系统中的合法主体,也就是说用户能否访问该系统。用户认证一般要求用户提供用户名和密码。系统通过校验用户名和密码来完成认证过程。通俗点说就是系统认为用户是否能登录
  • 用户授权:指的是验证某个用户是否有权限执行某个操作。在一个系统中,不同用户所具有的权限是不同的。比如对一个文件来说,有的用户只能进行读取,而有的用户可以进行修改。一般来说,系统会为不同的用户分配不同的角色,而每个角色则对应一系列的权限。通俗点讲就是系统判断用户是否有权限去做某些事情。

历史

“Spring Security 开始于 2003 年年底,““spring 的 acegi 安全系统”。 起因是 Spring 开发者邮件列表中的一个问题,有人提问是否考虑提供一个基于 spring 的安全实现。

Spring Security 以“The Acegi Secutity System for Spring” 的名字始于 2013 年晚些时候。一个问题提交到 Spring 开发者的邮件列表,询问是否已经有考虑一个机遇 Spring 的安全性社区实现。那时候 Spring 的社区相对较小(相对现在)。实际上 Spring 自己在2013 年只是一个存在于 ScourseForge 的项目,这个问题的回答是一个值得研究的领域,虽然目前时间的缺乏组织了我们对它的探索。

考虑到这一点,一个简单的安全实现建成但是并没有发布。几周后,Spring 社区的其他成员询问了安全性,这次这个代码被发送给他们。其他几个请求也跟随而来。到 2014 年一月大约有 20 万人使用了这个代码。这些创业者的人提出一个 SourceForge 项目加入是为了,这是在 2004 三月正式成立。

在早些时候,这个项目没有任何自己的验证模块,身份验证过程依赖于容器管理的安全性和 Acegi 安全性。而不是专注于授权。开始的时候这很适合,但是越来越多的用户请求额外的容器支持。容器特定的认证领域接口的基本限制变得清晰。还有一个相关的问题增加新的容器的路径,这是最终用户的困惑和错误配置的常见问题。

Acegi 安全特定的认证服务介绍。大约一年后,Acegi 安全正式成为了 Spring 框架的子项目。1.0.0 最终版本是出版于 2006 -在超过两年半的大量生产的软件项目和数以百计的改进和积极利用社区的贡献。Acegi 安全 2007 年底正式成为了 Spring 组合项目,更名为"Spring Security"。

对比

Spring Security 特点:

  • 和 Spring 无缝整合。
  • 全面的权限控制。
  • 专门为 Web 开发而设计。
  • 旧版本不能脱离Web 环境使用。新版本对整个框架进行了分层抽取,分成了核心模块和Web 模块。单独引入核心模块就可以脱离Web 环境。
  • 重量级

Shiro 特点:

  • Apache 旗下的轻量级权限控制框架。
  • 轻量级。Shiro 主张的理念是把复杂的事情变简单。针对对性能有更高要求的互联网应用有更好表现。
  • 通用性:
    • 好处:不局限于Web 环境,可以脱离Web 环境使用。
    • 缺陷:在Web 环境下一些特定的需求需要手动编写代码定制。

Spring Security 是 Spring 家族中的一个安全管理框架,实际上,在 Spring Boot 出现之前,Spring Security 就已经发展了多年了,但是使用的并不多,安全管理这个领域,一直是 Shiro 的天下。

相对于 Shiro,在 SSM 中整合 Spring Security 都是比较麻烦的操作,所以,Spring Security 虽然功能比 Shiro 强大,但是使用反而没有 Shiro 多(Shiro 虽然功能没有Spring Security 多,但是对于大部分项目而言,Shiro 也够用了)。

自从有了 Spring Boot 之后,Spring Boot 对于 Spring Security 提供了自动化配置方案,可以使用更少的配置来使用 Spring Security。

模块划分

image-20220201223814599

基本概念

什么是认证

认证是为了保护系统的隐私数据与资源,用户的身份合法方可访问该系统的资源。 认证 :用户认证就是判断一个用户的身份是否合法的过程,用户去访问系统资源时系统要求验证用户的身份信 息,身份合法方可继续访问,不合法则拒绝访问。

常见的用户身份认证方式有:用户名密码登录,二维码登录,手 机短信登录,指纹认证等方式。

  1. 基于 Session 的认证方式:

image-20220202101920918

  1. 基于 Token 的认证方式:

image-20220202101845750

什么是授权

还拿微信来举例子,微信登录成功后用户即可使用微信的功能,比如,发红包、发朋友圈、添加好友等。没有绑定银行卡的用户是无法发送红包的,绑定银行卡的用户才可以发红包,发红包功能、发朋友圈功能都是微信的资源即功能资源,用户拥有发红包功能的权限才可以正常使用发送红包功能,拥有发朋友圈功能的权限才可以使用发朋友圈功能,这个根据用户的权限来控制用户使用资源的过程就是授权。

认证是为了保证用户身份的合法性,授权则是为了更细粒度地对隐私数据进行划分,授权是在认证通过后发生的,控制不同的用户能够访问不同的资源。

授权是用户认证通过根据用户的权限来控制用户访问资源的过程,拥有资源的访问权限则正常访问,没有权限则拒绝访问。

授权的数据模型

初始版本:资源表和权限表是分开的

  • 资源表(资源id、资源名称、访问地址、…)
  • 权限表(权限id、权限标识、权限名称、资源id、…)
image-20220202102300958

但通常企业开发中将资源和权限表合并为一张权限表(权限id、权限标识、权限名称、资源名称、资源访问地址、…)

修改后数据模型之间的关系如下图:

image-20220202102548200

RBAC

如何实现授权?业界通常基于RBAC实现授权。

基于角色的访问控制

RBAC 基于角色的访问控制(Role-Based Access Control)是按角色进行授权,比如:主体的角色为总经理可以查询企业运营报表,查询员工工资信息等,访问控制流程如下:

授权代码可表示如下:

1
2
3
if(主体.hasRole("总经理角色id")) {
查询工资
}

而一旦查询工资所需要的角色变化为总经理和部门经理,此时就需要修改判断逻辑为“判断用户的角色是否是 总经理或部门经理”,修改代码如下:

1
2
3
if(主体.hasRole("总经理角色id") || 主体.hasRole("部门经理角色id")) {
查询工资
}

根据例子发现,当需要修改角色的权限时就需要修改授权的相关代码,系统可扩展性差

基于资源的访问控制

RBAC 基于资源的访问控制(Resource-Based Access Control)是按资源(或权限)进行授权,比如:用户必须具有查询工资权限才可以查询员工工资信息等,访问控制流程如下:

授权代码可以表示为:

1
2
3
if(主体.hasPermission("查询工资权限标识")) {
查询工资
}

优点:系统设计时定义好查询工资的权限标识,即使查询工资所需要的角色变化为总经理和部门经理也不需要修改授权代码,系统可扩展性强

Spring Security 工作原理

更多详细介绍见文档 https://blog.yuyunzhao.cn/documents/SpringSecurity.pdf

Spring Security 所解决的问题就是安全访问控制,而安全访问控制功能其实就是对所有进入系统的请求进行拦截, 校验每个请求是否能够访问它所期望的资源。可以通过 Filter 或 AOP 等技术来实现,Spring Security 对 Web 资源的保护是通过 Filter 实现的,所以从 Filter 来入手,逐步深入 Spring Security 原理。

Spring Security 本质是一个过滤器链

1
2
3
4
5
6
7
8
9
10
11
12
13
14
org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter
org.springframework.security.web.context.SecurityContextPersistenceFilter
org.springframework.security.web.header.HeaderWriterFilter
org.springframework.security.web.csrf.CsrfFilter
org.springframework.security.web.authentication.logout.LogoutFilter
org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter
org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter
org.springframework.security.web.savedrequest.RequestCacheAwareFilter
org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter
org.springframework.security.web.authentication.AnonymousAuthenticationFilter
org.springframework.security.web.session.SessionManagementFilter
org.springframework.security.web.access.ExceptionTranslationFilter
org.springframework.security.web.access.intercept.FilterSecurityInterceptor

当初始化 Spring Security 时,会创建一个名为 SpringSecurityFilterChain 的 Filter 过滤器,类型为 org.springframework.security.web.FilterChainProxy,它实现了 javax.servlet.Filter,因此外部的请求会经过此类,下图是 Spring Security 过滤器链结构图:

image-20220202110547426

FilterChainProxy 是一个代理,真正起作用的是 FilterChainProxySecurityFilterChain 所包含的各个 Filter,同时这些 Filter 作为 Bean 被 Spring 管理,它们是 Spring Security 核心,各有各的职责,但他们并不直接处理用户的认证,也不直接处理用户的授权,而是把它们交给了**认证管理器(AuthenticationManager)决策管理器 (AccessDecisionManager)**进行处理,下图是 FilterChainProxy 相关类的 UML 图示:

image-20220202110753655

Spring Security 功能的实现主要是由一系列过滤器链相互配合完成:

image-20220202111038977

  • SecurityContextPersistenceFilter 这个Filter是整个拦截过程的入口出口(也就是第一个和最后一个拦截器),会在请求开始时从配置好的 SecurityContextRepository 中获取 SecurityContext,然后把它设置给 SecurityContextHolder。在请求完成后将 SecurityContextHolder 持有的 SecurityContext 再保存到配置好 的 SecurityContextRepository,同时清除 SecurityContextHolder 所持有的 SecurityContext
  • UsernamePasswordAuthenticationFilter 用于处理来自表单提交的认证(认证过滤器)。该表单必须提供对应的用户名和密码,其内部还有登录成功或失败后进行处理的 AuthenticationSuccessHandlerAuthenticationFailureHandler,这些都可以根据需求做相关改变;
  • FilterSecurityInterceptor 是用于保护 web 资源的(权限过滤器),使用 AccessDecisionManager 对当前用户进行授权访问:根据资源权限配置来判断当前请求是否有权限访问对应的资源。如果访问受限会抛出相关异常,最终所抛出的异常会由前一个过滤器 ExceptionTranslationFilter 捕获
  • ExceptionTranslationFilter 能够捕获来自 FilterChain 所有的异常,并进行处理。但是它只会处理两类异常: AuthenticationExceptionAccessDeniedException,其它的异常它会继续抛出。

只有当前过滤器通过,才能进入下一个过滤器,任何一个不通过都会抛出异常,被 ExceptionTranslationFilter 捕获后返回给客户端 403 Forbidden

认证流程

认证流程主要使用 UsernamePasswordAuthenticationFilter 对提交的表单进行认证,判断前端传来的用户名和密码是否匹配。流程图:

image-20220201232253172

其中,UsernamePasswordAuthenticationFilter 本身不进行验证,而是将请求信息封装成 UsernamePasswordAuthenticationToken 对象后调用 AuthenticationManagerauthenticate() 方法进行认证。其将委托 DaoAuthenticationProvider 进行验证,进一步调用 UserDetailsServiceloadUserByUsername() 方法获取到当前用户名的密码、权限等信息。最后填充这些信息到 Authentication 对象中存入安全上下文中。

详细流程:

  • 用户提交用户名、密码被 SecurityFilterChain 中的 UsernamePasswordAuthenticationFilter 过滤器获取到, 封装为请求Authentication,通常情况下是 UsernamePasswordAuthenticationToken 这个实现类。
  • 然后过滤器将 Authentication 提交至认证管理器(AuthenticationManager)进行认证
  • 认证成功后, AuthenticationManager 身份管理器返回一个被填充满了信息的(包括上面提到的权限信息, 身份信息,细节信息,但密码通常会被移除) Authentication 实例。
  • SecurityContextHolder 安全上下文容器将第3步填充了信息的 Authentication ,通过 SecurityContextHolder.getContext().setAuthentication(…) 方法,设置到其中。

可以看出 AuthenticationManager 接口(认证管理器)是认证相关的核心接口,也是发起认证的出发点,它的实现类为 ProviderManager。而 Spring Security 支持多种认证方式,因此 ProviderManager维护着一个 List 列表,存放多种认证方式,最终实际的认证工作是由 AuthenticationProvider完成的。咱们知道 web 表单的对应的 AuthenticationProvider 实现类为 DaoAuthenticationProvider,它的内部又维护着一个 UserDetailsService 负责 UserDetails 的获取。最终 AuthenticationProviderUserDetails 填充至 Authentication

授权流程

Spring Security 可以通过 http.authorizeRequests() 对 web 请求进行授权保护。Spring Security 使用标准 Filter 建立了对 web 请求的拦截,最终实现对资源的授权访问。根据资源权限配置来判断当前请求是否有权限访问对应的资源。如果访问受限会抛出相关异常,最终所抛出的异常会由前一个过滤器 ExceptionTranslationFilter 捕获:

image-20220202125403180

分析授权流程:

  • 拦截请求,已认证用户访问受保护的web资源将被SecurityFilterChain中的 FilterSecurityInterceptor 的子类拦截。
  • 获取资源访问策略,FilterSecurityInterceptor会从 SecurityMetadataSource 的子类 DefaultFilterInvocationSecurityMetadataSource 获取要访问当前资源所需要的权限 CollectionSecurityMetadataSource其实就是读取访问策略的抽象,而读取的内容,其实就是我们配置的访问规则,读取访问策略如:
1
2
3
4
5
http
.authorizeRequests()
.antMatchers("/r/r1").hasAuthority("p1")
.antMatchers("/r/r2").hasAuthority("p2")
...
  • 最后,FilterSecurityInterceptor 会调用 AccessDecisionManager 进行授权决策,若决策通过,则允许访问资源,否则将禁止访问(抛出异常)。

所有过滤器

  • WebAsyncManagerIntegrationFilter:将 Security 上下文与 Spring Web 中用于处理异步请求映射的 WebAsyncManager 进行集成。
  • SecurityContextPersistenceFilter:在每次请求处理之前将该请求相关的安全上下文信息加载到 SecurityContextHolder 中,然后在该次请求处理完成之后,将SecurityContextHolder 中关于这次请求的信息存储到一个“仓储”中,然后将SecurityContextHolder 中的信息清除,例如在 Session 中维护一个用户的安全信息就是这个过滤器处理的。
  • HeaderWriterFilter:用于将头信息加入响应中。
  • CsrfFilter:用于处理跨站请求伪造。
  • LogoutFilter:用于处理退出登录。
  • UsernamePasswordAuthenticationFilter:用于处理基于表单的登录请求,从表单中获取用户名和密码。默认情况下处理来自 /login 的请求。从表单中获取用户名和密码时,默认使用的表单 name 值为 username 和 password,这两个值可以通过设置这个过滤器的 usernameParameter 和 passwordParameter 两个参数的值进行修改**(可自定义该过滤器重写方法,实现从数据库中读取用户名和密码等信息)**
  • DefaultLoginPageGeneratingFilter:如果没有配置登录页面,那系统初始化时就会配置这个过滤器,并且用于在需要进行登录时生成一个登录表单页面。
  • BasicAuthenticationFilter:检测和处理 http basic 认证**(可自定义该过滤器重写方法,从 Redis 中读取认证后用户的权限列表信息)**
  • RequestCacheAwareFilter:用来处理请求的缓存。
  • SecurityContextHolderAwareRequestFilter:主要是包装请求对象 request。
  • AnonymousAuthenticationFilter:检测 SecurityContextHolder 中是否存在 Authentication 对象,如果不存在为其提供一个匿名 Authentication。
  • SessionManagementFilter:管理 session 的过滤器
  • ExceptionTranslationFilter:处理 AccessDeniedException 和 AuthenticationException 异常。
  • FilterSecurityInterceptor:可以看做过滤器链的出口。
  • RememberMeAuthenticationFilter:当用户没有登录而直接访问资源时, 从 cookie 里找出用户的信息, 如果 Spring Security 能够识别出用户提供的 remember me cookie, 用户将不必填写用户名和密码, 而是直接登录进入系统,该过滤器默认不开启。

总结

主要流程:

  • Tomcat 收到浏览器发来的请求后使用上述过滤器逐个过滤请求
  • 先经过认证过滤器,判断前端传来的用户名和密码是否匹配
  • 若匹配,则经过授权过滤器,将用户的权限列表信息存储到 Spring Security 上下文对象中(方便后面 Controller 层获取到用户权限信息)
  • Spring Security 的所有过滤器都放行后才会进入到 Spring MVC 的 DispatcherServlet

单体 Web 应用权限方案

配置案例

  1. 导入 Maven 依赖 spring-boot-starter-security
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>

<!--mybatis-plus-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.0.5</version>
</dependency>

<!--mysql-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>

<!--lombok 用来简化实体类-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
</dependencies>
  1. 自定义登录用户细节类(实现 UserDetailsService 接口),该类的 loadUserByUsername 方法将被 DaoAuthenticationProvider 调用,从数据库中查询指定用户名的密码与权限,并返回 UserDetails 对象给 DaoAuthenticationProvider ,验证前端传来的密码是否和数据库中的匹配:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@Service("userDetailsService")
public class MyUserDetailsService implements UserDetailsService { @Autowired
private UserMapper userMapper;

@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
QueryWrapper<User> wrapper = new QueryWrapper();
wrapper.eq("username", s);
User user = userMapper.selectOne(wrapper);
if (user == null) {
// 数据库没有用户名,认证失败
throw new UsernameNotFoundException("用户名不存在!");
}

//// 赋予角色权限
// List<GrantedAuthority> auths = AuthorityUtils
// .commaSeparatedStringToAuthorityList("admin", "ROLE_sale");
// return new User(user.getUsername(), new BCryptPasswordEncoder().
// encode(users.getPassword()), auths);

// 从数据库中查询用户权限 "admin", "ROLE_sale"
List<String> permissions = userMapper.findPermissionsByUserId(user.getId());
String[] perarray = new String[permissions.size()];
permissions.toArray(perarray);
// 创建 UserDetails
UserDetails userDetails = User.
withUsername(user.getFullname()). // 设置用户名
password(user.getPassword()). // 设置用户密码
authorities(perarray). // 设置用户权限
build();
// 返回给 DaoAuthenticationProvider 该 UserDetails,用于验证前端传来的密码是否和数据库中的匹配
return userDetails;
}
}

该方法被调用的时机:

image-20220201232253172

  1. Spring Security 核心配置类 SecurityConfig。负责配置安全拦截机制等信息:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
@Configuration
//@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserDetailsService userDetailsService;

// 注入数据源
@Autowired
private DataSource dataSource;

// 配置 JdbcTokenRepositoryImpl,用于将 token 持久化到数据库中
@Bean
public PersistentTokenRepository persistentTokenRepository() {
JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
jdbcTokenRepository.setDataSource(dataSource);
return jdbcTokenRepository;
}

// 配置自定义的 UserDetailsService 和自定义加密器 BCryptPasswordEncoder
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService)
.passwordEncoder(password());
}

// 注入加密器
@Bean
PasswordEncoder password() {
return new BCryptPasswordEncoder();
}

// 配置安全拦截机制
@Override
protected void configure(HttpSecurity http) throws Exception {
// 配置退出
http.logout().logoutUrl("/logout").
logoutSuccessUrl("/test/hello").permitAll();

// 配置没有权限访问时跳转自定义页面
http.exceptionHandling().accessDeniedPage("/unauth.html");

// 自定义登录页面
http.formLogin()
.loginPage("/on.html") // 登录页面设置
.loginProcessingUrl("/user/login") // 登录访问路径
.defaultSuccessUrl("/success.html").permitAll() // 登录成功之后,跳转路径
.failureUrl("/unauth.html")
.and()
// 请求访问授权规则
.authorizeRequests()
.antMatchers("/", "/test/hello", "/user/login").permitAll() // 设置哪些路径可以直接访问,不需要认证
// 配置角色/权限访问控制:当前登录用户,只有具有admins权限才可以访问这个路径
// 1. hasAuthority方法
.antMatchers("/test/index").hasAuthority("admins")
// 2. hasAnyAuthority方法
// .antMatchers("/test/index").hasAnyAuthority("admins", "manager")
// 3. hasRole方法 ROLE_sale
// .antMatchers("/test/index").hasRole("sale")
// 其余的任意请求都需要用户登录
.anyRequest().authenticated()
.and()
// 配置基于数据库的免登陆
.rememberMe()
.tokenRepository(persistentTokenRepository())
// 配置自定义的 UserDetailsService
.userDetailsService(userDetailsService)
.tokenValiditySeconds(60); // 设置有效时长,单位秒
// .and().csrf().csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse());
// .and().csrf().disable(); // 关闭csrf防护
}
}
  1. 自定义前端登录页面:
1
2
3
4
5
6
7
<form action="/logn" method="post">
<input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}"/>
用户名:<input type="text" name="username"/><br/>
密码:<input type="password" name="password"/><br/>
记住我:<input type="checkbox" name="remember-me" title="记住密码"/><br/>
<input type="submit" value="提交"/>
</form>

注意:页面提交方式必须 POST 请求,并且用户名密码必须为 usernamepassword。原因:执行登录的过程中会经过一个过滤器 UsernamePasswordAuthenticationFilter,其默认配置的用户名和密码就是该值。如果想修改可以调用 http 对象的 usernameParameter()passwordParameter() 方法。若想开启免登陆功能,必须添加一个复选框 remember-me

Spring Security 默认开启了 CSRF 防护,此时除了 GET 请求之外的请求都无法正常访问。除非在表单里添加一个隐藏域 ${_csrf.parameterName},每次提交时携带一个 token。Spring Security 会在用户登录时保存该 token,之后的非 GET 请求都会校验该 token,一致时才能允许放行请求。


  1. Controller 层:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@RestController
@RequestMapping("/test")
public class TestController {

@GetMapping("hello")
public String hello() {
return "hello security";
}

@GetMapping("index")
public String index() {
return "hello index";
}

@GetMapping("update")
//@Secured({"ROLE_sale","ROLE_manager"})
//@PreAuthorize("hasAnyAuthority('admins')")
@PostAuthorize("hasAnyAuthority('admins')")
public String update() {
System.out.println("update......");
return "hello update";
}

@GetMapping("getAll")
@PostAuthorize("hasAnyAuthority('admins')")
@PostFilter("filterObject.username == 'admin1'")
public List<Users> getAllUser(){
ArrayList<Users> list = new ArrayList<>();
list.add(new Users(11,"admin1","6666"));
list.add(new Users(21,"admin2","888"));
System.out.println(list);
return list;
}
}

配置细节

关于配置的更多细节见文档 https://blog.yuyunzhao.cn/documents/SpringSecurity.pdf

分布式应用权限方案

创建统一认证服务 UAA,负责进行统一认证与授权。经过该服务授权后,才会进入到具体的微服务。

统一认证服务 UAA

核心配置类

  1. 导入 Maven 依赖:
1
2
3
4
5
6
7
8
9
10
11
<!-- Spring Security 依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>

<!-- JWT 依赖 -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
</dependency>
  1. Spring Security 核心配置类:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class TokenWebSecurityConfig extends WebSecurityConfigurerAdapter {
// 自定义 JWT 生成器
private TokenManager tokenManager;
private RedisTemplate redisTemplate;
// 自定义 UserDetailsService
private UserDetailsService userDetailsService;

@Autowired
public TokenWebSecurityConfig(UserDetailsService userDetailsService,
TokenManager tokenManager, RedisTemplate redisTemplate) {
this.userDetailsService = userDetailsService;
this.tokenManager = tokenManager;
this.redisTemplate = redisTemplate;
}

/**
* 配置安全拦截机制
* 设置退出的地址和 token,redis 操作地址
* @param http
* @throws Exception
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
http.exceptionHandling() // 异常处理器
.authenticationEntryPoint(new UnauthEntryPoint()) // 无权限访问时的处理器
.and()
// 关闭 CSRF 拦截
.csrf().disable()
// 请求访问授权规则
.authorizeRequests()
.anyRequest().authenticated() // 所有请求都需要授权
.and()
// 退出路径
.logout().logoutUrl("/admin/acl/index/logout")
// 添加自定义 logout 处理器
.addLogoutHandler(new TokenLogoutHandler(tokenManager, redisTemplate))
.and()
// 添加自定义认证过滤器 TokenLoginFilter,实现自定义登录认证
.addFilter(new TokenLoginFilter(authenticationManager(), tokenManager, redisTemplate))
// 添加自定义授权过滤器 TokenAuthFilter,从 Redids 中读取该用户的权限列表
.addFilter(new TokenAuthFilter(authenticationManager(), tokenManager, redisTemplate))
.httpBasic();
}

// 添加自定义 UserDetailsService 和加密器
@Override
public void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService)
.passwordEncoder(new BCryptPasswordEncoder());
}

// 配置不需要进行认证的路径
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/api/**");
}
}

自定义过滤器

共需要自定义两个过滤器:

  • 令牌认证过滤器 TokenLoginFilter:实现自定义登录认证,在认证成功后保存用户名权限列表到 Redis 中,方便后续授权过滤器获取到该用户的权限列表
  • 令牌授权过滤器 TokenAuthFilter:从 Redis 中读取该用户的权限列表,存储到上下文 SecurityContextHolder.getContext()
  1. 自定义认证过滤器 TokenLoginFilter,实现自定义登录认证:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
public class TokenLoginFilter extends UsernamePasswordAuthenticationFilter {
private TokenManager tokenManager;
private RedisTemplate redisTemplate;
private AuthenticationManager authenticationManager;

public TokenLoginFilter(AuthenticationManager authenticationManager, TokenManager tokenManager, RedisTemplate redisTemplate) {
this.authenticationManager = authenticationManager;
this.tokenManager = tokenManager;
this.redisTemplate = redisTemplate;
this.setPostOnly(false);
this.setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher("/admin/acl/login","POST"));
}

/**
* 1. 重写 attemptAuthentication() 方法
* 1.1 首先解析前端传来的用户名和密码;
* 1.2 然后调用 authenticationManager.authenticate() 方法进行认证。
* 该方法内部将调用自定义 UserDetailsService 对象的 loadUserByUsername() 方法
* 从数据库中查询用户名和密码数据
*/
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
// 获取前端表单提交的用户名和密码
try {
User user = new ObjectMapper().readValue(request.getInputStream(), User.class);
// authenticationManager.authenticate() 方法进行认证
return authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(user.getUsername(),
user.getPassword(),
new ArrayList<>()));
} catch (IOException e) {
e.printStackTrace();
throw new RuntimeException();
}
}

/**
* 2. 认证成功后调用的方法
* 2.1 认证成功,得到用户信息(包括该用户的权限信息),并据此生成 JWT
* 2.2 把用户名称和用户权限列表存储到 Redis
* 2.3 返回 JWT 给客户端
*/
@Override
protected void successfulAuthentication(HttpServletRequest request,
HttpServletResponse response, FilterChain chain, Authentication authResult)
throws IOException, ServletException {
// 认证成功,得到用户信息(包括该用户的权限信息)
SecurityUser user = (SecurityUser)authResult.getPrincipal();
// 根据用户名生成 JWT
String token = tokenManager.createToken(user.getCurrentUserInfo().getUsername());
// 把用户名称和用户权限列表存储到 Redis
redisTemplate.opsForValue().set(user.getCurrentUserInfo().getUsername(),user.getPermissionValueList());
// 返回 JWT 给客户端
ResponseUtil.out(response, R.ok().data("token",token));
}

/**
* 3. 认证失败调用的方法
*/
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed)
throws IOException, ServletException {
ResponseUtil.out(response, R.error());
}
}
  1. 自定义登录用户细节类(实现 UserDetailsService 接口),该类的 loadUserByUsername 方法将被上面自定义的认证过滤器 TokenLoginFilter 调用,从数据库中查询指定用户名的密码与权限,并返回 UserDetails 对象给 DaoAuthenticationProvider ,验证前端传来的密码是否和数据库中的匹配:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@Service("userDetailsService")
public class MyUserDetailsService implements UserDetailsService { @Autowired
@Autowired
private UserService userService;

@Autowired
private PermissionService permissionService;

@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
// 查询该用户的信息
User user = userService.selectByUsername(username);
if (user == null) {
// 数据库没有用户名,认证失败
throw new UsernameNotFoundException("用户名不存在!");
}

//// 赋予角色权限
// List<GrantedAuthority> auths = AuthorityUtils
// .commaSeparatedStringToAuthorityList("admin", "ROLE_sale");
// return new User(user.getUsername(), new BCryptPasswordEncoder().
// encode(users.getPassword()), auths);

// 从数据库中查询用户权限,例如"admin", "ROLE_sale"
List<String> permionssions = permissionService.selectPermissionValueByUserId(user.getId());
// 创建 UserDetails
UserDetails userDetails = User.
withUsername(user.getFullname()). // 设置用户名
password(user.getPassword()). // 设置用户密码
authorities(permionssions). // 设置用户权限
build();
// 返回给 DaoAuthenticationProvider 该 UserDetails,用于验证前端传来的密码是否和数据库中的匹配
return userDetails;
}
}

该方法被调用的时机:

image-20220201232253172

  1. 自定义令牌授权过滤器 TokenAuthFilter:从 Redis 中读取该用户的权限列表,存储到上下文 SecurityContextHolder.getContext()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public class TokenAuthFilter extends BasicAuthenticationFilter {
private TokenManager tokenManager;
private RedisTemplate redisTemplate;
public TokenAuthFilter(AuthenticationManager authenticationManager,TokenManager tokenManager,RedisTemplate redisTemplate) {
super(authenticationManager);
this.tokenManager = tokenManager;
this.redisTemplate = redisTemplate;
}

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
// 获取当前认证成功的用户权限信息
UsernamePasswordAuthenticationToken authRequest = getAuthentication(request);
// 判断如果有权限信息,保存到权限上下文中
if (authRequest != null) {
SecurityContextHolder.getContext().setAuthentication(authRequest);
}
chain.doFilter(request,response);
}

/*
* 从 redis 中获取当前用户的权限列表
*/
private UsernamePasswordAuthenticationToken getAuthentication(HttpServletRequest request) {
// 从 header 获取 token
String token = request.getHeader("token");
if (token != null) {
// 从 token 获取用户名
String username = tokenManager.getUserInfoFromToken(token);
// 从 redis 获取对应权限列表
List<String> permissionValueList = (List<String>)redisTemplate.opsForValue().get(username);
Collection<GrantedAuthority> authority = new ArrayList<>();
for (String permissionValue : permissionValueList) {
SimpleGrantedAuthority auth = new SimpleGrantedAuthority(permissionValue);
authority.add(auth);
}
return new UsernamePasswordAuthenticationToken(username, token, authority);
}
return null;
}
}

这样存储在 SecurityContextHolder.getContext() 中用户信息就可以在 Controller 层获取到:

1
2
3
4
5
6
7
@GetMapping("menu")
public R getMenu(){
// 获取当前登录用户用户名
String username = SecurityContextHolder.getContext().getAuthentication().getName();
List<JSONObject> permissionList = indexService.getMenu(username);
return R.ok().data("permissionList", permissionList);
}

自定义处理器

  1. 自定义 JWT 生成器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/**
* jwt(json web token)工具类,用于生成token字符串
*/
@Component
public class TokenManager {
// 设置token过期时间,单位ms
public static final long EXPIRE = 1000 * 60 * 60 * 24;
// jwt加密时使用的秘钥。需要保存到服务端,用于加签和验签
public static final String APP_SECRET = "ukc8BDbRigUDaY6pZFfWus2jZWLPHO";

// 1. 使用 jwt 根据用户名生成 token
public String createToken(String username) {
String token = Jwts.builder()
.setSubject(username)
.setExpiration(new Date(System.currentTimeMillis() + tokenEcpiration))
.signWith(SignatureAlgorithm.HS512, tokenSignKey)
.compressWith(CompressionCodecs.GZIP)
.compact();
return token;
}

// 2. 根据 token 字符串得到用户信息
public String getUserInfoFromToken(String token) {
String userinfo = Jwts.parser()
.setSigningKey(tokenSignKey)
.parseClaimsJws(token)
.getBody()
.getSubject();
return userinfo;
}

// 3. 逻辑删除token
public void removeToken(String token) { }
}
  1. 自定义 logout 处理器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class TokenLogoutHandler implements LogoutHandler {
private TokenManager tokenManager;
private RedisTemplate redisTemplate;

public TokenLogoutHandler(TokenManager tokenManager,RedisTemplate redisTemplate) {
this.tokenManager = tokenManager;
this.redisTemplate = redisTemplate;
}
@Override
public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
// 1. 从 header 里面获取 token
String token = request.getHeader("token");
// 2. 如果 token 不为空,移除 token,并从 Redis 中删除该用户的权限列表信息
if( token != null) {
// 逻辑删除
tokenManager.removeToken(token);
// 从 token 中获取用户名
String username = tokenManager.getUserInfoFromToken(token);
// 从 redis 中删除该用户的权限列表信息
redisTemplate.delete(username);
}
ResponseUtil.out(response, R.ok());
}
}
  1. 自定义无权限访问时的处理器 UnauthEntryPoint,当访问无权限时将执行 commence 方法:
1
2
3
4
5
6
public class UnauthEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
ResponseUtil.out(httpServletResponse, R.error());
}
}

网关服务

订单服务