目录
1、本文概要
本文主要工作为在 Spring Boot 环境内集成 Spring Security 安全框架,并介绍其简单使用。本文Spring Boot版本使用2.1.0.RELEASE。
2、集成Spring Security
Spring Boot 集成 Spring Security 非常简单,新建一个 Spring Boot 项目,引入 Spring Security 依赖即可。
- 引入 Spring Security 依赖
<!-- spring security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
- 创建一个测试资源接口
@Controller
public class HomeController {
@ResponseBody
@RequestMapping("/")
public String home() {
return "home";
}
}
至此,一个最简单的 Spring Security 项目就创建好了。项目所有资源默认收到 Spring Security 保护。启动项目的时候会在控制台生成一个随机的登录密码,对应的用户名为 “user”。
Using generated security password: d711e6d9-645b-4454-a458-6caaed7cad6a
启动项目,浏览器访问:http://localhost:8080,会默认跳转到登录页面:http://localhost:8080/login。输入对应的用户名密码,登录成功返回"home"字符串。若要退出,只需访问:http://localhost:8080/logout 即可。
- 默认登录页面,Spring Security框架自带
[外链图片转存失败,源站可能有防盗]!链机制,建
3、自定义登录退出页面,使用 Thymeleaf 模板引擎
上一步的登录退出页面为框架自带的,可以自定义改成自己需要的。这里使用 Thymeleaf 作为页面模板引擎,thymeleaf 相关信息可以点击官网了解。
- 添加thymeleaf依赖
<!-- thymeleaf -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
在resource中创建 templates 目录,添加自定义的 login.html 和 user.html 页面。在登录页面点击登录,会 POST 请求 /login 地址。相应地在user.html点击退出按钮,会 POST 请求 /logout,实现用户退出。
- login.html 和 user.html
<form th:action="@{/login}" method="post">
<div class="form-group" style="margin-top: 30px">
<div class="input-group col-md-6 col-md-offset-3">
<div class="input-group-addon"><span class="glyphicon glyphicon-user"></span></div>
<input type="text" class="form-control" name="username" id="username" placeholder="账号">
</div>
</div>
<div class="form-group ">
<div class="input-group col-md-6 col-md-offset-3">
<div class="input-group-addon"><span class="glyphicon glyphicon-lock"></span></div>
<input type="password" class="form-control" name="password" id="password"
placeholder="密码">
</div>
</div>
<br>
<div th:if="${param.error}">
<p style="text-align: center" class="text-danger">登录失败,账号或密码错误!</p>
</div>
<div th:if="${result}">
<p style="text-align: center" class="text-success" th:text="${result}"></p>
</div>
<div class="form-group">
<div class="input-group col-md-6 col-md-offset-3 col-xs-12 ">
<button type="submit" class="btn btn-primary btn-block">登录</button>
</div>
</div>
</form>
<div class="container" style="margin-top: 60px">
<div style="text-align: center; margin-top: 10%">
<p th:text="${username}" style="margin-top: 25px; font-size: 20px; color: crimson">username</p>
<form th:action="@{/logout}" method="post">
<button class="btn btn-danger" style="margin-top: 20px">退出登录</button>
</form>
</div>
</div>
添加 Spring Security 配置。创建配置类继承 WebSecurityConfigurerAdapter 并重写其 configure(Httpsecurity http) 方法。
- WebSecurityConfig
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
// 允许POST提交登录,取消csrf保护
.csrf().disable()
// 配置需要认证的目录: 除了/login不需要认证,其他所有资源需要认证
// /login 必须无需认证,否则会出现重定向次数过多异常。-> 浏览器访问 /login, 无权限 -> 重定向到/login -> 无限循环
// antMatches配置有顺序, 如果.anyRequest().permitAll()放在最前面, 后面的权限配置会失效
.authorizeRequests()
.antMatchers("/login").permitAll()
.anyRequest().authenticated()
// 登录路径 /login, 登录成功跳转路径 /user
.and()
.formLogin().loginPage("/login").defaultSuccessUrl("/user")
// 退出路径 /logout, 退出成功跳转路径 /login
.and()
.logout().logoutUrl("/logout").logoutSuccessUrl("/login");
}
}
- /login和/user资源配置
/**
* 登录, 返回 login.html
*/
@GetMapping("/login")
public String login() {
return "login";
}
/**
* user页面,返回 user.html
*/
@GetMapping("/user")
public String user(@AuthenticationPrincipal Principal principal, Model model) {
model.addAttribute("username", principal.getName());
return "user";
}
至此,本小节使用自定义页面代替了框架自带登录退出页面。当浏览器访问受保护资源时,会自动被拦截跳转到自定义登录页面。登录成功会跳转到之前的资源地址。
登录页面 /user
登录成功页面跳转 /user
4、使用自定义用户名密码
这一节使用自定义的用户名密码代替项目启动时 Spring Security 初始化的用户名密码。
4.1 内存保存用户名密码
选在在内存中配置用户信息。由于这种方式用户信息写死,因此使用的场景不多。配置方法很简单,只需要配置 AuthenticationManagerBuilder。需要注意的是,必须配置一个 PasswordEncoder,这里使用 BCryptPasswordEncoder,示例代码如下:
/*
内存保存用户信息
A. 必须指定PasswordEncoder: 两种方式
1. auth.inMemoryAuthentication().passwordEncoder(new BCryptPasswordEncoder())...
2. @Bean配置一个PasswordEncoder
B. auth..password(), 传入Encoder编码之后的值
C. 至少指定一个roles, 否则启动报错
*/
@Autowired
public void configureAuthenticationManagerBuilder(AuthenticationManagerBuilder auth) throws Exception {
PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
auth.inMemoryAuthentication()
.passwordEncoder(passwordEncoder)
.withUser("test_user_001").password(passwordEncoder.encode("pwd_001")).roles("USER", "ADMIN");
}
/**
* 配置PasswordEncoder,页面上传的密码会通过此Encoder的encode()方法编码之后进行比较
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
另外,可以通过配置一个 UserDetailsService 达到相同的效果,具体的实现选择 InMemoryUserDetailsManager。
/*
内存保存用户名密码
A. 必须指定PasswordEncoder: @Bean配置
B. 必须指定roles: InMemoryUserDetailsManager...authorities("USER", "ADMIN")
*/
@Bean
public UserDetailsService userDetailsService() {
PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
manager.createUser(User.withUsername("test_user_001").password(passwordEncoder.encode("pwd_001")).authorities("USER", "ADMIN").build());
return manager;
}
至此,可以通过配置在内存中的用户 “test_user_001” 认证访问受保护资源。
4.2 数据库保存用户信息
在内存中配置用户信息使用不多,这里选择保存用户信息到数据库。数据库采用 MySQL,由于后面涉及到用户的角色和权限,所以这儿也需要对用户添加权限和角色设计。用户角色权限设计采用通用 RBAC 权限模型,简单地说就是一个用户可以拥有多个角色,每个角色包含多种权限。数据库表包含五张,分别为:user,role,user_role,permission,role_permission。
创建用户信息相关表,并添加用户信息。示例添加五个用户,admin,query,add,update,delete,每个用户分别有不同的角色和权限。其中 admin 用户拥有所有权限。每个用户的密码和用户名相同,并使用 BCryptPasswordEncoder 编码。
- 用户信息表结构及数据
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- Table structure for permission
-- ----------------------------
DROP TABLE IF EXISTS `permission`;
CREATE TABLE `permission` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`permission` varchar(255) DEFAULT NULL,
`url` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- ----------------------------
-- Records of permission
-- ----------------------------
BEGIN;
INSERT INTO `permission` VALUES (1, 'auth_query', '/order/query');
INSERT INTO `permission` VALUES (2, 'auth_add', '/order/add');
INSERT INTO `permission` VALUES (3, 'auth_update', '/order/update');
INSERT INTO `permission` VALUES (4, 'auth_delete', '/order/delete');
INSERT INTO `permission` VALUES (5, 'auth_a', '/order/a');
COMMIT;
-- ----------------------------
-- Table structure for role
-- ----------------------------
DROP TABLE IF EXISTS `role`;
CREATE TABLE `role` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`role_name` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- ----------------------------
-- Records of role
-- ----------------------------
BEGIN;
INSERT INTO `role` VALUES (1, 'ROLE_ADMIN');
INSERT INTO `role` VALUES (2, 'ROLE_role_a');
INSERT INTO `role` VALUES (3, 'ROLE_add');
INSERT INTO `role` VALUES (4, 'ROLE_query');
INSERT INTO `role` VALUES (5, 'ROLE_update');
INSERT INTO `role` VALUES (6, 'ROLE_delete');
COMMIT;
-- ----------------------------
-- Table structure for role_permission
-- ----------------------------
DROP TABLE IF EXISTS `role_permission`;
CREATE TABLE `role_permission` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`role_id` bigint(20) DEFAULT NULL,
`permission_id` bigint(20) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=11 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- ----------------------------
-- Records of role_permission
-- ----------------------------
BEGIN;
INSERT INTO `role_permission` VALUES (1, 1, 1);
INSERT INTO `role_permission` VALUES (2, 1, 2);
INSERT INTO `role_permission` VALUES (3, 1, 3);
INSERT INTO `role_permission` VALUES (4, 1, 4);
INSERT INTO `role_permission` VALUES (5, 1, 5);
INSERT INTO `role_permission` VALUES (6, 2, 5);
INSERT INTO `role_permission` VALUES (7, 3, 2);
INSERT INTO `role_permission` VALUES (8, 4, 1);
INSERT INTO `role_permission` VALUES (9, 5, 3);
INSERT INTO `role_permission` VALUES (10, 6, 4);
COMMIT;
-- ----------------------------
-- Table structure for user
-- ----------------------------
DROP TABLE IF EXISTS `user`;
CREATE TABLE `user` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`username` varchar(255) DEFAULT NULL,
`password` varchar(255) DEFAULT NULL,
`mobile` varchar(255) DEFAULT NULL,
`current_address` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- ----------------------------
-- Records of user
-- ----------------------------
BEGIN;
INSERT INTO `user` VALUES (1, 'admin', '$2a$10$u2CoYP0Y0L2tS3nCEahyvumQTJzk./Nhv9lwgIrOmFVJMFnXv9MJ.', '18777777777', 'hz');
INSERT INTO `user` VALUES (2, 'query', '$2a$10$D8oNcK0t2fodGZfcNDxUYO7k8GAksuHHXSRhdPWN30clA3dJrxs/C', '18777777777', 'hz');
INSERT INTO `user` VALUES (3, 'add', '$2a$10$nkB3o3GBv0s0nmuj21Btx.BM2tUzjfY4ZGAblsgF0k050OKfLguS2', '18777777777', 'hz');
INSERT INTO `user` VALUES (4, 'update', '$2a$10$oKRIGw9KfGsKkvgVbL3Qo.BLZjr9jD1j7fm9aUK403sKHON/MNib2', '18777777777', 'hz');
INSERT INTO `user` VALUES (5, 'delete', '$2a$10$CeKB/NmMhwTghdXUFEkrBea.a.WccS8dEJMPqFgi9CoXfhSVDxA.2', '18777777777', 'hz');
INSERT INTO `user` VALUES (6, 'a', '$2a$10$XXMNUhvexLAC/9Hc9QpKJOB5JQrUTp8mRF8Pvsi1YAuAr330v3l5.', '18777777777', 'hz');
COMMIT;
-- ----------------------------
-- Table structure for user_role
-- ----------------------------
DROP TABLE IF EXISTS `user_role`;
CREATE TABLE `user_role` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`user_id` bigint(20) DEFAULT NULL,
`role_id` bigint(20) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- ----------------------------
-- Records of user_role
-- ----------------------------
BEGIN;
INSERT INTO `user_role` VALUES (1, 1, 1);
INSERT INTO `user_role` VALUES (2, 6, 2);
INSERT INTO `user_role` VALUES (3, 3, 3);
INSERT INTO `user_role` VALUES (4, 2, 4);
INSERT INTO `user_role` VALUES (5, 4, 5);
INSERT INTO `user_role` VALUES (6, 5, 6);
COMMIT;
SET FOREIGN_KEY_CHECKS = 1;
Spring Security 在认证过程中,会调用 UserDetailsService 的 loadUserByUsername 方法获取一个 UserDetails,从而获取用户信息。所以要实现自定义用户信息,只需自定义 UserDetailsService 并重写 loadUserByUsername 方法即可。在方法内部可以通过 MySQL 查找用户信息。
在 loadUserByUsername 方法中,通过 username 从数据库中查找对应的用户信息,若用户不存在,抛出一个 UsernameNotFoundException 即可。相应地,查到用户所拥有的权限和角色,并设置到UserDetails中。需要注意的是,角色 roles 和权限 authorities 在 Spring Security 中并无实质区别,只不过角色必须已 ROLE_ 为前缀。
在 loadUserByUsername 方法中,需要把数据库查出来的用户对象转化成 UserDetails,还有一种方式是自定义的用户对象 UserDTO 实现 UserDetails 接口,这样的话loadUserByUsername中直接返回 UserDTO 即可。下面示例代码展示的是第一种方式。
- 自定义 UserDetailsService
@Service
public class DemoUserDetailsService implements UserDetailsService {
private final UserMapper userMapper;
@Autowired
public DemoUserDetailsService(UserMapper userMapper) {
this.userMapper = userMapper;
}
/**
* 此方法实现自定义用户信息
*/
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 数据库读取用户
UserDTO userDTO = userMapper.getUserByUsername(username);
// 用户不存在
if (userDTO == null) {
throw new UsernameNotFoundException("用户名或密码错误");
}
// 权限authorities和角色roles 概念一致. 一并设置进UserDetails
// roles默认必须以 "ROLE_" 开头, 否则需要自定义 accessDecisionManager 注入roleVoter, 修改roleVoter的rolePrefix属性
List<String> authoritiesAndRoles = new ArrayList<>();
// 用户权限
List<PermissionEntity> permissionList = userMapper.getPermissionByUsername(username);
// 用户角色
List<RoleEntity> roleList = userMapper.getRoleByUsername(username);
permissionList.forEach(element -> authoritiesAndRoles.add(element.getPermission()));
roleList.forEach(element -> {
if (element.getRoleName() != null && element.getRoleName().startsWith("ROLE_")) {
authoritiesAndRoles.add(element.getRoleName());
}
});
return User.builder().username(userDTO.getUsername()).password(userDTO.getPassword())
.authorities(createAuthorities(authoritiesAndRoles))
.build();
}
/**
* 组装用户权限信息
*/
private List<SimpleGrantedAuthority> createAuthorities(List<String> roleList){
String[] roles = roleList.toArray(new String[0]);
List<SimpleGrantedAuthority> simpleGrantedAuthorities = new ArrayList<>();
for (String role : roles) {
simpleGrantedAuthorities.add(new SimpleGrantedAuthority(role));
}
return simpleGrantedAuthorities;
}
}
- 在WebSecurityConfig中添加自定义的UserDetailsService,并配置AuthenticationManagerBuilder
@Autowired
private DemoUserDetailsService demoUserDetailsService;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(demoUserDetailsService).passwordEncoder(passwordEncoder());
}
至此,可以通过存储在数据库中的用户信息认证访问受保护资源。
5、访问权限控制(授权)
#对于服务端资源来说,有时候不同的资源需要不同的权限才能访问。授权的目的就是为了控制不同的用户能够访问不同的资源。上一节中数据库添加了几个用户,分别授予了不同的权限:
- "admin"用户,拥有"ROLE_ADMIN"角色,以及所有权限
- "query"用户,拥有"ROLE_query"角色,"auth_query"权限
- "add"用户,拥有"ROLE_add"角色,"auth_add"权限
- "delete"用户,拥有"ROLE_delete"角色,"auth_delete"权限
- "update"用户,拥有"ROLE_update"角色,"auth_update"权限
为了便于测试,创建order相关资源
@RestController
@RequestMapping("/order")
public class OrderController {
/**
* 必须拥有auth_query权限 或者ADMIN角色
* hasAnyAuthority('ROLE_ADMIN')等同于hasAnyRole('ADMIN')
*/
@GetMapping("/query")
public String queryOrder() {
return "查看订单";
}
/**
* 必须拥有auth_add权限 或者ADMIN角色
*
*/
@GetMapping("/add")
public String addOrder() {
return "添加订单";
}
/**
* 必须拥有auth_delete权限 或者ADMIN角色
*/
@GetMapping("/delete")
public String deleteOrder() {
return "删除订单";
}
/**
* 必须拥有auth_update权限 或者ADMIN角色
*/
@GetMapping("/update")
public String updateOrder() {
return "修改订单";
}
}
5.1 基于web授权
在WebSecurityConfig配置中,可以针对每个资源路径配置所需的角色及权限。示例中,admin角色可以访问配置的所有资源,而query,add,delete,update角色只能访问对应的资源,示例代码如下:
// 拥有ROLE_ADMIN或者ROLE_query角色。 hasRole("query")对应角色为 "ROLE_query"
.antMatchers("/order/query").hasAnyRole("ADMIN", "query")
.antMatchers("/order/add").hasAnyRole("ADMIN", "add")
.antMatchers("/order/delete").hasAnyRole("ADMIN", "delete")
.antMatchers("/order/update").hasAnyRole("ADMIN", "update")
启动项目,使用admin用户登录,然后浏览器输入 http://localhost:8080/order/query,http://localhost:8080/order/add … 地址都能正确获取对应资源。退出登录,使用query用户登录,能正常获取 http://localhost:8080/order/query,但是访问 http://localhost:8080/order/add 的时候,服务器返回 “type=Forbidden, status=403” 权限错误。
5.2 注解基于方法授权
Spring Security 也支持在方法上通过注解,给每个方法设置对应权限。要启动方法授权,需要在 WebSecurityConfig 配置类上加上 @EnableGlobalMethodSecurity 注解。示例配置:
// 此注解为spring security指定的权限控制开关
@EnableGlobalMethodSecurity(securedEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
// 支持 JSR-250的注解,提供下列注解
// @DenyAll: 拒绝所有访问
// @RolesAllowed({"USER", "ADMIN"}):拥有ROLE_USER或ROLE_ADMIN即可访问
// @PermitAll: 允许所有访问
@EnableGlobalMethodSecurity(jsr250Enabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
// 此注解为spring指定的权限控制开关,支持方法级的安全,提供一下四种注解
// @PreAuthorize:进入方法之前验证授权。
// @PostAuthorize:在方法执行后再进行权限验证。
// @PreFilter:对集合类型的参数执行过滤,移除结果为false的元素
// @PostFilter:对集合类型的返回值进行过滤,移除结果为false的元素
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
在这里选择配置 @EnableGlobalMethodSecurity(prePostEnabled = true) 。修改 order 资源接口,添加方法级授权。至此实现了给方法绑定权限的效果。方法授权颗粒度更小,对于复杂的权限控制,个人觉得这种方法用起来可能会更方便。
@RestController
@RequestMapping("/order")
public class OrderController {
/*
* 注解基于方法授权
*
* 对于spring security, authority和role 概念一致, 都可认为普通字符串.
* authority: 不以 ROLE_ 开头
* role: 必须以 ROLE_ 开头
*
* 示例: 假设用户拥有 authority: a1, a2; role: ROLE_r1, ROLE_r2
* 可以配置:
* hasAnyAuthority('a1', 'a2')
* hasAnyRole('r1', 'r2') (不必加上ROLE_)
* 也可以把以'ROLE_'开头的role设置导authority中:
* hasAnyAuthority('a1', 'ROLE_r1')
*
*/
/**
* 必须拥有auth_query权限 或者ADMIN角色
* hasAnyAuthority('ROLE_ADMIN')等同于hasAnyRole('ADMIN')
*/
@GetMapping("/query")
@PreAuthorize("hasAnyAuthority('auth_query', 'ROLE_ADMIN')")
public String queryOrder() {
return "查看订单";
}
/**
* 必须拥有auth_add权限 或者ADMIN角色
*
*/
@GetMapping("/add")
@PreAuthorize("hasAnyAuthority('auth_add', 'ROLE_ADMIN')")
public String addOrder() {
return "添加订单";
}
/**
* 必须拥有auth_delete权限 或者ADMIN角色
*/
@GetMapping("/delete")
@PreAuthorize("hasAnyAuthority('auth_delete', 'ROLE_ADMIN')")
public String deleteOrder() {
return "删除订单";
}
/**
* 必须拥有auth_update权限 或者ADMIN角色
*/
@GetMapping("/update")
@PreAuthorize("hasAnyAuthority('auth_update', 'ROLE_ADMIN')")
public String updateOrder() {
return "修改订单";
}
}
6、Spring Security 基本原理
6.1 Spring Security 过滤器链
Spring Security 实现 web 安全的核心是其内置的一系列 servlet 过滤器。Spring Security 内部维护了一个过滤器链 filter chain,在这个 filter chain 中每一个 filter 都有自己的功能,并且我们可以根据具体需求新增或减少 filter。下面展示过滤器链中主要的几个过滤器。
- SecurityContextPersistenceFilter:在请求开始时从 SecurityContextRepository 获取 SecurityContext 并设置到 SecurityContextHolder。在请求完成之后能将 SecurityContextHolder 中的 SecurityContext 重新保存到 SecurityContextRepository 中。然后清空 securityContextHolder中的SecurityContext
- CsrfFilter:用于防止 csrf 攻击的过滤器,在 spring4 中默认开启
- LogoutFilter:用于处理退出操作的过滤器
- UsernamePasswordAuthenticationFilter:用于处理来自表单提交的认证。表单提交username和password后的一系列认证流程在此过滤器完成
- AnonymousAuthenticationFilter:用于处理匿名访问,对于匿名访问,也会走 Spring Security 认证流程,不同的是身份为匿名
- FilterSecurityInterceptor:这个过滤器用于处理 web 授权,可以对 url 进行拦截并判断授权状态。
- ExceptionTranslationFilter:此过滤器会将产生的异常交给特定的处理类去处理
- FilterSecurityInterceptor:安全拦截过滤器类,获取当前请求 url
6.2 springSecurityFilterChain
当 Spring Security 初始化时,Spring 会创建一个名称为 springSecurityFilterChain 的过滤器,它的类型为 org.springframework.security.web.FilterChainProxy。查看源码可以发现,当在一个配置类中添加 @EnableWebSecurity 注解时,会加载 WebSecurityConfiguration 配置类,而在此配置类中会创建 springSecurityFilterChain 对象。
/**
* Creates the Spring Security Filter Chain
* @return the {@link Filter} that represents the security filter chain
* @throws Exception
*/
// DEFAULT_FILTER_NAME: 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);
}
// 获得 springSecurityFilterChain
return webSecurity.build();
}
webSecurity.build() 也是通过 WebSecurity 的 performBuild 方法获得 springSecurityFilterChain
public final class WebSecurity extends
AbstractConfiguredSecurityBuilder<Filter, WebSecurity> implements
SecurityBuilder<Filter>, ApplicationContextAware {
// ... 省略代码
@Override
protected Filter performBuild() throws Exception {
Assert.state(
!securityFilterChainBuilders.isEmpty(),
() -> "At least one SecurityBuilder<? extends SecurityFilterChain> needs to be specified. "
+ "Typically this done by adding a @Configuration that extends WebSecurityConfigurerAdapter. "
+ "More advanced users can invoke "
+ WebSecurity.class.getSimpleName()
+ ".addSecurityFilterChainBuilder directly");
int chainSize = ignoredRequests.size() + securityFilterChainBuilders.size();
List<SecurityFilterChain> securityFilterChains = new ArrayList<>(
chainSize);
for (RequestMatcher ignoredRequest : ignoredRequests) {
securityFilterChains.add(new DefaultSecurityFilterChain(ignoredRequest));
}
for (SecurityBuilder<? extends SecurityFilterChain> securityFilterChainBuilder : securityFilterChainBuilders) {
securityFilterChains.add(securityFilterChainBuilder.build());
}
// 通过 securityFilterChains 创建 springSecurityFilterChain
FilterChainProxy filterChainProxy = new FilterChainProxy(securityFilterChains);
if (httpFirewall != null) {
filterChainProxy.setFirewall(httpFirewall);
}
filterChainProxy.afterPropertiesSet();
Filter result = filterChainProxy;
if (debugEnabled) {
logger.warn("\n\n"
+ "********************************************************************\n"
+ "********** Security debugging is enabled. *************\n"
+ "********** This may include sensitive information. *************\n"
+ "********** Do not use in a production system! *************\n"
+ "********************************************************************\n\n");
result = new DebugFilter(filterChainProxy);
}
postBuildAction.run();
return result;
}
// ... 省略代码
}
springSecurityFilterChain 类型是 FilterChainProxy。通过类名也可以猜测它只是个代理,事实也是如此,真正起作用的是其内部 SecurityFilterChain 里面包含的各个Filter。这些Filter就是上面所列举的各个过滤器,是 Spring Security 的核心,它们都被 Spring 所管理。
FilterChainProxy 中的 SecurityFilterChain 集合:
// SecurityFilterChain 集合
private List<SecurityFilterChain> filterChains;
// ... 省略代码
// 获得 SecurityFilterChain 中的各个 Filter
private List<Filter> getFilters(HttpServletRequest request) {
for (SecurityFilterChain chain : filterChains) {
if (chain.matches(request)) {
return chain.getFilters();
}
}
return null;
}
6.3 DelegatingFilterProxy
Spring Security 的入口 Filter 是上面介绍的 springSecurityFilterChain,事实上 springSecurityFilterChain 也是被 DelegatingFilterProxy 所委托的。DelegatingFilterProxy 存在于 spring-web 模块中,实现了Filter接口。
6.4 总结
- spring security 的核心是基于filter
- 入口filter是springSecurityFilterChain(它会被DelegatingFilterProxy委托来执行过滤任务)
- springSecurityFilterChain 类型是 FilterChainProxy
- FilterChainProxy里边有一个SecurityFilterChain集合,doFilter的时候会从其中取。
- SecurityFilterChain通过 WebSecurity 的 performBuild 方法获得
7、Spring Security 认证流程
此小节以表单登录为例,简单介绍 Spring Security 认证的流程以及认证过程中用到的组件。
- 接受登录认证HTTP请求
Spring Security 定义了一系列过滤器来处理客户端请求,不同的过滤器分别处理不同的事情。比如 UsernamePasswordAuthenticationFilter 就是其中一个用来处理表单提交认证请求的过滤器。当用户进行表单登录认证时,UsernamePasswordAuthenticationFilter 会对其进行拦截处理;再比如 BasicAuthenticationFilter 就是用来处理 HTTP BASIC 认证的过滤器。 - 封装Authentication
基于不同的认证方式,Spring Security 会创建不同的 Authentication 的实现类。Authentication 是一个接口,用来表示用户的认证信息,里面还包含用户权限等信息。以表单登录为例,当用户提交用户名和密码之后,UsernamePasswordAuthenticationFilter 会将此用户名和密码封装成一个 Authentication,具体实现类为 UsernamePasswordAuthenticationToken 。
下图为 Authentication 不同的实现类。
- Authentication 传递给 AuthenticationManager 的实现类 ProviderManager
UsernamePasswordAuthenticationFilter 过滤器并不直接进行认证操作,而是把封装好的 UsernamePasswordAuthenticationToken 交由 AuthenticationManager (认证管理器)进行认证操作。AuthenticationManager 是一个接口,具体实现类为ProviderManager。authenticate 方法返回认证结果。若认证失败,此方法抛出 AuthenticationException 异常,若认证成功则返回用户认证信息 Authentication,里面新添加了用户权限等信息。
public interface AuthenticationManager {
Authentication authenticate(Authentication authentication)
throws AuthenticationException;
}
- ProviderManager 委托 AuthenticationProvider 进行认证
UsernamePasswordAuthenticationToken 到了 ProviderManager 之后,ProviderManager 会委托 AuthenticationProvider 进行认证。Spring Security维护着一个 AuthenticationProvider 列表,ProviderManager 会依次调用每一个 AuthenticationProvider,找到此次认证支持的 AuthenticationProvider,并调用其 authenticate 认证方法,获得认证结果。 每个AuthenticationProvider 会实现supports()方法来表明自己支持的认证方式。示例代码如下:
public class ProviderManager implements AuthenticationManager, MessageSourceAware, InitializingBean {
// ProviderManager 中的认证方法,会委托 AuthenticationProvider 进行认证
public Authentication authenticate(Authentication authentication)
throws AuthenticationException {
for (AuthenticationProvider provider : getProviders()) {
// 只有支持的 AuthenticationProvider,才会调用其 authenticate(Authentication authentication) 方法
if (!provider.supports(toTest)) {
continue;
}
try {
// 委托受支持的 AuthenticationProvider 进行认证,并返回结果
result = provider.authenticate(authentication);
if (result != null) {
copyDetails(authentication, result);
break;
}
}
// ... 省略代码
}
}
}
// AuthenticationProvider
public interface AuthenticationProvider {
// 具体的认证实现方法
Authentication authenticate(Authentication authentication) throws AuthenticationException;
// 此方法表明当前 AuthenticationProvider 所支持的认证方式
boolean supports(Class<?> authentication);
}
以表单登录为例,ProviderManager 会找到支持 UsernamePasswordAuthenticationToken 认证的 AuthenticationProvider(DaoAuthenticationProvider),并调用其 authenticate(Authentication authentication) 方法进行认证操作。 DaoAuthenticationProvider 的 authenticate方法在其父类 AbstractUserDetailsAuthenticationProvider 中可以找到,示例代码如下:
public abstract class AbstractUserDetailsAuthenticationProvider implements
AuthenticationProvider, InitializingBean, MessageSourceAware {
// ...省略代码
public Authentication authenticate(Authentication authentication)
throws AuthenticationException {
// 具体认证操作
UserDetails user = this.userCache.getUserFromCache(username);
if (user == null) {
cacheWasUsed = false;
try {
// 取回用户信息
// 具体实现在 DaoAuthenticationProvider
user = retrieveUser(username,
(UsernamePasswordAuthenticationToken) authentication);
}
// ...省略代码
}
// ...省略代码
// 返回认证结果 UsernamePasswordAuthenticationToken 对象,包含了用户权限等信息
return createSuccessAuthentication(principalToReturn, authentication, user);
}
// ...省略代码
protected Authentication createSuccessAuthentication(Object principal,
Authentication authentication, UserDetails user) {
UsernamePasswordAuthenticationToken result = new UsernamePasswordAuthenticationToken(
principal, authentication.getCredentials(),
authoritiesMapper.mapAuthorities(user.getAuthorities()));
result.setDetails(authentication.getDetails());
return result;
}
}
可以看到认证 authenticate 方法中通过 retrieveUser 方法获得用户信息,retrieveUser 示例代码如下,可以看到其内部调用了 UserDetailsService 的 loadUserByUsername 方法获取用户信息。毫无疑问,我们可以重写此方法来达到自定义用户信息的目的。
public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {
// ... 省略代码
protected final UserDetails retrieveUser(String username,
UsernamePasswordAuthenticationToken authentication)
throws AuthenticationException {
prepareTimingAttackProtection();
try {
// 重写此方法 自定义用户信息
UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
if (loadedUser == null) {
throw new InternalAuthenticationServiceException(
"UserDetailsService returned null, which is an interface contract violation");
}
return loadedUser;
}
// ... 省略代码
}
// ... 省略代码
}
至此,认证 authenticate 方法结束,返回认证结果 Authentication。对于表单登录,具体实现类为 UsernamePasswordAuthenticationToken。和传入时的 UsernamePasswordAuthenticationToken 不一样,返回的结果包含的用户信息更多。
- 认证结果处理
相关过滤器获得认证结果 Authentication 之后,会将其设置到安全上下文中。至此,认证过程结束。
SecurityContextHolder.getContext().setAuthentication(authResult);
- 认证流程图
8、Spring Security 授权流程
Spring Security 包含两种授权方式,web 授权和方法授权。web授权通过url拦截进行授权,对应的过滤器为 FilterSecurityInterceptor;方法授权则是通过 MethodSecurityInterceptor 实现,MethodSecurityInterceptor 实现了 Interceptor 接口,是一个拦截器。它们都会调用 AccessDecisionManager 进行授权决策。如果同时使用两种授权方式,会先执行 web 授权,再执行方法授权,如果最终决策通过,则被允许访问资源,否则禁止访问。下面以 web 授权为例介绍其授权流程。
- 请求到达 FilterSecurityInterceptor,并调用其父类 beforeInvocation 方法
经过前面一系列过滤器,请求最终会在 FilterSecurityInterceptor 被拦截,并校验其权限。FilterSecurityInterceptor 继承 AbstractSecurityInterceptor 类,并实现了 Filter 接口,是一个过滤器。其 doFilter 方法首先会调用自身一个 invoke 方法,在 invoke 方法中首先调用了父类的 beforeInvocation 方法,然后调用 FilterChain 的 doFilter 方法获取资源,再然后调用父类的 afterInvocation 方法。毫无疑问,beforeInvocation方法中实现了授权过程。
FilterSecurityInterceptor 中 invoke 方法示例代码:
public class FilterSecurityInterceptor extends AbstractSecurityInterceptor implements
Filter {
// ... 省略代码
// doFilter 中调用 invoke 方法
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
FilterInvocation fi = new FilterInvocation(request, response, chain);
invoke(fi);
}
// invoke 方法,会调用父类 AbstractSecurityInterceptor 的 beforeInvocation 完整授权校验
public void invoke(FilterInvocation fi) throws IOException, ServletException {
if ((fi.getRequest() != null)
&& (fi.getRequest().getAttribute(FILTER_APPLIED) != null)
&& observeOncePerRequest) {
// filter already applied to this request and user wants us to observe
// once-per-request handling, so don't re-do security checking
fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
}
else {
// first time this request being called, so perform security checking
if (fi.getRequest() != null && observeOncePerRequest) {
fi.getRequest().setAttribute(FILTER_APPLIED, Boolean.TRUE);
}
// 父类 beforeInvocation 方法。
InterceptorStatusToken token = super.beforeInvocation(fi);
try {
// 授权访问通过,获得资源
fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
}
finally {
super.finallyInvocation(token);
}
super.afterInvocation(token, null);
}
}
// ... 省略代码
}
AbstractSecurityInterceptor 中 beforeInvocation 方法会调用AccessDecisionManager 的 decide 方法
protected InterceptorStatusToken beforeInvocation(Object object) {
// ... 省略代码
// 获得 访问当前资源所需的权限
Collection<ConfigAttribute> attributes = this.obtainSecurityMetadataSource()
.getAttributes(object);
// ... 省略代码
// 当前认证的用户信息 Authentication
Authentication authenticated = authenticateIfRequired();
// Attempt authorization
try {
// 授权决策操作 -> 调用 AccessDecisionManager 的 decide 方法完成
this.accessDecisionManager.decide(authenticated, object, attributes);
}
catch (AccessDeniedException accessDeniedException) {
publishEvent(new AuthorizationFailureEvent(object, attributes, authenticated,
accessDeniedException));
throw accessDeniedException;
}
// ... 省略代码
}
- 获取当前资源的访问策略
通过上面 beforeInvocation 方法可以看到,过滤器会先去获取访问当前资源所需的权限。
// 获得 访问当前资源所需的权限
Collection<ConfigAttribute> attributes = this.obtainSecurityMetadataSource().getAttributes(object);
上面代码首先通过 obtainSecurityMetadataSource 方法获取到 SecurityMetadataSource 对象,具体来说是其实现类 DefaultFilterInvocationSecurityMetadataSource。SecurityMetadataSource 其实是就是我们配置的各种访问策略,在当前 Spring Boot 项目中就是 WebSecurityConfig 配置中的如下代码(示例代码):
http
// ... 省略代码
.authorizeRequests()
.antMatchers("/login").permitAll()
.antMatchers("/order/query").hasAnyRole("ADMIN", "query")
// ... 省略代码
- AccessDecisionManager 投票决策,完成授权操作
在 beforeInvocation 方法中,委托 AccessDecisionManager 授权校验。AccessDecisionManager 定义了 decide 方法来决定本次请求是否有访问对应受保护资源的权限。
// 授权决策操作 -> 调用 AccessDecisionManager 的 decide 方法完成
this.accessDecisionManager.decide(authenticated, object, attributes);
查看decide方法源码可以看到,AccessDecisionManager通过 投票 的方式来确定本次请求能否访问资源。AccessDecisionManager 包含了数个 AccessDecisionVoter ,可以通过调用其 vote 方法获得投票结果,投票结果包括:同意/拒绝/弃权。 若最终授权访问不通过,会在 decide 方法中抛出 AccessDeniedException 异常。
AccessDecisionManager 内置有三个实现类,分别是AffirmativeBased,ConsensusBased,UnanimousBased,这三个实现类对于投票结果处理策略是不一样的。
AffirmativeBased:“Denies access only if there was a deny vote AND no affirmative votes”,当没有赞成票且存在反对票的情况,拒绝访问,其他情况都同意访问。比如存在一个赞成票,或者全部都是弃权票的情况,也同意访问。 Spring Security 默认使用 AffirmativeBased。
ConsensusBased:赞成票 > 反对票 的情况同意访问;若 赞成票 == 反对票,按照 allowIfEqualGrantedDeniedDecisions 属性判断,若该值为 true,则同意访问。该值默认为 true。
UnanimousBased:只有在不存在反对票的情况下,才同意访问。
AffirmativeBased 以此为例,示例代码:
public class AffirmativeBased extends AbstractAccessDecisionManager {
// ... 省略代码
public void decide(Authentication authentication, Object object,
Collection<ConfigAttribute> configAttributes) throws AccessDeniedException {
int deny = 0;
for (AccessDecisionVoter voter : getDecisionVoters()) {
int result = voter.vote(authentication, object, configAttributes);
if (logger.isDebugEnabled()) {
logger.debug("Voter: " + voter + ", returned: " + result);
}
switch (result) {
// 只要存在同意票,则同意访问
case AccessDecisionVoter.ACCESS_GRANTED:
return;
// 存在反对票的情况,deny++
case AccessDecisionVoter.ACCESS_DENIED:
deny++;
break;
default:
break;
}
}
if (deny > 0) {
throw new AccessDeniedException(messages.getMessage(
"AbstractAccessDecisionManager.accessDenied", "Access is denied"));
}
// To get this far, every AccessDecisionVoter abstained
checkAllowIfAllAbstainDecisions();
}
}
- 若授权访问未通过,decide方法会抛出 AccessDeniedException 异常;若授权访问通过,decide 方法执行结束,接下来 FilterSecurityInterceptor 过滤器继续执行,正常返回所请求的资源。 至此,web 授权流程结束。
fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
9、Spring Security Session
由于http协议是无状态的,为了避免每次用户操作都需要认证,一种解决方式是把用户信息保存在会话session中。默认情况下,Spring Security会为每个登录成功的用户创建一个session,并保存在内存中,然后向客户端以cookie的形式返回sessionid。当浏览器再次访问服务端的时候,会携带sessionid,服务端获取sessionid便会以此查找内存中的用户信息,从而判断用户的登录状态。
9.1 session生成策略
session创建策略有以下几种方式可以选择:
SessionCreationPolicy | description |
---|---|
IF_REQUIRED | (默认)登录时创建session |
ALWAYS | 总是创建session |
NEVER | 不创建session,但是会使用已存在的session |
STATELESS | 不创建session,不使用session |
配置session生成策略示例代码:
@Override
protected void configure(HttpSecurity http) throws Exception {
http.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
}
9.2 session超时
由于登录成功之后用户信息保存在session中,我们可以通过设置session超时时间来自动下线长时间未操作的用户。在Spring Boot中设置session的超时时间十分简单,只需要在application.yml中添加:
server:
servlet:
session:
timeout: PT10M
上面示例配置表示session超时时间为10分钟。即用户超过十分钟未操作,则session失效。具体设置的方法可以参照Java8 java.time.Duration类。
另外,我们可以设置session超时之后的跳转路径,示例代码:
http.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.invalidSessionUrl("/timeout");
9.3 集群环境下session共享
在集群环境中,必须保证每个服务之间的session信息共享,否则可能会由于session不共享而导致重新登录。解决方案很多,比如session同步,多台应用服务器之间同步session;又比如session绑定机器,同一个会话的请求都发送到同一台机器上;再比如session集中存储,在一台单独的服务器中集中存储管理所有session,比如redis。下面介绍session集中存储的方案。
9.3.1 spring-session 共享
spring-session是spring提供的,用于处理集群环境下session共享的问题。spring-session可以将用户session数据保存到redis中。spring-session可以解决同域名下服务器集群之间session共享的问题,并不能解决跨域session共享问题。spring-session使用非常简单,只需要在application.yml配置文件中添加redis和session配置即可。Spring Security会在认证成功之后,自动把用户信息存入配置的redis数据库中。
在application.yml配置示例
spring:
redis:
database: 3
host: 127.0.0.1
port: 6379
password: password
session:
store-type: redis
redis:
flush-mode: on_save
namespace: spring:session
基于session的认证方式,虽然安全性较高,且更容易对会话进行管理,但是会存在几个弊端。由于所有用户信息需要保存在服务端,所以对服务端内存的压力较大。其次,由于session是基于cookie的,会存在跨域问题,并且有可能会受到CSRF跨站伪造请求攻击。