SpringSecurity 详解

一、概述

1. 简介

  • Spring Security is a framework that provides authentication, authorization, and protection against common attacks. With first class support for securing both imperative and reactive applications, it is the de-facto standard for securing Spring-based applications.
  • 功能:
    • 身份认证(authentication):验证谁正在访问系统资源,判断用户是否为合法用户。认证用户的常见方式是要求用户输入用户名和密码
    • 授权(authorization):用户进行身份认证后,系统会控制谁能访问哪些资源,这个过程叫做授权。用户无法访问没有权限的资源
    • 防御常见攻击:CSRF 等
  • 官方文档:https://docs.spring.io/spring-security/reference/index.html

2. HelloWorld

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
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.0</version>
</parent>

<dependencies>
<!-- web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- thymeleaf -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-springsecurity6</artifactId>
</dependency>
</dependencies>
  • IndexController
1
2
3
4
5
6
7
8
@Controller
public class IndexController {

@GetMapping("/")
public String index() {
return "index";
}
}
  • resources/templates/index.html
1
2
3
4
5
6
7
8
9
10
<html xmlns:th="https://www.thymeleaf.org">
<head>
<title>Hello Security!</title>
</head>
<body>
<h1>Hello Security</h1>
<!-- 会自动适应当前的上下文路径,如配置了context-path -->
<a th:href="@{/logout}">Log Out</a>
</body>
</html>
  • 此时访问 http://localhost:8080/test 会跳转到 SpringSecurity 提供的登录页,默认用户名是 user,密码在程序控制台

3. 在配置文件中指定用户

1
2
3
4
5
6
7
8
9
10
11
@ConfigurationProperties(prefix = "spring.security")
public class SecurityProperties {
private final Filter filter = new Filter();
private final User user = new User();

public static class User {
private String name = "user";
private String password = UUID.randomUUID().toString();
private List<String> roles = new ArrayList<>();
}
}

4. SpringSecurity 默认开启的功能

  • 访问应用程序的任何资源都需要进行身份验证,返回 401 未经授权,并重定向到登录页面
  • 提供一个默认用户 user,并生成一个随机密码记录在控制台上
  • 生成默认的登录表单和注销页面,提供基于表单的登录和注销流程
  • 处理跨站请求伪造(CORS)攻击和会话劫持攻击(CSRF)
  • 写入 Strict-Transport-Security 以确保 HTTPS;写入 X-Content-Type-Options 以处理嗅探攻击;写入 Cache Control 来保护经过身份验证的资源;写入 X-Frame-Option s以处理点击劫持攻击
  • CORS:它是浏览器的保护机制,只允许网页请求统一域名下的服务(协议、域名、端口号都一致)
  • CSRF:请求参数中有一个隐藏的 _csrf 字段

二、SpringSecurity 底层原理

  • Spring 提供了一个名为 DelegatingFilterProxy 的 Filter 实现
  • FilterChainProxy 是 SpringSecurity 提供的一个特殊的 Filter。FilterChainProxy 使用 SecurityFilterChain 来确定应该为当前请求调用哪些 Filter 实例
  • 这些注册到 SpringSecurity 的 Filter 实例可用于多种不同的目的,例如身份验证、授权、漏洞保护等。过滤器会按照特定的顺序执行,以保证它们在正确的时间被调用
  • 如:程序启动时,DefaultSecurityFilterChain 加载了 16 个 Filter

三、自定义认证配置

1. 基于内存的用户认证

  • WebSecurityConfig
1
2
3
4
5
6
7
8
9
10
11
@Configuration
@EnableWebSecurity // 开启自定义配置(Spring项目需要添加此注解,SpringBoot项目不需要)
public class WebSecurityConfig {
@Bean
public UserDetailsService userDetailsService() {
// 使用自定义配置后,配置文件里的用户就不生效了
InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
manager.createUser(User.withDefaultPasswordEncoder().username("test").password("123").roles("LIST").build());
return manager;
}
}

2. 基于数据库的用户认证

  • pom.xml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!-- mysql -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>3.5.5</version>
</dependency>
<!-- lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
  • application.yml
1
2
3
4
5
6
7
8
9
10
11
12
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/test
username: root
password: 123456
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
logging:
level:
org.springframework.security: debug
  • SysUser
1
2
3
4
5
6
7
8
9
@Data
public class SysUser {

@TableId(value = "id", type = IdType.AUTO)
private Integer id;
private String username;
private String password;
private Boolean isEnable;
}
  • UserDetailsServiceImpl
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Component
public class UserDetailsServiceImpl implements UserDetailsService {

@Resource
public SysUserService sysUserService;

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
SysUser sysUser = sysUserService.getOne(Wrappers.<SysUser>lambdaQuery().eq(SysUser::getUsername, username));
if (sysUser == null) {
throw new UsernameNotFoundException(username);
}
return User.builder()
.username(sysUser.getUsername())
.password(sysUser.getPassword())
.disabled(!sysUser.getIsEnable())
.authorities(AuthorityUtils.createAuthorityList("LIST", "GET", "ROLE_ADMIN"))
.build();
}
}
  • WebSecurityConfig
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Configuration
public class WebSecurityConfig {

/* SpringSecurity默认会添加如下配置 */
//@Bean
//public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// http
// // 开启csrf
// .csrf(Customizer.withDefaults())
// // 对所有请求开启授权保护
// .authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated())
// // 表单授权方式:生成默认表单登录页
// .httpBasic(Customizer.withDefaults())
// // 基本授权方式:弹出用户名密码登录框
// .formLogin(Customizer.withDefaults());
// return http.build();
//}

@Bean
public PasswordEncoder passwordEncoder() {
// https://docs.spring.io/spring-security/reference/features/authentication/password-storage.html
return new BCryptPasswordEncoder(4);
}
}
  • SysUserController
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@RestController
@RequestMapping("/user")
public class SysUserController {
@Resource
public SysUserService sysUserService;
@Resource
public PasswordEncoder passwordEncoder;

@GetMapping("/list")
public List<SysUser> getList() {
return sysUserService.list();
}

@GetMapping("/add")
public SysUser add(SysUser user) {
user.setPassword(passwordEncoder.encode(user.getPassword()));
sysUserService.save(user);
return user;
}
}

3. 自定义登陆界面

  • LoginController
1
2
3
4
5
6
7
8
@Controller
public class LoginController {

@GetMapping("/login")
public String login() {
return "login";
}
}
  • resources/templates/login.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!DOCTYPE html>
<html xmlns:th="https://www.thymeleaf.org">
<head>
<title>登录</title>
</head>
<body>
<h1>登录</h1>
<div th:if="${param.error}">
错误的用户名和密码.
</div>

<!-- 使用动态参数,表单中会自动生成_csrf隐藏字段,用于防止csrf攻击 -->
<form th:action="@{/login}" method="post">
<div>
<input type="text" name="username" placeholder="用户名"/>
</div>
<div>
<input type="password" name="password" placeholder="密码"/>
</div>
<input type="submit" value="登录"/>
</form>
</body>
</html>
  • WebSecurityConfig
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Configuration
public class WebSecurityConfig {

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.cors(withDefaults())
.authorizeRequests(authorize -> authorize.anyRequest().authenticated())
.formLogin(form -> {
form.loginPage("/login").permitAll() // 登录页面无需授权
.usernameParameter("username") // 自定义表单参数
.passwordParameter("password") // 自定义表单参数
.failureUrl("/login?error");
})
.httpBasic(withDefaults());
return http.build();
}

@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(4);
}
}

4. 获取用户数据

securitycontextholder

  • SysUserController
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@RestController
@RequestMapping("/user")
public class SysUserController {

@GetMapping("/get")
public SysUser get() {
SecurityContext securityContext = SecurityContextHolder.getContext();
Authentication authentication = securityContext.getAuthentication();
System.out.println(authentication.getName()); // admin
System.out.println(authentication.getAuthorities()); // [LIST, GET, ROLE_ADMIN]
System.out.println(authentication.getCredentials()); // null
System.out.println(authentication.getPrincipal()); // SysUser(...)
return (SysUser) authentication.getPrincipal();
}
}

四、授权

1. 基于配置的授权

1
2
3
4
5
6
7
8
9
10
11
12
13
http.authorizeRequests(authorize -> {
authorize
// 具有USER_LIST权限的用户可以访问/user/list
.requestMatchers("/user/list").hasAuthority("LIST")
// 具有USER_ADD权限的用户可以访问/user/add
.requestMatchers("/user/add").hasAuthority("ADD")
// 具有管理员角色的用户可以访问/user/**
.requestMatchers("/user/**").hasRole("ADMIN")
// 对所有请求开启授权保护
.anyRequest()
// 已认证的请求会被自动授权
.authenticated();
});

2. 基于方法的授权

  • WebSecurityConfig
1
@EnableMethodSecurity  // 开启方法授权
  • SysUserController
1
2
3
4
5
6
7
8
9
10
11
@PreAuthorize("hasRole('ADMIN') and authentication.name == 'admin'")
@GetMapping("/list")
public List<SysUser> getList() {
...
}

@PreAuthorize("hasAuthority('ADD')")
@GetMapping("/add")
public SysUser add(SysUser user) {
...
}

五、前后端分离

UsernamePasswordAuthenticationFilter

1. 登录成功后

1
2
3
4
5
6
7
8
9
10
11
public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler {

@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
Object principal = authentication.getPrincipal(); // 用户身份信息
String json = JSON.toJSONString(R.ok(principal));

response.setContentType("application/json;charset=UTF-8");
response.getWriter().println(json);
}
}

2. 登录失败后

1
2
3
4
5
6
7
8
9
10
11
public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler {

@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
String localizedMessage = exception.getLocalizedMessage();
String json = JSON.toJSONString(R.fail(localizedMessage));

response.setContentType("application/json;charset=UTF-8");
response.getWriter().println(json);
}
}

3. 登出成功后

1
2
3
4
5
6
7
8
9
10
public class MyLogoutSuccessHandler implements LogoutSuccessHandler {

@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
String json = JSON.toJSONString(R.ok());

response.setContentType("application/json;charset=UTF-8");
response.getWriter().println(json);
}
}

4. 请求未认证时

1
2
3
4
5
6
7
8
9
10
11
public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint {

@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
String localizedMessage = authException.getLocalizedMessage();
String json = JSON.toJSONString(R.fail(localizedMessage));

response.setContentType("application/json;charset=UTF-8");
response.getWriter().println(json);
}
}

5. 无权访问时

1
2
3
4
5
6
7
8
9
10
public class MyAccessDeniedHandler implements AccessDeniedHandler {

@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
String json = JSON.toJSONString(R.fail("没有权限"));

response.setContentType("application/json;charset=UTF-8");
response.getWriter().println(json);
}
}

6. Session 过期后

1
2
3
4
5
6
7
8
9
10
11
public class MySessionInformationExpiredStrategy implements SessionInformationExpiredStrategy {

@Override
public void onExpiredSessionDetected(SessionInformationExpiredEvent event) throws IOException, ServletException {
String json = JSON.toJSONString(R.fail("该账号已从其他设备登录"));

HttpServletResponse response = event.getResponse();
response.setContentType("application/json;charset=UTF-8");
response.getWriter().println(json);
}
}

7. 整体配置

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
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.cors(withDefaults())
.authorizeRequests(authorize -> authorize.anyRequest().authenticated())
.formLogin(form -> {
form.loginPage("/login").permitAll()
.successHandler(new MyAuthenticationSuccessHandler())
.failureHandler(new MyAuthenticationFailureHandler());
})
.logout(logout -> {
logout.logoutSuccessHandler(new MyLogoutSuccessHandler());
})
.exceptionHandling(exception -> {
exception.authenticationEntryPoint(new MyAuthenticationEntryPoint())
.accessDeniedHandler(new MyAccessDeniedHandler());
})
.sessionManagement(session -> {
session
.maximumSessions(1)
.expiredSessionStrategy(new MySessionInformationExpiredStrategy());
})
.httpBasic(withDefaults());
return http.build();
}

六、认证流程源码解析

authenticate

  • UsernamePasswordAuthenticationFilter
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
if (this.postOnly && !request.getMethod().equals("POST")) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
}
// 从request获取用户输入的用户名和密码
String username = obtainUsername(request);
username = (username != null) ? username.trim() : "";
String password = obtainPassword(request);
password = (password != null) ? password : "";
// 构建Token
UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username, password);
setDetails(request, authRequest);
// 开始认证
return this.getAuthenticationManager().authenticate(authRequest);
}
  • ProviderManager
1
2
3
4
5
6
7
8
9
10
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
......
for (AuthenticationProvider provider : getProviders()) {
......
// 每个Provider都认证下
result = provider.authenticate(authentication);
......
}
......
}
  • AbstractUserDetailsAuthenticationProvider
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
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
// 先从缓存中找
boolean cacheWasUsed = true;
UserDetails user = this.userCache.getUserFromCache(username);
if (user == null) {
cacheWasUsed = false;
// 获取真实用户(从内存、数据库等)
user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
}
// 预检查用户状态(是否过期、是否启用、是否锁定等)
this.preAuthenticationChecks.check(user);
// 验证用户密码是否匹配
additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
......
// 创建一个成功的身份验证对象
return createSuccessAuthentication(principalToReturn, authentication, user);
}


protected Authentication createSuccessAuthentication(Object principal, Authentication authentication, UserDetails user) {
UsernamePasswordAuthenticationToken result = UsernamePasswordAuthenticationToken.authenticated(principal, authentication.getCredentials(), this.authoritiesMapper.mapAuthorities(user.getAuthorities()));
result.setDetails(authentication.getDetails());
this.logger.debug("Authenticated user");
return result;
}
  • DaoAuthenticationProvider
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
......
// 获取用户【UserDetailsService】
UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
......
}


protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
......
// 密码比较【PasswordEncoder】
String presentedPassword = authentication.getCredentials().toString();
if (!this.passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
throw new BadCredentialsException(......));
}
......
}