SpringBoot(11):Spring Security 入门------让你的项目加上登录墙

上周帮朋友看一个项目,接口全部裸奔------没有任何登录校验,随便一个人拿到 URL 就能调管理员接口删数据。我问他怎么不加个登录,他说"Spring Security 太复杂了,配了一周没跑通"。这话说得不冤,Spring Security 上手确实陡。但如果你搞懂了它的核心流程(认证 + 授权 + 过滤器链),其实没那么难。这篇文章从零开始,把 Spring Security 的原理、核心组件、源码、配置方式、JWT 整合、权限控制全部讲一遍。看完你能在自己项目里搭一套完整的登录认证体系。
问题:为什么需要 Spring Security
没有安全框架时,登录校验通常这么写:
kotlin
@RestController
@RequestMapping("/api")
public class OrderController {
@Autowired
private HttpSession session;
@GetMapping("/orders")
public Result listOrders() {
User user = (User) session.getAttribute("loginUser");
if (user == null) {
return Result.fail("未登录");
}
if (!user.getRoles().contains("ADMIN")) {
return Result.fail("无权限");
}
return Result.success(orderService.list());
}
}
每个接口都要写一遍 if (user == null),遗漏一个就不安全。角色判断散落在各个 Controller 里,改一下权限逻辑得改一堆地方。
Spring Security 解决的问题:
| 痛点 | Spring Security 的解法 |
|---|---|
| 每个接口手动校验登录 | 过滤器链自动拦截,未登录跳转登录页 |
| 权限判断散落各处 | 注解 + 统一配置,集中管理 |
| 密码明文存储 | 内置 BCrypt 加密 |
| Session 管理麻烦 | 支持 Session + JWT 两种模式 |
| CSRF 攻击 | 内置 CSRF 防护 |
| 暴力破解 | 内置登录限速、账号锁定 |
Spring Security 核心架构
整个 Spring Security 就是一条过滤器链(FilterChain)。每个请求进来,按顺序经过一系列 Filter,每个 Filter 负责一件事:认证、授权、CSRF 校验、CORS 处理等。全部通过后,请求才到达你的 Controller。
核心组件
| 组件 | 作用 | 类比 |
|---|---|---|
SecurityFilterChain |
一组 Filter 的有序集合 | 保安队 |
Authentication |
认证信息(谁、密码、权限) | 工牌 |
SecurityContext |
存储当前用户的 Authentication | 工牌夹 |
AuthenticationManager |
认证管理器,负责验证 | 门禁系统 |
ProviderManager |
AuthenticationManager 的默认实现 | 门禁总控 |
AuthenticationProvider |
具体的认证逻辑(账号密码/短信/证书) | 某种验证方式 |
UserDetailsService |
加载用户信息 | HR 查档案 |
PasswordEncoder |
密码加密比对 | 密码保险箱 |
AccessDecisionManager |
授权决策 | 权限审批员 |
SecurityContextHolder |
线程级上下文持有者 | 全局工牌架 |
它们之间的关系:
scss
请求 → FilterChainProxy → 各 Filter
↓
UsernamePasswordAuthenticationFilter
↓
AuthenticationManager (ProviderManager)
↓
DaoAuthenticationProvider
↓
UserDetailsService.loadUserByUsername()
↓
PasswordEncoder.matches()
↓
认证成功 → SecurityContext 存入 Authentication
↓
FilterSecurityInterceptor(授权检查)
↓
Controller
快速上手:30 秒加上登录墙
引入依赖
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
就这一个依赖,启动项目后所有接口都自动加上了登录墙。访问任何接口会跳转到 Spring Security 自带的登录页(/login),默认用户名 user,密码在启动日志里打印:
sql
Using generated security password: 8f3a7b2c-1d4e-4f6a-b8c9-2e3d4a5b6c7d
这个密码每次启动都变,开发时用不方便。先改成固定密码。
第一个配置:自定义用户名密码
yaml
spring:
security:
user:
name: admin
password: admin123
roles: ADMIN
这样就能用 admin / admin123 登录了。
用 Java 配置替代 YAML
实际项目用 Java 配置更灵活:
less
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**", "/login", "/register").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.requestMatchers("/api/**").authenticated()
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/login")
.defaultSuccessUrl("/home")
.failureUrl("/login?error=true")
.permitAll()
)
.logout(logout -> logout
.logoutUrl("/logout")
.logoutSuccessUrl("/login?logout=true")
.permitAll()
)
.csrf(csrf -> csrf.disable());
return http.build();
}
@Bean
public UserDetailsService userDetailsService(PasswordEncoder passwordEncoder) {
UserDetails admin = User.builder()
.username("admin")
.password(passwordEncoder.encode("admin123"))
.roles("ADMIN")
.build();
UserDetails user = User.builder()
.username("zhangsan")
.password(passwordEncoder.encode("123456"))
.roles("USER")
.build();
return new InMemoryUserDetailsManager(admin, user);
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
这段配置做了几件事:
/public/**、/login、/register放行/admin/**需要 ADMIN 角色/api/**需要登录- 其他所有请求都要登录
- 表单登录,自定义登录页
- 关闭 CSRF(前后端分离时通常关闭)
认证流程源码分析
1. 请求进入过滤器链
所有请求经过 FilterChainProxy。它是 Spring Security 的入口 Filter,内部维护了一个 SecurityFilterChain 列表:
vbscript
// org.springframework.security.web.FilterChainProxy
public class FilterChainProxy extends GenericFilterBean {
private List<SecurityFilterChain> filterChains;
@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) {
doFilterInternal(request, response, chain);
}
private void doFilterInternal(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
// 找到匹配当前请求的过滤器链
List<Filter> filters = getFilters(httpRequest);
if (filters == null || filters.size() == 0) {
chain.doFilter(request, response);
return;
}
// 沿着过滤器链执行
VirtualFilterChain virtualFilterChain =
new VirtualFilterChain(chain, filters);
virtualFilterChain.doFilter(request, response);
}
}
2. UsernamePasswordAuthenticationFilter 拦截登录请求
这个 Filter 只处理 POST /login:
scala
// org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
@Override
public Authentication attemptAuthentication(HttpServletRequest request,
HttpServletResponse response)
throws AuthenticationException {
String username = obtainUsername(request);
String password = obtainPassword(request);
UsernamePasswordAuthenticationToken authRequest =
UsernamePasswordAuthenticationToken.unauthenticated(username, password);
// 交给 AuthenticationManager 认证
return this.getAuthenticationManager().authenticate(authRequest);
}
}
注意这里创建了一个 UsernamePasswordAuthenticationToken,此时它的 authenticated 属性是 false,表示还没认证。
3. ProviderManager 委托给 DaoAuthenticationProvider
java
// org.springframework.security.authentication.ProviderManager
public class ProviderManager implements AuthenticationManager {
private List<AuthenticationProvider> providers;
@Override
public Authentication authenticate(Authentication authentication)
throws AuthenticationException {
for (AuthenticationProvider provider : providers) {
if (!provider.supports(authentication.getClass())) {
continue;
}
// 委托给具体的 Provider
result = provider.authenticate(authentication);
if (result != null) {
return result;
}
}
throw new ProviderNotFoundException("No provider found");
}
}
4. DaoAuthenticationProvider 调用 UserDetailsService
scala
// org.springframework.security.authentication.dao.DaoAuthenticationProvider
public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {
private UserDetailsService userDetailsService;
private PasswordEncoder passwordEncoder;
@Override
protected void additionalAuthenticationChecks(UserDetails userDetails,
UsernamePasswordAuthenticationToken authentication)
throws AuthenticationException {
String presentedPassword = authentication.getCredentials().toString();
// 密码比对
if (!passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
throw new BadCredentialsException("Bad credentials");
}
}
@Override
protected UserDetails retrieveUser(String username,
UsernamePasswordAuthenticationToken authentication)
throws AuthenticationException {
// 从 UserDetailsService 加载用户
return this.userDetailsService.loadUserByUsername(username);
}
}
5. 认证成功,存入 SecurityContext
认证成功后,AbstractAuthenticationProcessingFilter 把 Authentication 存入 SecurityContextHolder:
typescript
// org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter
public abstract class AbstractAuthenticationProcessingFilter {
private void successfulAuthentication(HttpServletRequest request,
HttpServletResponse response, FilterChain chain,
Authentication authentication) {
// 存入 SecurityContext
SecurityContextHolder.getContext().setAuthentication(authentication);
// 调用成功处理器
successHandler.onAuthenticationSuccess(request, response, chain, authentication);
}
}
6. 后续请求自动获取认证信息
SecurityContextPersistenceFilter 在每个请求开始时,从 Session 中恢复 SecurityContext:
scala
// org.springframework.security.web.context.SecurityContextPersistenceFilter
public class SecurityContextPersistenceFilter extends GenericFilterBean {
@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) {
HttpServletRequest httpRequest = (HttpServletRequest) request;
// 从 Session 加载 SecurityContext
SecurityContext contextBeforeChainExecution =
repo.loadContext(httpRequest);
SecurityContextHolder.setContext(contextBeforeChainExecution);
try {
chain.doFilter(request, response);
} finally {
// 保存回 Session
SecurityContextHolder.clearContext();
}
}
}
整个认证流程的数据流:
scss
用户提交用户名密码
↓
UsernamePasswordAuthenticationFilter
↓ 创建未认证的 Token
ProviderManager
↓ 遍历 Providers
DaoAuthenticationProvider
↓ 调用 UserDetailsService
UserDetailsService.loadUserByUsername()
↓ 返回 UserDetails
PasswordEncoder.matches()
↓ 密码匹配
认证成功 → 创建已认证的 Token
↓
SecurityContextHolder.setAuthentication()
↓ 存入 Session
后续请求 → SecurityContextPersistenceFilter 从 Session 恢复
数据库用户认证
内存用户只适合 demo。实际项目用户信息存数据库。
建表
sql
CREATE TABLE sys_user (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) NOT NULL UNIQUE,
password VARCHAR(100) NOT NULL,
nickname VARCHAR(50),
enabled TINYINT(1) DEFAULT 1,
account_non_expired TINYINT(1) DEFAULT 1,
account_non_locked TINYINT(1) DEFAULT 1,
credentials_non_expired TINYINT(1) DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE sys_role (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(50) NOT NULL,
code VARCHAR(50) NOT NULL UNIQUE
);
CREATE TABLE sys_user_role (
user_id BIGINT NOT NULL,
role_id BIGINT NOT NULL,
PRIMARY KEY (user_id, role_id)
);
CREATE TABLE sys_permission (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(100) NOT NULL,
code VARCHAR(100) NOT NULL UNIQUE,
type VARCHAR(20) NOT NULL COMMENT 'MENU/BUTTON/API'
);
CREATE TABLE sys_role_permission (
role_id BIGINT NOT NULL,
permission_id BIGINT NOT NULL,
PRIMARY KEY (role_id, permission_id)
);
实体类
less
@Entity
@Table(name = "sys_user")
public class SysUser {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
private String password;
private String nickname;
private Boolean enabled;
private Boolean accountNonExpired;
private Boolean accountNonLocked;
private Boolean credentialsNonExpired;
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(name = "sys_user_role",
joinColumns = @JoinColumn(name = "user_id"),
inverseJoinColumns = @JoinColumn(name = "role_id"))
private Set<SysRole> roles;
public UserDetails toUserDetails() {
List<GrantedAuthority> authorities = roles.stream()
.flatMap(role -> role.getPermissions().stream())
.map(p -> new SimpleGrantedAuthority(p.getCode()))
.collect(Collectors.toList());
authorities.addAll(roles.stream()
.map(r -> new SimpleGrantedAuthority("ROLE_" + r.getCode()))
.collect(Collectors.toList()));
return User.builder()
.username(username)
.password(password)
.disabled(!enabled)
.accountExpired(!accountNonExpired)
.accountLocked(!accountNonLocked)
.credentialsExpired(!credentialsNonExpired)
.authorities(authorities)
.build();
}
}
自定义 UserDetailsService
java
@Service
public class CustomUserDetailsService implements UserDetailsService {
@Autowired
private SysUserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username)
throws UsernameNotFoundException {
SysUser user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException(
"用户不存在: " + username));
return user.toUserDetails();
}
}
修改配置
less
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Autowired
private CustomUserDetailsService userDetailsService;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**", "/login", "/register",
"/css/**", "/js/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.requestMatchers("/api/**").authenticated()
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/login")
.defaultSuccessUrl("/home")
.permitAll()
)
.logout(logout -> logout
.logoutUrl("/logout")
.logoutSuccessUrl("/login?logout")
.permitAll()
)
.csrf(csrf -> csrf.disable())
.userDetailsService(userDetailsService);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
JWT 无状态认证
前后端分离项目用 JWT 比 Session 更合适:不用服务器存状态,方便水平扩展。
JWT vs Session
| 对比项 | Session | JWT |
|---|---|---|
| 存储位置 | 服务器内存/Redis | 客户端(请求头) |
| 水平扩展 | 需要Session共享 | 天然支持 |
| 跨域 | 需要额外处理 | 天然支持 |
| 安全性 | SessionId 泄露风险 | Token 泄露风险 |
| 注销 | 删Session即可 | 需要黑名单机制 |
| 适用场景 | 服务端渲染 | 前后端分离 |
引入 JWT 依赖
xml
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.5</version>
<scope>runtime</scope>
</dependency>
JWT 工具类
scss
@Component
public class JwtUtils {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expiration:86400000}")
private long expiration;
private SecretKey getSigningKey() {
byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8);
return Keys.hmacShaKeyFor(keyBytes);
}
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()
.claims(claims)
.subject(userDetails.getUsername())
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + expiration))
.signWith(getSigningKey())
.compact();
}
public String extractUsername(String token) {
return Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.getPayload()
.getSubject();
}
public boolean isTokenValid(String token, UserDetails userDetails) {
String username = extractUsername(token);
return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
}
private boolean isTokenExpired(String token) {
return Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.getPayload()
.getExpiration()
.before(new Date());
}
}
JWT 登录接口
less
@RestController
@RequestMapping("/auth")
public class AuthController {
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private CustomUserDetailsService userDetailsService;
@Autowired
private JwtUtils jwtUtils;
@PostMapping("/login")
public Result<LoginResponse> login(@RequestBody LoginRequest request) {
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getUsername(), request.getPassword()));
SecurityContextHolder.getContext().setAuthentication(authentication);
UserDetails userDetails = userDetailsService
.loadUserByUsername(request.getUsername());
String token = jwtUtils.generateToken(userDetails);
return Result.success(new LoginResponse(token, userDetails.getUsername()));
}
@GetMapping("/info")
public Result<UserInfo> getUserInfo(@AuthenticationPrincipal UserDetails userDetails) {
UserInfo info = new UserInfo();
info.setUsername(userDetails.getUsername());
info.setAuthorities(userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList()));
return Result.success(info);
}
}
JWT 认证过滤器
scala
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private JwtUtils jwtUtils;
@Autowired
private CustomUserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
String token = extractToken(request);
if (token != null && jwtUtils.isTokenValid(token,
userDetailsService.loadUserByUsername(
jwtUtils.extractUsername(token)))) {
String username = jwtUtils.extractUsername(token);
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken authentication =
UsernamePasswordAuthenticationToken.authenticated(
userDetails, null, userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource()
.buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
private String extractToken(HttpServletRequest request) {
String header = request.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
return header.substring(7);
}
return null;
}
}
JWT 配置
java
@Configuration
@EnableWebSecurity
public class JwtSecurityConfig {
@Autowired
private JwtAuthenticationFilter jwtAuthenticationFilter;
@Autowired
private CustomUserDetailsService userDetailsService;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/login", "/auth/register",
"/public/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.authenticationProvider(authenticationProvider())
.addFilterBefore(jwtAuthenticationFilter,
UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public DaoAuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailsService);
provider.setPasswordEncoder(passwordEncoder());
return provider;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
}
配置要点:
sessionCreationPolicy(STATELESS)--- 告诉 Spring Security 不使用 SessionaddFilterBefore--- 把 JWT 过滤器放在认证过滤器之前- 关闭 CSRF(无状态不需要)
权限控制
方式一:URL 级别权限
less
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/admin/**").hasRole("ADMIN")
.requestMatchers("/user/**").hasAnyRole("ADMIN", "USER")
.requestMatchers("/api/order/**").hasAuthority("order:view")
.requestMatchers("/api/order/create").hasAuthority("order:create")
.anyRequest().authenticated()
);
hasRole("ADMIN") 底层会自动加 ROLE_ 前缀,匹配 ROLE_ADMIN。hasAuthority 不加前缀,直接匹配。
方式二:方法级别权限
less
@Configuration
@EnableMethodSecurity
public class MethodSecurityConfig {
}
然后在 Controller 或 Service 上用注解:
less
@RestController
@RequestMapping("/api/users")
public class UserController {
@GetMapping
@PreAuthorize("hasRole('ADMIN')")
public Result listUsers() {
return Result.success(userService.list());
}
@GetMapping("/{id}")
@PreAuthorize("hasAuthority('user:view') or #id == authentication.principal.id")
public Result getUser(@PathVariable Long id) {
return Result.success(userService.getById(id));
}
@PostMapping
@PreAuthorize("hasAuthority('user:create')")
public Result createUser(@RequestBody UserRequest request) {
return Result.success(userService.create(request));
}
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN') and hasAuthority('user:delete')")
public Result deleteUser(@PathVariable Long id) {
userService.delete(id);
return Result.success();
}
@GetMapping("/profile")
public Result getProfile(@AuthenticationPrincipal UserDetails userDetails) {
return Result.success(userService.getByUsername(userDetails.getUsername()));
}
}
SpEL 表达式常用写法:
| 表达式 | 说明 |
|---|---|
hasRole('ADMIN') |
拥有 ADMIN 角色 |
hasAnyRole('ADMIN','USER') |
拥有任一角色 |
hasAuthority('user:delete') |
拥有 user:delete 权限 |
hasAnyAuthority('a','b') |
拥有任一权限 |
isAuthenticated() |
已登录 |
isAnonymous() |
未登录 |
permitAll |
放行 |
denyAll |
拒绝所有 |
#id == authentication.principal.id |
参数等于当前用户ID |
方式三:自定义权限评估
java
@Component
public class SecurityService {
@Autowired
private SysPermissionRepository permissionRepository;
public boolean hasPermission(String permissionCode) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) {
return false;
}
return auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals(permissionCode));
}
public boolean isOwner(Long userId) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null) return false;
UserDetails userDetails = (UserDetails) auth.getPrincipal();
SysUser user = userRepository.findByUsername(userDetails.getUsername())
.orElse(null);
return user != null && user.getId().equals(userId);
}
}
在 SpEL 中使用:
less
@PreAuthorize("@securityService.hasPermission('order:cancel')")
@PostMapping("/order/{id}/cancel")
public Result cancelOrder(@PathVariable Long id) {
orderService.cancel(id);
return Result.success();
}
@PreAuthorize("@securityService.isOwner(#userId) or hasRole('ADMIN')")
@GetMapping("/user/{userId}/profile")
public Result getProfile(@PathVariable Long userId) {
return Result.success(userService.getById(userId));
}
密码加密
Spring Security 默认用 BCrypt,这是目前推荐的密码哈希算法。
typescript
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
使用:
scss
@Service
public class UserService {
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
private SysUserRepository userRepository;
public void register(RegisterRequest request) {
if (userRepository.existsByUsername(request.getUsername())) {
throw new BusinessException("用户名已存在");
}
SysUser user = new SysUser();
user.setUsername(request.getUsername());
user.setPassword(passwordEncoder.encode(request.getPassword()));
user.setNickname(request.getNickname());
user.setEnabled(true);
user.setAccountNonExpired(true);
user.setAccountNonLocked(true);
user.setCredentialsNonExpired(true);
userRepository.save(user);
}
}
BCrypt 的特点:
| 特点 | 说明 |
|---|---|
| 自带盐值 | 每次加密自动生成随机盐,不需要单独存 |
| 慢哈希 | 故意算得慢,防止暴力破解 |
| 长度固定 | 输出 60 个字符 |
| 强度可调 | BCryptPasswordEncoder(12),数字越大越慢,默认 10 |
验证密码不需要手动取出盐值,直接 passwordEncoder.matches(rawPassword, encodedPassword) 即可。
自定义登录成功/失败处理
前后端分离项目通常返回 JSON 而不是重定向:
typescript
@Component
public class JsonAuthenticationSuccessHandler
implements AuthenticationSuccessHandler {
@Autowired
private JwtUtils jwtUtils;
@Autowired
private ObjectMapper objectMapper;
@Override
public void onAuthenticationSuccess(HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) throws IOException {
response.setContentType("application/json;charset=UTF-8");
response.setStatus(HttpServletResponse.SC_OK);
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
String token = jwtUtils.generateToken(userDetails);
Map<String, Object> result = new HashMap<>();
result.put("code", 200);
result.put("message", "登录成功");
result.put("data", Map.of(
"token", token,
"username", userDetails.getUsername(),
"authorities", userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority).toList()
));
response.getWriter().write(objectMapper.writeValueAsString(result));
}
}
@Component
public class JsonAuthenticationFailureHandler
implements AuthenticationFailureHandler {
@Autowired
private ObjectMapper objectMapper;
@Override
public void onAuthenticationFailure(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException exception) throws IOException {
response.setContentType("application/json;charset=UTF-8");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
String message = "登录失败";
if (exception instanceof BadCredentialsException) {
message = "用户名或密码错误";
} else if (exception instanceof DisabledException) {
message = "账号已被禁用";
} else if (exception instanceof LockedException) {
message = "账号已被锁定";
} else if (exception instanceof AccountExpiredException) {
message = "账号已过期";
}
Map<String, Object> result = new HashMap<>();
result.put("code", 401);
result.put("message", message);
response.getWriter().write(objectMapper.writeValueAsString(result));
}
}
配置:
less
http.formLogin(form -> form
.loginProcessingUrl("/auth/login")
.successHandler(jsonAuthenticationSuccessHandler)
.failureHandler(jsonAuthenticationFailureHandler)
);
未登录和权限不足处理
java
@Component
public class JsonAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException ex) throws IOException {
response.setContentType("application/json;charset=UTF-8");
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.getWriter().write(
"{"code":403,"message":"权限不足"}");
}
}
@Component
public class JsonAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException ex) throws IOException {
response.setContentType("application/json;charset=UTF-8");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write(
"{"code":401,"message":"未登录,请先登录"}");
}
}
配置:
less
http.exceptionHandling(ex -> ex
.authenticationEntryPoint(jsonAuthenticationEntryPoint)
.accessDeniedHandler(jsonAccessDeniedHandler)
);
CORS 配置
前后端分离项目前后端不同域,需要处理跨域:
java
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of("http://localhost:3000"));
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(List.of("*"));
configuration.setAllowCredentials(true);
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
ini
http.cors(Customizer.withDefaults());
实战案例:RBAC 权限管理系统
把上面的内容整合成一个完整的 RBAC(基于角色的访问控制)系统。
初始化数据
java
@Component
public class DataInitializer implements CommandLineRunner {
@Autowired
private SysUserRepository userRepository;
@Autowired
private SysRoleRepository roleRepository;
@Autowired
private SysPermissionRepository permissionRepository;
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
private SysUserRoleRepository userRoleRepository;
@Autowired
private SysRolePermissionRepository rolePermissionRepository;
@Override
public void run(String... args) {
if (roleRepository.count() > 0) return;
SysRole adminRole = new SysRole("管理员", "ADMIN");
SysRole userRole = new SysRole("普通用户", "USER");
roleRepository.saveAll(List.of(adminRole, userRole));
List<SysPermission> permissions = List.of(
new SysPermission("用户查看", "user:view", "MENU"),
new SysPermission("用户新增", "user:create", "BUTTON"),
new SysPermission("用户编辑", "user:edit", "BUTTON"),
new SysPermission("用户删除", "user:delete", "BUTTON"),
new SysPermission("订单查看", "order:view", "MENU"),
new SysPermission("订单取消", "order:cancel", "BUTTON"),
new SysPermission("商品管理", "product:manage", "MENU")
);
permissionRepository.saveAll(permissions);
SysUser admin = new SysUser();
admin.setUsername("admin");
admin.setPassword(passwordEncoder.encode("admin123"));
admin.setNickname("系统管理员");
admin.setEnabled(true);
admin.setAccountNonExpired(true);
admin.setAccountNonLocked(true);
admin.setCredentialsNonExpired(true);
userRepository.save(admin);
SysUser user = new SysUser();
user.setUsername("zhangsan");
user.setPassword(passwordEncoder.encode("123456"));
user.setNickname("张三");
user.setEnabled(true);
user.setAccountNonExpired(true);
user.setAccountNonLocked(true);
user.setCredentialsNonExpired(true);
userRepository.save(user);
userRoleRepository.save(new SysUserRole(admin.getId(), adminRole.getId()));
userRoleRepository.save(new SysUserRole(user.getId(), userRole.getId()));
for (SysPermission p : permissions) {
rolePermissionRepository.save(
new SysRolePermission(adminRole.getId(), p.getId()));
}
rolePermissionRepository.save(new SysRolePermission(userRole.getId(),
permissions.get(4).getId()));
rolePermissionRepository.save(new SysRolePermission(userRole.getId(),
permissions.get(6).getId()));
}
}
完整的安全配置
less
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class FullSecurityConfig {
@Autowired
private JwtAuthenticationFilter jwtAuthenticationFilter;
@Autowired
private CustomUserDetailsService userDetailsService;
@Autowired
private JsonAuthenticationEntryPoint authenticationEntryPoint;
@Autowired
private JsonAccessDeniedHandler accessDeniedHandler;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.cors(Customizer.withDefaults())
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.exceptionHandling(ex -> ex
.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/login", "/auth/register",
"/public/**", "/captcha").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.authenticationProvider(authenticationProvider())
.addFilterBefore(jwtAuthenticationFilter,
UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public DaoAuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailsService);
provider.setPasswordEncoder(passwordEncoder());
return provider;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of("http://localhost:3000"));
configuration.setAllowedMethods(
List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(List.of("*"));
configuration.setAllowCredentials(true);
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
前端对接要点
前端拿到 JWT 后,每个请求在 Header 里带上:
ini
axios.interceptors.request.use(config => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
axios.interceptors.response.use(
response => response,
error => {
if (error.response?.status === 401) {
localStorage.removeItem('token');
window.location.href = '/login';
}
if (error.response?.status === 403) {
message.error('权限不足');
}
return Promise.reject(error);
}
);
Spring Security 过滤器链完整顺序
Spring Security 默认注册的过滤器(按执行顺序):
| 顺序 | 过滤器 | 作用 |
|---|---|---|
| 1 | DisableEncodeUrlFilter | 禁用URL编码SessionId |
| 2 | WebAsyncManagerIntegrationFilter | 异步请求安全上下文集成 |
| 3 | SecurityContextPersistenceFilter | 从Session恢复SecurityContext |
| 4 | HeaderWriterFilter | 写安全响应头(X-Frame-Options等) |
| 5 | CorsFilter | CORS 跨域处理 |
| 6 | CsrfFilter | CSRF 防护 |
| 7 | LogoutFilter | 登出处理 |
| 8 | UsernamePasswordAuthenticationFilter | 表单登录认证 |
| 9 | DefaultLoginPageGeneratingFilter | 生成默认登录页 |
| 10 | BasicAuthenticationFilter | HTTP Basic 认证 |
| 11 | RequestCacheAwareFilter | 请求缓存恢复 |
| 12 | SecurityContextHolderFilter | 安全上下文管理 |
| 13 | RememberMeAuthenticationFilter | 记住我认证 |
| 14 | AnonymousAuthenticationFilter | 匿名用户认证 |
| 15 | SessionManagementFilter | Session 管理 |
| 16 | ExceptionTranslationFilter | 异常翻译处理 |
| 17 | FilterSecurityInterceptor | 权限校验 |
JWT 过滤器通过 addFilterBefore 插入到 UsernamePasswordAuthenticationFilter 之前。
Remember-Me(记住我)
less
http.rememberMe(remember -> remember
.key("uniqueAndSecret")
.tokenValiditySeconds(7 * 24 * 3600)
.rememberMeParameter("remember-me")
.userDetailsService(userDetailsService)
);
登录时带上 remember-me=true 参数,Spring Security 会生成一个持久化 Token 存在 Cookie 里,有效期 7 天。
底层实现:RememberMeAuthenticationFilter 检测到 Cookie 中有 remember-me Token 时,自动调用 UserDetailsService 加载用户信息,跳过登录流程。
常见配置对比
| 场景 | 配置 |
|---|---|
| 服务端渲染 | formLogin + Session |
| 前后端分离 | JWT + STATELESS |
| 只保护部分接口 | requestMatchers 放行 + authenticated |
| 全部放行(开发时) | http.authorizeHttpRequests(auth -> auth.anyRequest().permitAll()) |
| 禁用 Security | 在配置类上加 @Profile("test") 或排除自动配置 |
Spring Security 配置速查
| 需求 | 代码 |
|---|---|
| 放行路径 | .requestMatchers("/xxx").permitAll() |
| 需要登录 | .anyRequest().authenticated() |
| 需要角色 | .requestMatchers("/admin/**").hasRole("ADMIN") |
| 需要权限 | .requestMatchers("/api/x").hasAuthority("x:view") |
| 表单登录 | .formLogin(form -> form.loginPage("/login")) |
| 关闭 CSRF | .csrf(csrf -> csrf.disable()) |
| 无状态 | .sessionManagement(s -> s.sessionCreationPolicy(STATELESS)) |
| CORS | .cors(Customizer.withDefaults()) + 配置 Bean |
| 注销 | .logout(l -> l.logoutUrl("/logout")) |
| 记住我 | .rememberMe(r -> r.key("xxx")) |
总结
| 知识点 | 要点 |
|---|---|
| 核心原理 | 过滤器链,每个请求依次经过认证、授权等 Filter |
| 认证流程 | Token → AuthenticationManager → Provider → UserDetailsService → PasswordEncoder |
| 配置方式 | SecurityFilterChain Bean,Lambda DSL |
| 用户来源 | 内存、数据库(UserDetailsService) |
| 密码加密 | BCryptPasswordEncoder,自带盐值 |
| JWT 认证 | 自定义 Filter,解析 Token 设置 SecurityContext |
| 权限控制 | URL 级别(requestMatchers)+ 方法级别(@PreAuthorize) |
| 异常处理 | AuthenticationEntryPoint(未登录)+ AccessDeniedHandler(权限不足) |
| CORS | CorsConfigurationSource Bean |
| RBAC | 用户-角色-权限三表模型,GrantedAuthority 承载权限 |
Spring Security 看着吓人,拆开看就是一条过滤器链。认证靠 AuthenticationManager 委托给 AuthenticationProvider,授权靠 FilterSecurityInterceptor 检查权限。搞清楚这条链上每个节点的职责,剩下的就是配配置的事。前后端分离用 JWT,服务端渲染用 Session,权限细粒度控制用 @PreAuthorize。数据库用户实现 UserDetailsService,密码加密用 BCrypt。这几个组件搞明白了,Spring Security 就算过了入门这道坎。