Spring Security 概述

什么是 Spring Security

Spring Security 是一个专注于为 Java 应用程序提供身份验证和授权的框架它与 Spring 生态系统紧密集成是保护 Spring 应用的事实标准

  • 核心功能
    • 身份验证Authentication验证用户身份
    • 授权Authorization控制用户对资源的访问权限
    • 攻击防护防止 CSRFXSS会话固定等攻击
    • 密码加密支持多种密码编码方式
  • 主要优势
    • 与 Spring 无缝集成充分利用 Spring IoC 和 AOP
    • 高度可定制可以自定义认证和授权逻辑
    • 社区活跃文档丰富生态完善
    • 企业级安全经过大量生产环境验证

Spring Security 的核心概念

身份验证Authentication

验证用户是谁的过程通常通过用户名和密码进行

1
2
3
4
5
// 认证流程
1. 用户提供凭证用户名/密码
2. AuthenticationManager 验证凭证
3. 如果成功返回 Authentication 对象
4. 将 Authentication 存入 SecurityContext

授权Authorization

确定用户能做什么的过程基于角色或权限

1
2
3
4
// 授权方式
- 基于 URL 的授权/admin/** 需要 ADMIN 角色
- 基于方法的授权@PreAuthorize("hasRole('ADMIN')")
- 基于数据的授权只能访问自己的数据

核心组件

组件 职责
SecurityContextHolder 存储当前用户的安全上下文
Authentication 表示当前认证的用户信息
AuthenticationManager 处理身份验证请求
UserDetailsService 加载用户特定数据的核心接口
PasswordEncoder 密码加密和解密
GrantedAuthority 授予用户的权限

Spring Security 的工作原理

过滤器链

Spring Security 通过一系列过滤器来处理安全相关的请求

1
2
3
4
5
6
7
8
9
10
11
12
典型过滤器链顺序:
1. ChannelProcessingFilter - 确保请求使用正确的协议HTTP/HTTPS
2. CsrfFilter - CSRF 保护
3. LogoutFilter - 处理登出请求
4. UsernamePasswordAuthenticationFilter - 表单登录认证
5. BasicAuthenticationFilter - HTTP Basic 认证
6. RequestCacheAwareFilter - 缓存请求
7. SecurityContextHolderAwareRequestFilter - 包装请求
8. AnonymousAuthenticationFilter - 匿名用户
9. SessionManagementFilter - 会话管理
10. ExceptionTranslationFilter - 异常转换
11. FilterSecurityInterceptor - 访问控制决策

认证流程

1
2
3
4
5
6
7
8
9
1. 用户提交登录表单
2. UsernamePasswordAuthenticationFilter 捕获请求
3. 创建 UsernamePasswordAuthenticationToken
4. AuthenticationManager 验证
5. UserDetailsService 加载用户信息
6. PasswordEncoder 验证密码
7. 认证成功创建 Authentication 对象
8. 存入 SecurityContext
9. 重定向到目标页面

环境搭建

添加依赖

Maven 依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<!-- Spring Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>

<!-- JWT可选 -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>

Gradle 依赖

1
2
3
4
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'

基本配置

内存用户配置

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
@Configuration
@EnableWebSecurity
public class SecurityConfig {

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/login")
.defaultSuccessUrl("/home", true)
.failureUrl("/login?error")
)
.logout(logout -> logout
.logoutUrl("/logout")
.logoutSuccessUrl("/login?logout")
);

return http.build();
}

@Bean
public UserDetailsService userDetailsService() {
UserDetails user = User.builder()
.username("user")
.password(passwordEncoder().encode("123456"))
.roles("USER")
.build();

UserDetails admin = User.builder()
.username("admin")
.password(passwordEncoder().encode("admin123"))
.roles("ADMIN")
.build();

return new InMemoryUserDetailsManager(user, admin);
}

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

数据库用户配置

实体类

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
@Entity
@Table(name = "sys_user")
@Data
public class SysUser implements UserDetails {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;

@Column(unique = true, nullable = false)
private String username;

@Column(nullable = false)
private String password;

private Boolean enabled = true;

@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(
name = "sys_user_role",
joinColumns = @JoinColumn(name = "user_id"),
inverseJoinColumns = @JoinColumn(name = "role_id")
)
private Set<SysRole> roles;

// UserDetails 接口方法
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.getName()))
.collect(Collectors.toList());
}

@Override
public boolean isAccountNonExpired() {
return true;
}

@Override
public boolean isAccountNonLocked() {
return true;
}

@Override
public boolean isCredentialsNonExpired() {
return true;
}

@Override
public boolean isEnabled() {
return this.enabled;
}
}

Role 实体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Entity
@Table(name = "sys_role")
@Data
public class SysRole {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;

@Column(unique = true, nullable = false)
private String name;

private String description;
}

UserDetailsService 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Service
public class CustomUserDetailsService implements UserDetailsService {

@Autowired
private UserRepository userRepository;

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
SysUser user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("用户不存在: " + username));

if (!user.isEnabled()) {
throw new DisabledException("用户已被禁用");
}

return user;
}
}

Repository

1
2
3
public interface UserRepository extends JpaRepository<SysUser, Integer> {
Optional<SysUser> findByUsername(String username);
}

安全配置

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
@Configuration
@EnableWebSecurity
public class SecurityConfig {

@Autowired
private CustomUserDetailsService userDetailsService;

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**", "/login").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/login")
.defaultSuccessUrl("/home", true)
)
.userDetailsService(userDetailsService);

return http.build();
}

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

认证方式

表单认证

登录页面

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
<!DOCTYPE html>
<html>
<head>
<title>登录</title>
</head>
<body>
<h2>登录</h2>
<form action="/login" method="post">
<div>
<label>用户名</label>
<input type="text" name="username"/>
</div>
<div>
<label>密码</label>
<input type="password" name="password"/>
</div>
<div>
<button type="submit">登录</button>
</div>
</form>

<!-- 显示错误信息 -->
<div th:if="${param.error}">
用户名或密码错误
</div>
<div th:if="${param.logout}">
已退出登录
</div>
</body>
</html>

配置表单登录

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.formLogin(form -> form
.loginPage("/login") // 登录页面
.loginProcessingUrl("/login") // 登录处理 URL
.defaultSuccessUrl("/home", true) // 登录成功后跳转
.failureUrl("/login?error") // 登录失败后跳转
.usernameParameter("username") // 用户名参数名
.passwordParameter("password") // 密码参数名
)
.logout(logout -> logout
.logoutUrl("/logout") // 登出 URL
.logoutSuccessUrl("/login?logout") // 登出后跳转
.invalidateHttpSession(true) // 使会话失效
.deleteCookies("JSESSIONID") // 删除 Cookie
);

return http.build();
}

HTTP Basic 认证

1
2
3
4
5
6
7
8
9
10
11
12
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.httpBasic(basic -> basic
.realmName("My Realm")
)
.authorizeHttpRequests(auth -> auth
.anyRequest().authenticated()
);

return http.build();
}

JWT 认证

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
35
36
37
38
39
40
41
42
43
44
45
46
47
@Component
public class JwtUtil {

private static final String SECRET_KEY = "your-secret-key-at-least-256-bits-long";
private static final long EXPIRATION_TIME = 7 * 24 * 60 * 60 * 1000; // 7天

public String generateToken(UserDetails userDetails) {
Map<String, Object> claims = new HashMap<>();
claims.put("roles", userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList()));

return Jwts.builder()
.setClaims(claims)
.setSubject(userDetails.getUsername())
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME))
.signWith(SignatureAlgorithm.HS256, SECRET_KEY)
.compact();
}

public String extractUsername(String token) {
return Jwts.parser()
.setSigningKey(SECRET_KEY)
.parseClaimsJws(token)
.getBody()
.getSubject();
}

public List<String> extractRoles(String token) {
Claims claims = Jwts.parser()
.setSigningKey(SECRET_KEY)
.parseClaimsJws(token)
.getBody();

return (List<String>) claims.get("roles");
}

public boolean validateToken(String token) {
try {
Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token);
return true;
} catch (JwtException | IllegalArgumentException e) {
return false;
}
}
}

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
35
36
37
38
39
40
41
42
43
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

@Autowired
private JwtUtil jwtUtil;

@Autowired
private UserDetailsService userDetailsService;

@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {

String header = request.getHeader("Authorization");

if (header != null && header.startsWith("Bearer ")) {
String token = header.substring(7);

if (jwtUtil.validateToken(token)) {
String username = jwtUtil.extractUsername(token);

UserDetails userDetails = userDetailsService.loadUserByUsername(username);

UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);

authentication.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request)
);

SecurityContextHolder.getContext().setAuthentication(authentication);
}
}

filterChain.doFilter(request, response);
}
}

配置 JWT 认证

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Configuration
@EnableWebSecurity
public class SecurityConfig {

@Autowired
private JwtAuthenticationFilter jwtAuthenticationFilter;

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable()) // JWT 不需要 CSRF 保护
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/login", "/auth/register").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

return http.build();
}
}

认证控制器

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
@RestController
@RequestMapping("/auth")
public class AuthController {

@Autowired
private AuthenticationManager authenticationManager;

@Autowired
private JwtUtil jwtUtil;

@Autowired
private UserDetailsService userDetailsService;

@PostMapping("/login")
public ResponseEntity<Map<String, String>> login(@RequestBody LoginRequest request) {
try {
// 认证
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getUsername(),
request.getPassword()
)
);

SecurityContextHolder.getContext().setAuthentication(authentication);

// 生成 Token
UserDetails userDetails = userDetailsService.loadUserByUsername(request.getUsername());
String token = jwtUtil.generateToken(userDetails);

Map<String, String> response = new HashMap<>();
response.put("token", token);

return ResponseEntity.ok(response);
} catch (BadCredentialsException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Collections.singletonMap("error", "用户名或密码错误"));
}
}
}

授权控制

URL 级别授权

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
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
// 公开访问
.requestMatchers("/", "/home", "/public/**").permitAll()

// 需要认证
.requestMatchers("/user/**").authenticated()

// 需要特定角色
.requestMatchers("/admin/**").hasRole("ADMIN")
.requestMatchers("/manager/**").hasAnyRole("ADMIN", "MANAGER")

// 需要特定权限
.requestMatchers("/api/data/**").hasAuthority("DATA_READ")

// IP 限制
.requestMatchers("/internal/**").hasIpAddress("192.168.1.0/24")

// 其他请求需要认证
.anyRequest().authenticated()
);

return http.build();
}

方法级别授权

启用方法安全

1
2
3
4
5
6
7
@Configuration
@EnableMethodSecurity // Spring Security 5.7+
public class MethodSecurityConfig {
}

// 或使用旧注解
// @EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)

@PreAuthorize

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
@Service
public class UserService {

// 需要 ADMIN 角色
@PreAuthorize("hasRole('ADMIN')")
public void deleteUser(Integer id) {
// ...
}

// 需要特定权限
@PreAuthorize("hasAuthority('USER_WRITE')")
public void updateUser(User user) {
// ...
}

// 自定义表达式
@PreAuthorize("#userId == authentication.principal.id or hasRole('ADMIN')")
public User getUserById(Integer userId) {
// ...
}

// 多个条件
@PreAuthorize("hasRole('ADMIN') and #user.enabled")
public void activateUser(User user) {
// ...
}
}

@PostAuthorize

1
2
3
4
5
@PostAuthorize("returnObject.owner == authentication.principal.username")
public Document getDocument(Integer id) {
// 只有文档所有者才能访问
return documentRepository.findById(id);
}

@Secured

1
2
3
4
5
6
7
8
9
@Secured("ROLE_ADMIN")
public void deleteProduct(Integer id) {
// ...
}

@Secured({"ROLE_ADMIN", "ROLE_MANAGER"})
public void updateProduct(Product product) {
// ...
}

@RolesAllowedJSR-250

1
2
3
4
@RolesAllowed("ADMIN")
public void deleteOrder(Integer id) {
// ...
}

自定义权限表达式

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
@Component("customPermissionEvaluator")
public class CustomPermissionEvaluator implements PermissionEvaluator {

@Override
public boolean hasPermission(Authentication authentication,
Object targetDomainObject,
Object permission) {
if (authentication == null || targetDomainObject == null) {
return false;
}

// 自定义权限判断逻辑
String userType = authentication.getAuthorities().stream()
.findFirst()
.map(GrantedAuthority::getAuthority)
.orElse("");

return "ROLE_ADMIN".equals(userType) ||
("ROLE_USER".equals(userType) && "READ".equals(permission));
}

@Override
public boolean hasPermission(Authentication authentication,
Serializable targetId,
String targetType,
Object permission) {
// 基于 ID 和类型的权限判断
return hasPermission(authentication, null, permission);
}
}
1
2
3
4
5
// 使用自定义权限表达式
@PreAuthorize("hasPermission(#document, 'READ')")
public Document getDocument(Integer id) {
// ...
}

安全防护

CSRF 保护

默认启用

Spring Security 默认启用 CSRF 保护适用于表单提交

禁用 CSRFAPI 项目

1
2
3
4
5
6
7
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable());

return http.build();
}

前端携带 CSRF Token

1
2
3
4
<form action="/update" method="post">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
<!-- 其他表单字段 -->
</form>

CORS 配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Configuration
public class CorsConfig {

@Bean
public CorsFilter corsFilter() {
CorsConfiguration config = new CorsConfiguration();
config.addAllowedOrigin("*");
config.addAllowedMethod("*");
config.addAllowedHeader("*");
config.setAllowCredentials(false);

UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);

return new CorsFilter(source);
}
}

或在 Security 配置中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.cors(cors -> cors.configurationSource(request -> {
CorsConfiguration config = new CorsConfiguration();
config.addAllowedOrigin("http://localhost:3000");
config.addAllowedMethod("*");
config.addAllowedHeader("*");
config.setAllowCredentials(true);
return config;
}));

return http.build();
}

密码加密

BCrypt推荐

1
2
3
4
5
6
7
8
9
10
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}

// 加密密码
String encodedPassword = passwordEncoder.encode("rawPassword");

// 验证密码
boolean matches = passwordEncoder.matches("rawPassword", encodedPassword);

其他编码器

1
2
3
4
5
6
7
8
// SCrypt
new SCryptPasswordEncoder();

// Argon2
new Argon2PasswordEncoder();

// PBKDF2
new Pbkdf2PasswordEncoder();

会话管理

1
2
3
4
5
6
7
8
9
10
11
12
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.sessionManagement(session -> session
.maximumSessions(1) // 最大会话数
.maxSessionsPreventsLogin(false) // 是否阻止新登录
.expiredUrl("/login?expired") // 会话过期跳转
.sessionFixation().migrateSession() // 会话固定攻击防护
);

return http.build();
}

最佳实践

最小权限原则

1
2
3
4
5
6
7
// 只授予必要的权限
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**").permitAll()
.requestMatchers("/user/**").hasRole("USER")
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().denyAll() // 默认拒绝所有
)

使用 HTTPS

1
2
3
4
5
6
server:
ssl:
key-store: classpath:keystore.p12
key-store-password: changeit
key-store-type: PKCS12
key-alias: tomcat

定期更新密钥

1
2
3
// 定期更换 JWT 密钥
// 定期更换密码加密盐值
// 定期轮换 API 密钥

日志记录

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Slf4j
@Component
public class AuthenticationEventListener {

@EventListener
public void handleAuthenticationSuccess(AuthenticationSuccessEvent event) {
log.info("用户登录成功: {}", event.getAuthentication().getName());
}

@EventListener
public void handleAuthenticationFailure(AuthenticationFailureBadCredentialsEvent event) {
log.warn("登录失败: {}, IP: {}",
event.getAuthentication().getName(),
((WebAuthenticationDetails) event.getAuthentication().getDetails()).getRemoteAddress()
);
}
}

常见问题

无限重定向

1
2
3
4
5
6
7
8
9
问题登录后无限重定向到登录页

错误原因
1. 登录成功 URL 也需要认证
2. CSRF Token 缺失

解决方案
1. 确保 defaultSuccessUrl 不需要认证或正确配置
2. 表单中添加 CSRF Token

权限不生效

1
2
3
4
5
6
7
8
9
问题@PreAuthorize 注解不生效

错误原因
1. 未启用方法安全
2. 代理问题

解决方案
1. 添加 @EnableMethodSecurity 或 @EnableGlobalMethodSecurity
2. 确保调用的是代理对象不是直接 new 的对象

报错处理

💗💗 Spring Security 报错AccessDeniedException

1
2
3
4
5
6
7
8
9
10
11
错误信息
Access Denied

错误原因
1. 用户没有所需权限
2. 未登录访问受保护资源

解决方案
1. 检查用户角色和权限是否正确
2. 确认用户已登录
3. 检查 URL 匹配规则

💗💗 Spring Security 报错BadCredentialsException

1
2
3
4
5
6
7
8
9
10
11
错误信息
Bad credentials

错误原因
1. 用户名或密码错误
2. 密码未加密或加密方式不匹配

解决方案
1. 检查用户名和密码是否正确
2. 确认数据库中密码已正确加密
3. 检查 PasswordEncoder 配置

学习资源

  • 视频
    • 尚硅谷 SpringSecurity 教程https://www.bilibili.com/video/BV15a411A7kP
    • SpringSecurity + OAuth2 教程https://www.bilibili.com/video/BV14b4y1A7Wz
  • 官方文档
    • Spring Security 官方文档https://spring.io/projects/spring-security
    • Spring Security GitHubhttps://github.com/spring-projects/spring-security
  • 书籍
    • Spring Security 实战陈韶健著
    • Spring Security 权威指南Rob Winch 著
  • 教程
    • Spring Security 入门教程https://www.baeldung.com/category/spring-security/
    • Baeldung Spring Security 教程https://www.baeldung.com/category/spring-security/
  • 社区
    • Stack Overflow Spring Security 标签https://stackoverflow.com/questions/tagged/spring-security
    • Spring 中文社区https://spring.io/projects