每次看到Spring Security的配置就头疼?明明跟着教程一步步操作,却总在某个环节卡住?这可能是大多数初中级开发者学习Spring Security时的真实写照。本文将带你用Spring Boot 3.x + Spring Security 6.x构建一个完整的用户管理系统,在实战中理解那些"只可意会"的核心流程。
我们先从最基础的Spring Boot项目开始。使用Spring Initializr创建项目时,除了选择Spring Web和Spring Security依赖外,建议加上Lombok和Thymeleaf简化开发:
bash复制curl https://start.spring.io/starter.zip \
-d dependencies=web,security,lombok,thymeleaf \
-d javaVersion=17 \
-d packaging=jar \
-d type=gradle-project \
-o user-management.zip
解压后,你会看到一个标准的Spring Boot项目结构。Spring Security 6.x默认启用了CSRF保护,并采用了更严格的默认安全配置。我们先创建一个简单的控制器测试基础功能:
java复制@Controller
public class HomeController {
@GetMapping("/")
public String home() {
return "index";
}
@GetMapping("/admin")
public String admin() {
return "admin";
}
}
启动项目后访问首页,你会被自动重定向到/login页面——这就是Spring Security的默认行为。但此时我们还没有任何用户配置,接下来让我们解决这个问题。
Spring Security 6.x推荐使用Lambda DSL进行配置,代码更加简洁:
java复制@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/home").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/login")
.permitAll()
)
.logout(logout -> logout
.permitAll()
);
return http.build();
}
@Bean
public UserDetailsService userDetailsService() {
UserDetails user = User.withDefaultPasswordEncoder()
.username("user")
.password("password")
.roles("USER")
.build();
UserDetails admin = User.withDefaultPasswordEncoder()
.username("admin")
.password("admin")
.roles("USER", "ADMIN")
.build();
return new InMemoryUserDetailsManager(user, admin);
}
}
注意:生产环境绝对不要使用
withDefaultPasswordEncoder(),这里仅用于演示。我们稍后会介绍正确的密码加密方式。
实际项目中,用户信息通常存储在数据库中。我们先定义用户实体和Repository:
java复制@Entity
@Data
@NoArgsConstructor
public class User {
@Id @GeneratedValue(strategy = IDENTITY)
private Long id;
private String username;
private String password;
private String roles; // 格式:"ROLE_USER,ROLE_ADMIN"
}
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
}
然后实现自定义的UserDetailsService:
java复制@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
return org.springframework.security.core.userdetails.User.builder()
.username(user.getUsername())
.password(user.getPassword())
.roles(user.getRoles().split(","))
.build();
}
}
Spring Security 6.x推荐使用BCryptPasswordEncoder:
java复制@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
在用户注册时这样使用:
java复制@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public User registerUser(User user) {
user.setPassword(passwordEncoder.encode(user.getPassword()));
return userRepository.save(user);
}
}
当用户提交登录表单时,Spring Security的认证流程如下:
UsernamePasswordAuthenticationFilter拦截请求,提取用户名和密码UsernamePasswordAuthenticationToken(Authentication接口的实现)AuthenticationManagerAuthenticationManager使用UserDetailsService加载用户详情PasswordEncoder验证密码Authentication对象SecurityContextHolder有时我们需要实现特殊认证逻辑,比如添加验证码校验。这时可以创建自定义过滤器:
java复制public class CaptchaAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
@Override
public Authentication attemptAuthentication(HttpServletRequest request,
HttpServletResponse response) throws AuthenticationException {
String captcha = request.getParameter("captcha");
// 验证码校验逻辑
if (!validateCaptcha(captcha)) {
throw new AuthenticationServiceException("Invalid captcha");
}
return super.attemptAuthentication(request, response);
}
private boolean validateCaptcha(String captcha) {
// 实现验证码验证逻辑
return true;
}
}
然后在配置中添加这个过滤器:
java复制http.addFilterBefore(
new CaptchaAuthenticationFilter(),
UsernamePasswordAuthenticationFilter.class
);
除了在配置中定义URL权限,还可以使用方法级注解:
java复制@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/admin/dashboard")
public String adminDashboard() {
return "dashboard";
}
@PostAuthorize("returnObject.owner == authentication.name")
public Document getDocument(Long id) {
// 获取文档逻辑
}
需要在配置类上添加@EnableMethodSecurity:
java复制@Configuration
@EnableMethodSecurity(prePostEnabled = true)
public class MethodSecurityConfig {
}
对于需要从数据库加载权限规则的场景,可以实现AuthorizationManager:
java复制@Component
public class DynamicAuthorizationManager implements AuthorizationManager<RequestAuthorizationContext> {
@Override
public AuthorizationDecision check(Supplier<Authentication> authentication,
RequestAuthorizationContext context) {
// 从数据库查询当前URL需要的权限
String requiredPermission = getRequiredPermission(context.getRequest());
// 检查用户是否拥有所需权限
boolean granted = authentication.get().getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals(requiredPermission));
return new AuthorizationDecision(granted);
}
}
然后在配置中使用:
java复制http.authorizeHttpRequests(auth -> auth
.anyRequest().access(new WebExpressionAuthorizationManager(
"@dynamicAuthorizationManager.check(authentication, request)"
))
);
Spring Security默认将SecurityContext存储在ThreadLocal中,但我们可以改变这一行为:
java复制@Bean
public SecurityContextRepository securityContextRepository() {
return new HttpSessionSecurityContextRepository();
}
限制同一用户的并发登录数:
java复制http.sessionManagement(session -> session
.maximumSessions(1)
.expiredUrl("/login?expired")
);
java复制http.rememberMe(remember -> remember
.key("uniqueAndSecret")
.tokenValiditySeconds(86400) // 1天
.userDetailsService(userDetailsService)
);
Spring Security默认启用CSRF防护。在表单中添加CSRF token:
html复制<form th:action="@{/logout}" method="post">
<input type="hidden"
th:name="${_csrf.parameterName}"
th:value="${_csrf.token}"/>
<button type="submit">Logout</button>
</form>
java复制@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of("https://trusted.com"));
configuration.setAllowedMethods(List.of("GET", "POST"));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
使用@WithMockUser进行测试:
java复制@Test
@WithMockUser(username = "admin", roles = {"ADMIN"})
public void testAdminEndpoint() throws Exception {
mockMvc.perform(get("/admin"))
.andExpect(status().isOk());
}
查看当前生效的过滤器:
java复制@Autowired
private FilterChainProxy filterChainProxy;
@GetMapping("/filters")
public void listFilters() {
filterChainProxy.getFilterChains().forEach(chain -> {
System.out.println("Filters for " + chain.getRequestMatcher());
chain.getFilters().forEach(filter ->
System.out.println(filter.getClass().getName())
);
});
}
java复制http.headers(headers -> headers
.contentSecurityPolicy(csp -> csp
.policyDirectives("default-src 'self'")
)
.frameOptions(frame -> frame
.sameOrigin()
)
.httpStrictTransportSecurity(hsts -> hsts
.includeSubDomains(true)
.maxAgeInSeconds(31536000)
)
);
实现AuthenticationSuccessHandler记录登录成功事件:
java复制@Component
public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
private static final Logger logger = LoggerFactory.getLogger(
CustomAuthenticationSuccessHandler.class);
@Override
public void onAuthenticationSuccess(HttpServletRequest request,
HttpServletResponse response, Authentication authentication) {
logger.info("User {} logged in from IP {}",
authentication.getName(),
request.getRemoteAddr());
response.sendRedirect("/home");
}
}
在配置中使用:
java复制http.formLogin(form -> form
.successHandler(authenticationSuccessHandler)
);
添加Spring Security OAuth2客户端依赖后,配置GitHub登录:
java复制http.oauth2Login(oauth -> oauth
.clientRegistrationRepository(clientRegistrationRepository())
.authorizedClientService(authorizedClientService())
.loginPage("/login")
);
@Bean
public ClientRegistrationRepository clientRegistrationRepository() {
return new InMemoryClientRegistrationRepository(githubClientRegistration());
}
private ClientRegistration githubClientRegistration() {
return ClientRegistration.withRegistrationId("github")
.clientId("github-client-id")
.clientSecret("github-client-secret")
.scope("read:user")
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
.authorizationUri("https://github.com/login/oauth/authorize")
.tokenUri("https://github.com/login/oauth/access_token")
.userInfoUri("https://api.github.com/user")
.userNameAttributeName("login")
.clientName("GitHub")
.build();
}
创建JWT工具类:
java复制@Component
public class JwtTokenUtil {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expiration}")
private Long expiration;
public String generateToken(UserDetails userDetails) {
Map<String, Object> claims = new HashMap<>();
return Jwts.builder()
.setClaims(claims)
.setSubject(userDetails.getUsername())
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + expiration * 1000))
.signWith(SignatureAlgorithm.HS512, secret)
.compact();
}
public Boolean validateToken(String token, UserDetails userDetails) {
final String username = extractUsername(token);
return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
}
}
创建JWT认证过滤器:
java复制public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
String token = extractToken(request);
if (token != null && jwtTokenUtil.validateToken(token, userDetails)) {
Authentication auth = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(auth);
}
chain.doFilter(request, response);
}
}
禁用不必要的过滤器:
java复制http.securityContext(context -> context.disable())
.sessionManagement(session -> session.disable())
.csrf(csrf -> csrf.disable());
问题1:登录后无限重定向
SecurityContext是否持久化AuthenticationSuccessHandler是否正确配置问题2:权限不生效
ROLE_前缀)问题3:CSRF导致POST请求失败
java复制http.csrf(csrf -> csrf
.ignoringRequestMatchers("/api/**")
);
现在,让我们将所有知识点整合到一个实际项目中。我们将构建一个具有以下功能的系统:
sql复制CREATE TABLE users (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
password VARCHAR(100) NOT NULL,
enabled BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE roles (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) NOT NULL
);
CREATE TABLE user_roles (
user_id BIGINT NOT NULL,
role_id BIGINT NOT NULL,
PRIMARY KEY (user_id, role_id),
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (role_id) REFERENCES roles(id)
);
用户注册服务:
java复制@Service
@Transactional
@RequiredArgsConstructor
public class UserRegistrationService {
private final UserRepository userRepository;
private final RoleRepository roleRepository;
private final PasswordEncoder passwordEncoder;
public User registerUser(RegistrationDto dto) {
if (userRepository.existsByUsername(dto.getUsername())) {
throw new UsernameExistsException("Username already taken");
}
User user = new User();
user.setUsername(dto.getUsername());
user.setPassword(passwordEncoder.encode(dto.getPassword()));
user.setEnabled(true);
Role userRole = roleRepository.findByName("ROLE_USER")
.orElseThrow(() -> new RoleNotFoundException("Default role not found"));
user.setRoles(Set.of(userRole));
return userRepository.save(user);
}
}
Thymeleaf安全扩展:
html复制<div sec:authorize="isAuthenticated()">
Welcome, <span sec:authentication="name"></span>
<form th:action="@{/logout}" method="post">
<input type="submit" value="Logout"/>
</form>
</div>
<div sec:authorize="hasRole('ADMIN')">
<a href="/admin">Admin Panel</a>
</div>
application-prod.properties示例:
properties复制spring.security.user.name=admin
spring.security.user.password=${ADMIN_PASSWORD}
spring.security.user.roles=ADMIN
jwt.secret=${JWT_SECRET}
jwt.expiration=86400
management.endpoints.web.exposure.include=health,info,metrics
management.endpoint.health.show-details=when_authorized
实现ApplicationListener记录安全事件:
java复制@Component
public class SecurityAuditListener
implements ApplicationListener<AbstractAuthenticationEvent> {
private static final Logger logger = LoggerFactory.getLogger(
SecurityAuditListener.class);
@Override
public void onApplicationEvent(AbstractAuthenticationEvent event) {
if (event instanceof AuthenticationSuccessEvent) {
logger.info("User {} logged in successfully",
event.getAuthentication().getName());
} else if (event instanceof AuthenticationFailureBadCredentialsEvent) {
logger.warn("Failed login attempt for user: {}",
((AuthenticationFailureBadCredentialsEvent)event)
.getAuthentication().getName());
}
}
}
掌握了这些核心概念后,可以进一步探索:
在实际项目中,我发现最常遇到的挑战不是技术实现,而是如何在安全性和用户体验之间找到平衡。比如,过于复杂的密码规则可能导致用户选择不安全的方式记录密码,而频繁的会话超时又会影响用户体验。解决这些问题需要深入理解业务场景和安全需求,而不仅仅是技术配置。