第 6 章 · Spring Security 认证与授权
本章目标:集成 Spring Security 6;实现基于 UserDetailsService 的用户加载与 BCrypt 密码编码;配置 表单登录、登出与 URL 访问规则;在 HTML 表单中正确使用 CSRF Token(承接 ch04);使用 @PreAuthorize 方法级权限;对照 shop-demo(Django) 的 auth、Session、权限组说明差异;保护 shop-spring-demo 的 /catalog/** 管理页,开放 /health 与部分只读 API。
学时建议:6~7 小时(含 2.5 小时跟练)
前置:完成 spring-boot-web ch05;django-web ch03 认证章节更佳;理解 HTTP Session 与 Cookie。
6.1 场景说明:运营后台必须登录
shop-spring-demo 的 Thymeleaf 商品管理页仅运营人员可访问;公开 GET /api/v1/products 可匿名(只读),写操作需认证。虚构管理域 https://admin.example.com 经表单登录演示。
| 路径 | 访问策略 | shop-demo 对照 |
|---|---|---|
/health | 匿名 | 健康检查 |
/api/v1/products GET | 匿名只读 | DRF AllowAny 只读 |
/api/v1/products POST | 认证 + 角色 | IsAuthenticated |
/catalog/** | 认证 + ROLE_STAFF | @login_required + staff |
/h2-console/** | 仅 dev + 限制 IP | 无 Admin DB 控制台 |
说明:用户表、密码均为教学虚构;勿使用弱密码上任何真实环境。
6.2 依赖与默认行为
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
引入后默认:
- 所有端点需认证
- 生成随机密码用户
user(控制台打印)——需自定义SecurityFilterChain - 启用 CSRF(浏览器表单)
- 提供 表单登录 页
/login
启动后访问 /catalog 应跳转登录——说明 Security 已生效。
6.3 用户实体与 Repository
6.3.1 User 实体(简化)
@Entity
@Table(name = "users")
public class AppUser {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 64)
private String username;
@Column(nullable = false, length = 100)
private String passwordHash;
@Column(nullable = false)
private boolean enabled = true;
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "user_roles", joinColumns = @JoinColumn(name = "user_id"))
@Column(name = "role")
private Set<String> roles = Set.of("ROLE_STAFF");
protected AppUser() {}
public AppUser(String username, String passwordHash) {
this.username = username;
this.passwordHash = passwordHash;
}
public String getUsername() { return username; }
public String getPasswordHash() { return passwordHash; }
public boolean isEnabled() { return enabled; }
public Set<String> getRoles() { return roles; }
}
Flyway V3__users.sql(示例):
CREATE TABLE users (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(64) NOT NULL UNIQUE,
password_hash VARCHAR(100) NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE TABLE user_roles (
user_id BIGINT NOT NULL,
role VARCHAR(32) NOT NULL,
PRIMARY KEY (user_id, role),
FOREIGN KEY (user_id) REFERENCES users(id)
);
| Django | Spring |
|---|---|
User 模型 | AppUser @Entity |
is_staff / is_superuser | ROLE_STAFF / ROLE_ADMIN |
Group | roles 集合或独立 Role 表(ch16 RBAC 深化) |
6.3.2 Repository
public interface AppUserRepository extends JpaRepository<AppUser, Long> {
Optional<AppUser> findByUsername(String username);
}
6.4 UserDetailsService 实现
@Service
public class DbUserDetailsService implements UserDetailsService {
private final AppUserRepository userRepository;
public DbUserDetailsService(AppUserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
AppUser user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("用户不存在"));
var authorities = user.getRoles().stream()
.map(SimpleGrantedAuthority::new)
.toList();
return User.builder()
.username(user.getUsername())
.password(user.getPasswordHash())
.disabled(!user.isEnabled())
.authorities(authorities)
.build();
}
}
Spring Security 的 User.builder() 是框架类,与实体 AppUser 区分。
6.5 BCrypt 密码编码
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
创建用户时:
String hash = passwordEncoder.encode("Demo@2026"); // 教学密码,勿用于生产
AppUser u = new AppUser("operator", hash);
userRepository.save(u);
| 算法 | Django | Spring |
|---|---|---|
| 默认 | PBKDF2 等 | BCrypt(可换 Argon2) |
| 校验 | user.check_password() | passwordEncoder.matches(raw, hash) |
| 明文存储 | 禁止 | 禁止 |
切勿在日志或 API 中返回 passwordHash。初始化可用 CommandLineRunner 仅 dev profile 插入演示账号。
6.6 SecurityFilterChain 配置
@Configuration
@EnableMethodSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/health", "/css/**", "/login").permitAll()
.requestMatchers("/h2-console/**").hasIpAddress("127.0.0.1")
.requestMatchers(HttpMethod.GET, "/api/v1/products/**").permitAll()
.requestMatchers("/catalog/**").hasRole("STAFF")
.requestMatchers(HttpMethod.POST, "/api/v1/**").authenticated()
.anyRequest().authenticated()
)
.formLogin(form -> form