纲要
- 核心接口:
UserDetails与UserDetailsServiceUserDetails接口的方法与扩展点UserDetailsService的职责与loadUserByUsername方法
- 认证流程中的角色:
AuthenticationManager、UserDetailsService、PasswordEncoder - 数据库认证的默认表结构:
users与authorities - 实战:从内存认证切换到 JDBC 认证
- 添加 Spring JDBC 与 H2 依赖
- 配置数据源与 H2 Web 控制台
- 编写安全配置类,启用
jdbcAuthentication() - 启动应用并验证默认表与认证行为
- 打印 SQL 日志以观察查询过程
- 自定义
UserDetails实现的基本思路 - 小结
UserDetails ------ 安全上下文中的用户模型
UserDetails 是 Spring Security 中对用户信息的抽象,其本身是一个接口,而非具体类。这种设计使得框架能够适配几乎任何用户数据结构,同时保留高度可扩展性。查看 Spring Security 源码可以看到该接口的定义:
java
public interface UserDetails extends Serializable {
Collection<? extends GrantedAuthority> getAuthorities();
String getPassword();
String getUsername();
boolean isAccountNonExpired();
boolean isAccountNonLocked();
boolean isCredentialsNonExpired();
boolean isEnabled();
}
关键方法说明
getAuthorities():返回用户拥有的权限集合,每个权限通常由GrantedAuthority表示。getPassword()与getUsername():安全框架要求必须提供用户名和密码。开发时可在实现时自由映射,例如用邮箱代替用户名,只需在getUsername()方法中返回邮箱即可。isAccountNonExpired():账户是否未过期。某些企业级项目要求账户具有有效期,超过期限即不可用。isAccountNonLocked():账户是否未被锁定。锁定与禁用(isEnabled())略有不同:锁定的账户可能允许登录但无法执行操作,而禁用的账户直接拒绝登录。isCredentialsNonExpired():凭证(密码)是否未过期。当密码有有效期时,过期后系统可强制用户修改密码。isEnabled():账户是否激活,是最常用的状态控制字段。
通常情况下,最简单的实现可以只关注 username、password 和 enabled,其余方法直接返回 true。若需要自定义用户对象,只需让实体类实现该接口并提供相应逻辑。
UserDetailsService ------ 数据加载的桥梁
UserDetailsService 负责从数据源(如数据库)中加载用户信息并构建 UserDetails 对象。它仅定义了一个方法:
java
public interface UserDetailsService {
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
实现时,根据用户名查询用户实体,若实体类已经实现了 UserDetails 接口,则直接返回即可;否则需要构造一个 UserDetails 实例(例如使用 Spring 提供的 org.springframework.security.core.userdetails.User 类)。
需要特别注意:UserDetailsService 本身并不执行认证,它只是提供数据的服务。真正的认证由 AuthenticationManager 协调,典型的实现是 ProviderManager,它会调用 DaoAuthenticationProvider,后者使用 UserDetailsService 获取用户信息,再通过 PasswordEncoder 验证密码。
在认证成功后,Authentication 对象的 principal 字段存储的就是 UserDetails 实例。由于 getPrincipal() 返回 Object 类型,因此具备极强的扩展性,你可以将任意对象放入安全上下文,但数据库认证场景下最常见的就是 UserDetails。
认证流程概览
以下时序图展示了基于数据库认证时的核心交互步骤:
Database UserDetailsService DaoAuthenticationProvider AuthenticationManager UsernamePasswordAuthenticationFilter Client Database UserDetailsService DaoAuthenticationProvider AuthenticationManager UsernamePasswordAuthenticationFilter Client #mermaid-svg-IDJH1mOKhlHDVgyH{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-IDJH1mOKhlHDVgyH .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-IDJH1mOKhlHDVgyH .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-IDJH1mOKhlHDVgyH .error-icon{fill:#552222;}#mermaid-svg-IDJH1mOKhlHDVgyH .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-IDJH1mOKhlHDVgyH .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-IDJH1mOKhlHDVgyH .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-IDJH1mOKhlHDVgyH .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-IDJH1mOKhlHDVgyH .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-IDJH1mOKhlHDVgyH .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-IDJH1mOKhlHDVgyH .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-IDJH1mOKhlHDVgyH .marker{fill:#333333;stroke:#333333;}#mermaid-svg-IDJH1mOKhlHDVgyH .marker.cross{stroke:#333333;}#mermaid-svg-IDJH1mOKhlHDVgyH svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-IDJH1mOKhlHDVgyH p{margin:0;}#mermaid-svg-IDJH1mOKhlHDVgyH .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-IDJH1mOKhlHDVgyH text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-IDJH1mOKhlHDVgyH .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-IDJH1mOKhlHDVgyH .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-IDJH1mOKhlHDVgyH .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-IDJH1mOKhlHDVgyH .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-IDJH1mOKhlHDVgyH #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-IDJH1mOKhlHDVgyH .sequenceNumber{fill:white;}#mermaid-svg-IDJH1mOKhlHDVgyH #sequencenumber{fill:#333;}#mermaid-svg-IDJH1mOKhlHDVgyH #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-IDJH1mOKhlHDVgyH .messageText{fill:#333;stroke:none;}#mermaid-svg-IDJH1mOKhlHDVgyH .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-IDJH1mOKhlHDVgyH .labelText,#mermaid-svg-IDJH1mOKhlHDVgyH .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-IDJH1mOKhlHDVgyH .loopText,#mermaid-svg-IDJH1mOKhlHDVgyH .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-IDJH1mOKhlHDVgyH .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-IDJH1mOKhlHDVgyH .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-IDJH1mOKhlHDVgyH .noteText,#mermaid-svg-IDJH1mOKhlHDVgyH .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-IDJH1mOKhlHDVgyH .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-IDJH1mOKhlHDVgyH .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-IDJH1mOKhlHDVgyH .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-IDJH1mOKhlHDVgyH .actorPopupMenu{position:absolute;}#mermaid-svg-IDJH1mOKhlHDVgyH .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-IDJH1mOKhlHDVgyH .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-IDJH1mOKhlHDVgyH .actor-man circle,#mermaid-svg-IDJH1mOKhlHDVgyH line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-IDJH1mOKhlHDVgyH :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt密码匹配密码错误 提交用户名/密码authenticate(token)authenticate(token)loadUserByUsername(username)查询用户用户记录UserDetailspasswordEncoder.matches(raw, encoded)认证成功 Authentication已认证的 Authentication抛出 BadCredentialsException
默认数据库表结构
Spring Security 为 JDBC 认证提供了默认的表结构,最少仅需两张表:users 和 authorities。
users 表:
| 列名 | 描述 |
|---|---|
| username | 用户名(主键) |
| password | 加密后的密码 |
| enabled | 是否启用(布尔值) |
authorities 表:
| 列名 | 描述 |
|---|---|
| username | 用户名(外键) |
| authority | 权限字符串 |
在实现最基础的数据库认证时,UserDetails 中账户是否过期、锁定等状态均可直接返回 true,数据库中只需存储上述三个字段即可。
实战:从内存认证切换到 JDBC 认证
下面通过一个完整的 Spring Boot 示例,演示如何将内存用户存储切换为基于数据库的 JDBC 认证,并使用 H2 内存数据库快速验证。
项目结构
dir
src/
└── main/
├── java/com/example/security/
│ ├── SecurityApplication.java
│ ├── config/SecurityConfig.java
│ └── controller/HomeController.java
└── resources/
└── application.yml
添加依赖
在 pom.xml 中引入 spring-boot-starter-jdbc 和 H2 数据库依赖:
xml
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
配置数据源与 H2 控制台
在 application.yml 中设置数据源及 H2 Web 控制台:
yml
spring:
datasource:
url: jdbc:h2:mem:testdb;MODE=MySQL;DB_CLOSE_DELAY=-1
driver-class-name: org.h2.Driver
username: sa
password:
h2:
console:
enabled: true
path: /h2-console
jpa:
show-sql: false
logging:
level:
org.springframework.jdbc.core: DEBUG
MODE=MySQL让 H2 兼容 MySQL 语法,方便将来迁移到真实 MySQL。DB_CLOSE_DELAY=-1保持内存数据库在连接关闭后不被销毁。- 日志级别设为
DEBUG以便观察 SQL 语句的执行。
安全配置类
创建 SecurityConfig,启用 JDBC 认证并使用默认表结构:
java
package com.example.security.config;
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.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import javax.sql.DataSource;
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final DataSource dataSource;
public SecurityConfig(DataSource dataSource) {
this.dataSource = dataSource;
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.jdbcAuthentication()
.dataSource(dataSource)
.withDefaultSchema()
.passwordEncoder(passwordEncoder());
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/h2-console/**").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
.and()
.httpBasic();
// 允许 H2 控制台使用 frame 框架
http.headers().frameOptions().sameOrigin();
// 禁用 CSRF 以方便 H2 控制台访问(生产环境需考虑安全)
http.csrf().disable();
}
@Bean
public PasswordEncoder passwordEncoder() {
// 演示环境使用明文编码,生产务必使用 BCrypt 等
return NoOpPasswordEncoder.getInstance();
}
}
说明:
withDefaultSchema()会基于 H2 数据库自动创建users和authorities两张表,并默认插入两个用户:- 用户
user/ 密码password,角色ROLE_USER - 用户
admin/ 密码password,角色ROLE_USER,ROLE_ADMIN
- 用户
passwordEncoder为了简单演示使用了NoOpPasswordEncoder,实际项目必须替换为BCryptPasswordEncoder。- 配置放行
/h2-console/**路径并允许 frame 同源请求,以便通过浏览器访问数据库管理界面。
创建启动类与控制器
java
package com.example.security;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class SecurityApplication {
public static void main(String[] args) {
SpringApplication.run(SecurityApplication.class, args);
}
}
java
package com.example.security.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class HomeController {
@GetMapping("/")
public String home() {
return "Welcome! You are authenticated.";
}
@GetMapping("/admin")
public String admin() {
return "Admin page.";
}
}
启动与验证
- 启动应用,访问
http://localhost:8080,会跳转至登录页面。 - 使用默认用户
user/password或admin/password登录,可看到欢迎信息。 - 访问
http://localhost:8080/h2-console,JDBC URL 使用jdbc:h2:mem:testdb,用户名sa,空密码,登录后可查看自动生成的表及数据:
sql
SELECT * FROM USERS;
SELECT * FROM AUTHORITIES;
此时会发现数据库中已存在用户和对应的权限记录。
- 为了确认 JDBC 认证确实执行了数据库查询,可以在控制台日志中看到类似以下输出:
LOG
[DEBUG] Executing prepared SQL query [select username, password, enabled from users where username = ?]
[DEBUG] Executing prepared SQL query [select username, authority from authorities where username = ?]
这就是 JdbcDaoImpl 内部加载用户和权限的 SQL,证明数据来自数据库。
自定义 UserDetails 的方向
若项目需要更复杂的用户属性(如邮箱、手机号等),可让实体类实现 UserDetails 接口,然后自定义 UserDetailsService 实现。典型的步骤如下:
- 编写
UserEntity实现UserDetails,重写所有方法,将数据库字段映射到对应方法。 - 创建
CustomUserDetailsService实现UserDetailsService,在loadUserByUsername中通过 JPA/MyBatis 查询用户实体并返回。 - 在安全配置中注入自定义
UserDetailsService,并使用BCryptPasswordEncoder替代明文编码。
认证流程依然遵循前文所述的时序图,只是将 UserDetailsService 的实现替换为自定义版本。
小结
本文梳理了 Spring Security 中两个核心接口 UserDetails 和 UserDetailsService 的设计意图与使用方法,并演示了如何利用内置的 JDBC 认证机制快速将用户存储从内存切换到数据库。
通过 H2 内存数据库和默认表结构,可以零额外 SQL 编写即实现可运行的数据库认证,这对于原型开发或初期验证非常高效。后续内容将深入探讨如何自定义 UserDetails 实体并集成生产级密码编码器,以及 AuthenticationManager 的详细工作机理。