Spring Boot 集成 Spring Security 入门教程

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 认证的流程以及认证过程中用到的组件。

  1. 接受登录认证HTTP请求
        Spring Security 定义了一系列过滤器来处理客户端请求,不同的过滤器分别处理不同的事情。比如 UsernamePasswordAuthenticationFilter 就是其中一个用来处理表单提交认证请求的过滤器。当用户进行表单登录认证时,UsernamePasswordAuthenticationFilter 会对其进行拦截处理;再比如 BasicAuthenticationFilter 就是用来处理 HTTP BASIC 认证的过滤器。
  2. 封装Authentication
        基于不同的认证方式,Spring Security 会创建不同的 Authentication 的实现类。Authentication 是一个接口,用来表示用户的认证信息,里面还包含用户权限等信息。以表单登录为例,当用户提交用户名和密码之后,UsernamePasswordAuthenticationFilter 会将此用户名和密码封装成一个 Authentication,具体实现类为 UsernamePasswordAuthenticationToken
        下图为 Authentication 不同的实现类。
    Authentication 实现类
  3. Authentication 传递给 AuthenticationManager 的实现类 ProviderManager
        UsernamePasswordAuthenticationFilter 过滤器并不直接进行认证操作,而是把封装好的 UsernamePasswordAuthenticationToken 交由 AuthenticationManager (认证管理器)进行认证操作。AuthenticationManager 是一个接口,具体实现类为ProviderManager。authenticate 方法返回认证结果。若认证失败,此方法抛出 AuthenticationException 异常,若认证成功则返回用户认证信息 Authentication,里面新添加了用户权限等信息。
public interface AuthenticationManager {
  Authentication authenticate(Authentication authentication)
    throws AuthenticationException;
}
  1. 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 不一样,返回的结果包含的用户信息更多。

  1. 认证结果处理
        相关过滤器获得认证结果 Authentication 之后,会将其设置到安全上下文中。至此,认证过程结束。
SecurityContextHolder.getContext().setAuthentication(authResult);
  1. 认证流程图
        
    在这里插入图片描述

8、Spring Security 授权流程

    Spring Security 包含两种授权方式,web 授权和方法授权。web授权通过url拦截进行授权,对应的过滤器为 FilterSecurityInterceptor;方法授权则是通过 MethodSecurityInterceptor 实现,MethodSecurityInterceptor 实现了 Interceptor 接口,是一个拦截器。它们都会调用 AccessDecisionManager 进行授权决策。如果同时使用两种授权方式,会先执行 web 授权,再执行方法授权,如果最终决策通过,则被允许访问资源,否则禁止访问。下面以 web 授权为例介绍其授权流程。

  1. 请求到达 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;
	}
	// ... 省略代码
}
  1. 获取当前资源的访问策略
        通过上面 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")
	// ... 省略代码
  1. 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();
	}
}
  1. 若授权访问未通过,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创建策略有以下几种方式可以选择:

SessionCreationPolicydescription
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跨站伪造请求攻击。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值