概述
在构建基于 Spring Security 与 OAuth2 的安全体系时,标准的 JDBC 用户与权限存储有时难以满足复杂业务需求。本文将从定制化原理出发,深入讲解如何使用 JPA 实现自定义的 UserDetails 和 GrantedAuthority 接口,从而完成数据层的深度定制。
你将看到如何用 Hibernate 自动建表替代手写 SQL,如何通过实体关联映射用户与角色,以及如何编写可直接运行的代码示例。
纲要
UserDetailsManager与JdbcUserDetailsManager的定制原理- 移除 JDBC 依赖,引入
spring-boot-starter-data-jpa - 实体建模:实现
UserDetails的User实体 - 实体建模:实现
GrantedAuthority的Role实体 - 多对多关联映射与中间表配置
- 数据初始化脚本适配新字段
- 启动验证:Hibernate 自动建表结果
- 完整可运行示例代码
定制化原理回顾
在默认的 Spring Security 配置中,我们通常使用 withUser() 配合 withDefaultSchema() 来快速构建内存用户或基于内建表结构的 JDBC 用户。这背后是 JdbcUserDetailsManager 在起作用,它内部预置了建表 SQL 以及查询用户、角色的 SQL。当我们不使用 withDefaultSchema() 而采用自定义数据库结构时,需要通过 AuthenticationManagerBuilder 的 jdbcAuthentication() 并调用 usersByUsernameQuery()、authoritiesByUsernameQuery() 来替换查询语句,这是基于 JdbcDaoImpl 的实现方式。
进一步地,如果我们想完全掌控用户、角色的数据加载过程,甚至使用 JPA 替代 JDBC,就需要自己实现 UserDetails 和 UserDetailsService 接口。下面我们将展示如何利用 Spring Data JPA 完成这一深度定制。
切换至 JPA 并配置数据源
首先移除原有的 JDBC 依赖,在 pom.xml 中添加:
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
在 application.yml 中增加 JPA 及数据源配置:
yaml
spring:
datasource:
url: jdbc:h2:mem:testdb
driver-class-name: org.h2.Driver
username: sa
password:
jpa:
database: h2
database-platform: org.hibernate.dialect.H2Dialect
hibernate:
ddl-auto: update
show-sql: true
sql:
init:
mode: always
data-locations: classpath:data.sql
因为我们依赖 Hibernate 的自动建表能力,所以前期可以删除手写的 schema.sql,仅保留数据初始化脚本 data.sql(后续会调整字段)。
实现 UserDetails:用户实体
创建 User 实体,实现 UserDetails 接口。该实体将包含用户基本信息以及账户状态字段。
java
package com.example.security.entity;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import javax.persistence.*;
import java.util.Collection;
import java.util.Set;
@Entity
@Table(name = "mock_users")
public class User implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(length = 50, unique = true, nullable = false)
private String username;
@Column(name = "password_hash", length = 80, nullable = false)
private String password;
@Column(length = 50)
private String name;
@Column(length = 255, unique = true, nullable = false)
private String email;
@Column(length = 11)
private String mobile;
private boolean enabled = true;
@Column(name = "account_non_expired", nullable = false)
private boolean accountNonExpired = true;
@Column(name = "account_non_locked", nullable = false)
private boolean accountNonLocked = true;
@Column(name = "credentials_non_expired", nullable = false)
private boolean credentialsNonExpired = true;
// 角色关联
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(name = "mock_users_roles",
joinColumns = @JoinColumn(name = "user_id", referencedColumnName = "id"),
inverseJoinColumns = @JoinColumn(name = "role_id", referencedColumnName = "id"))
private Set<Role> authorities;
// 构造器
public User() {}
public User(String username, String password, String name, String email, String mobile,
Set<Role> authorities) {
this.username = username;
this.password = password;
this.name = name;
this.email = email;
this.mobile = mobile;
this.authorities = authorities;
}
// Getter / Setter 方法(可使用 Lombok @Data,但为清晰此处手动编写)
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
@Override
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
@Override
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public String getMobile() { return mobile; }
public void setMobile(String mobile) { this.mobile = mobile; }
@Override
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
@Override
public boolean isAccountNonExpired() { return accountNonExpired; }
public void setAccountNonExpired(boolean accountNonExpired) { this.accountNonExpired = accountNonExpired; }
@Override
public boolean isAccountNonLocked() { return accountNonLocked; }
public void setAccountNonLocked(boolean accountNonLocked) { this.accountNonLocked = accountNonLocked; }
@Override
public boolean isCredentialsNonExpired() { return credentialsNonExpired; }
public void setCredentialsNonExpired(boolean credentialsNonExpired) { this.credentialsNonExpired = credentialsNonExpired; }
@Override
public Collection<? extends GrantedAuthority> getAuthorities() { return authorities; }
public void setAuthorities(Set<Role> authorities) { this.authorities = authorities; }
}
注意:所有布尔类型字段均使用基础类型 boolean,这样 Lombok 或手动生成的 getter 会自动命名为 isXxx(),符合 UserDetails 接口规范。如果使用包装类型 Boolean,则需要显式定义 getter 方法以确保命名一致。
实现 GrantedAuthority:角色实体
创建 Role 实体并实现 GrantedAuthority 接口。
java
package com.example.security.entity;
import org.springframework.security.core.GrantedAuthority;
import javax.persistence.*;
import java.io.Serializable;
@Entity
@Table(name = "mock_roles")
public class Role implements GrantedAuthority, Serializable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "role_name", length = 50, unique = true, nullable = false)
private String authority;
public Role() {}
public Role(String authority) {
this.authority = authority;
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
@Override
public String getAuthority() {
return authority;
}
public void setAuthority(String authority) {
this.authority = authority;
}
}
关联映射详解
在 User 实体中,通过 @ManyToMany 和 @JoinTable 定义了用户与角色的多对多关系,中间表名为 mock_users_roles,包含 user_id 和 role_id 两个外键字段。为了减少查询次数,我们使用了 FetchType.EAGER,在加载用户的同时通过 JOIN 查询直接获取所有角色。
Hibernate 会根据这些注解自动生成数据库表结构,无需手动编写任何 DDL。启动应用后,控制台将打印建表语句,并可在 H2 控制台观察到 MOCK_USERS、MOCK_ROLES 和 MOCK_USERS_ROLES 三张表。
数据初始化脚本
在 src/main/resources/data.sql 中插入测试用户及角色数据。注意字段名需与实体映射一致。
sql
-- 插入角色
INSERT INTO mock_roles (id, role_name) VALUES (1, 'ROLE_USER');
INSERT INTO mock_roles (id, role_name) VALUES (2, 'ROLE_ADMIN');
-- 插入用户,密码是 BCrypt 加密后的 "123456"
INSERT INTO mock_users (id, username, password_hash, name, email, mobile, enabled,
account_non_expired, account_non_locked, credentials_non_expired)
VALUES (1, 'admin', '$2a$10$...', '管理员', 'admin@example.com', '13000000001', true, true, true, true);
-- 建立用户-角色关联
INSERT INTO mock_users_roles (user_id, role_id) VALUES (1, 1);
INSERT INTO mock_users_roles (user_id, role_id) VALUES (1, 2);
请将 password_hash 替换为实际 BCrypt 密文,可通过 new BCryptPasswordEncoder().encode("123456") 生成。
自定义 UserDetailsService
为了整合上述实体,我们需要实现 UserDetailsService 接口,利用 Spring Data JPA 的 Repository 进行查询。
java
package com.example.security.service;
import com.example.security.entity.User;
import com.example.security.repository.UserRepository;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@Service
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
public CustomUserDetailsService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("用户不存在: " + username));
}
}
对应的 Repository 接口:
java
package com.example.security.repository;
import com.example.security.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
}
安全配置类
最后,在 Spring Security 配置中注入自定义的 UserDetailsService。
java
package com.example.security.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final UserDetailsService userDetailsService;
public SecurityConfig(UserDetailsService userDetailsService) {
this.userDetailsService = userDetailsService;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/public/**").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
.permitAll();
return http.build();
}
@Autowired
public void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
}
}
项目结构概览
dir
src/main/java
└── com.example.security
├── config
│ └── SecurityConfig.java
├── entity
│ ├── User.java
│ └── Role.java
├── repository
│ └── UserRepository.java
└── service
└── CustomUserDetailsService.java
src/main/resources
├── application.yml
└── data.sql
验证运行结果
启动应用后,Hibernate 会自动创建三张表,执行 data.sql 插入初始数据。访问 H2 控制台(默认 /h2-console)可以看到 MOCK_USERS、MOCK_ROLES 以及 MOCK_USERS_ROLES 的结构及数据。随后通过登录表单使用 admin / 123456 进行登录,Spring Security 将通过我们自定义的 CustomUserDetailsService 加载用户及角色。
总结
本文从 JdbcUserDetailsManager 的定制化原理切入,展示了如何彻底抛弃默认 JDBC 依赖,使用 JPA 与 Hibernate 实现完全自定义的 UserDetails 和 GrantedAuthority。
通过实体映射、多对多关联以及 EAGER 抓取策略,我们实现了用户与角色的高效加载,并为后续集成 OAuth2 或 Rest API 鉴权奠定了坚实基础。