纲要
- 核心目标:替换 Spring Security 默认的用户加载逻辑,并实现密码编码自动升级
- 关键组件
User实体:实现UserDetails,配合 JPA 和 LombokUserRepository:基于 Spring Data JPA,提供Optional查询MyUserDetailsService:实现UserDetailsService,从数据库加载用户MyUserDetailsPasswordService:实现UserDetailsPasswordService,登录时自动升级密码哈希SecurityConfig:装配自定义服务,配置密码编码器策略
- 流程概览
- 认证时调用
MyUserDetailsService.loadUserByUsername - 若密码需要升级(旧编码仍可匹配,但已不是当前主力编码),Spring Security 调用
MyUserDetailsPasswordService.updatePassword - 新哈希写入数据库,用户无感知
- 认证时调用
项目结构
dir
src/main/java/com/example/security/
├── entity
│ └── User.java
├── repository
│ └── UserRepository.java
├── service
│ ├── MyUserDetailsService.java
│ └── MyUserDetailsPasswordService.java
├── config
│ └── SecurityConfig.java
└── SecurityApplication.java
src/main/resources/
└── application.yml
实体与持久层
首先定义用户实体,它既要映射数据库表,又要满足 Spring Security 的 UserDetails 契约。我们使用 Lombok 减少样板代码,并通过 @With 生成不可变风格的"修改并返回新对象"方法,这在密码升级时会非常有用。
java
package com.example.security.entity;
import lombok.*;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import javax.persistence.*;
import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;
@Entity
@Table(name = "users")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@With
public class User implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String username;
@Column(nullable = false)
private String password;
private boolean enabled;
private String email;
// 简单起见,将角色存储为逗号分隔字符串,实际项目建议使用关联表
private String roles; // 例如 "ROLE_USER,ROLE_ADMIN"
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(roles.split(",")).stream()
.map(String::trim)
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
}
@Override
public boolean isAccountNonExpired() { return true; }
@Override
public boolean isAccountNonLocked() { return true; }
@Override
public boolean isCredentialsNonExpired() { return true; }
@Override
public boolean isEnabled() { return enabled; }
}
持久层使用 Spring Data JPA,利用方法命名自动生成查询。findOptionalByUsername 返回 Optional<User>,天然表达"可能查不到"的语义,避免显式 null 判断。
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> findOptionalByUsername(String username);
}
自定义 UserDetailsService
UserDetailsService 是 Spring Security 加载用户信息的核心接口,只有一个方法 loadUserByUsername。我们需要把它替换为自己的实现,从数据库查询用户。若用户不存在,必须抛出 UsernameNotFoundException。
java
package com.example.security.service;
import com.example.security.entity.User;
import com.example.security.repository.UserRepository;
import lombok.RequiredArgsConstructor;
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
@RequiredArgsConstructor
public class MyUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return userRepository.findOptionalByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("用户不存在: " + username));
}
}
这里使用了 orElseThrow,将 Optional 优雅地转换为抛出异常或返回实体。@RequiredArgsConstructor 为 final 字段生成构造器注入。
密码升级服务
当系统存在多种密码编码方案时(例如从 MD5 向 BCrypt 迁移),UserDetailsPasswordService 提供了"登录即升级"的能力。Spring Security 在认证成功后,如果发现当前使用的密码编码不是主力编码器,就会调用 updatePassword 方法,传入已认证的 UserDetails 和新密码的哈希。
实现时,我们从数据库查出现有用户,用 @With 生成的 withPassword(newPassword) 创建一份包含新哈希的对象并保存。若数据库中没有该用户(极端情况),直接返回传入的 user 对象作为兜底。
java
package com.example.security.service;
import com.example.security.entity.User;
import com.example.security.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsPasswordService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class MyUserDetailsPasswordService implements UserDetailsPasswordService {
private final UserRepository userRepository;
@Transactional
@Override
public UserDetails updatePassword(UserDetails user, String newPassword) {
return userRepository.findOptionalByUsername(user.getUsername())
.map(dbUser -> userRepository.save(dbUser.withPassword(newPassword)))
.map(UserDetails.class::cast)
.orElse(user);
}
}
注意:withPassword 返回新的 User 对象,不修改原对象,完全符合函数式风格。通过 map 与 orElse 处理 Optional,逻辑清晰且无副作用。
安全配置
在 SecurityConfig 中,我们要做两件事:
- 把自定义的
UserDetailsService注入AuthenticationManagerBuilder; - 配置支持密码升级的
PasswordEncoder,并暴露UserDetailsPasswordService对应的 bean。
这里使用 DelegatingPasswordEncoder 作为主力编码器,并允许旧 MD5 编码兼容。认证成功后,MD5 存储的密码会被自动升级为 BCrypt。
java
package com.example.security.config;
import com.example.security.service.MyUserDetailsPasswordService;
import com.example.security.service.MyUserDetailsService;
import lombok.RequiredArgsConstructor;
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.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.DelegatingPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.crypto.password.MessageDigestPasswordEncoder;
import java.util.Map;
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final MyUserDetailsService myUserDetailsService;
private final MyUserDetailsPasswordService myUserDetailsPasswordService;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(myUserDetailsService)
.passwordEncoder(passwordEncoder());
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/api/public").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
.and()
.httpBasic();
}
@Bean
public PasswordEncoder passwordEncoder() {
// 默认使用 BCrypt 作为主力编码器
String idForEncode = "bcrypt";
Map<String, PasswordEncoder> encoders = Map.of(
idForEncode, new BCryptPasswordEncoder(),
"MD5", new MessageDigestPasswordEncoder("MD5")
);
DelegatingPasswordEncoder encoder = new DelegatingPasswordEncoder(idForEncode, encoders);
// 为老密码使用默认前缀匹配,这里简单设置为 MD5 作为备选
encoder.setDefaultPasswordEncoderForMatches(new MessageDigestPasswordEncoder("MD5"));
return encoder;
}
@Bean
public UserDetailsPasswordService userDetailsPasswordService() {
return myUserDetailsPasswordService;
}
}
配置要点说明:
DelegatingPasswordEncoder允许密码存储时携带前缀{bcrypt}或{MD5},已存的老 MD5 密码可正常匹配。- 当使用 MD5 匹配成功后,Spring Security 检测到与主力编码
bcrypt不一致,便会调用UserDetailsPasswordService升级密码。 - 升级完成后,数据库中的密码会被替换为
{bcrypt}$2a$...格式,后续认证直接走 BCrypt。
流程可视化
MyUserDetailsPasswordService DelegatingPasswordEncoder UserRepository MyUserDetailsService SecurityFilter Browser MyUserDetailsPasswordService DelegatingPasswordEncoder UserRepository MyUserDetailsService SecurityFilter Browser #mermaid-svg-I6h80wYKAH8uAMtC{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-I6h80wYKAH8uAMtC .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-I6h80wYKAH8uAMtC .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-I6h80wYKAH8uAMtC .error-icon{fill:#552222;}#mermaid-svg-I6h80wYKAH8uAMtC .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-I6h80wYKAH8uAMtC .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-I6h80wYKAH8uAMtC .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-I6h80wYKAH8uAMtC .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-I6h80wYKAH8uAMtC .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-I6h80wYKAH8uAMtC .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-I6h80wYKAH8uAMtC .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-I6h80wYKAH8uAMtC .marker{fill:#333333;stroke:#333333;}#mermaid-svg-I6h80wYKAH8uAMtC .marker.cross{stroke:#333333;}#mermaid-svg-I6h80wYKAH8uAMtC svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-I6h80wYKAH8uAMtC p{margin:0;}#mermaid-svg-I6h80wYKAH8uAMtC .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-I6h80wYKAH8uAMtC text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-I6h80wYKAH8uAMtC .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-I6h80wYKAH8uAMtC .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-I6h80wYKAH8uAMtC .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-I6h80wYKAH8uAMtC .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-I6h80wYKAH8uAMtC #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-I6h80wYKAH8uAMtC .sequenceNumber{fill:white;}#mermaid-svg-I6h80wYKAH8uAMtC #sequencenumber{fill:#333;}#mermaid-svg-I6h80wYKAH8uAMtC #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-I6h80wYKAH8uAMtC .messageText{fill:#333;stroke:none;}#mermaid-svg-I6h80wYKAH8uAMtC .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-I6h80wYKAH8uAMtC .labelText,#mermaid-svg-I6h80wYKAH8uAMtC .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-I6h80wYKAH8uAMtC .loopText,#mermaid-svg-I6h80wYKAH8uAMtC .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-I6h80wYKAH8uAMtC .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-I6h80wYKAH8uAMtC .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-I6h80wYKAH8uAMtC .noteText,#mermaid-svg-I6h80wYKAH8uAMtC .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-I6h80wYKAH8uAMtC .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-I6h80wYKAH8uAMtC .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-I6h80wYKAH8uAMtC .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-I6h80wYKAH8uAMtC .actorPopupMenu{position:absolute;}#mermaid-svg-I6h80wYKAH8uAMtC .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-I6h80wYKAH8uAMtC .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-I6h80wYKAH8uAMtC .actor-man circle,#mermaid-svg-I6h80wYKAH8uAMtC line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-I6h80wYKAH8uAMtC :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt编码非主力 (MD5) alt密码匹配密码不匹配 POST /login (username, password)loadUserByUsername(username)findOptionalByUsername(username)Optional<User>UserDetails (or throw)matches(raw, encoded)updatePassword(user, newBcryptHash)save(user.withPassword(newHash))升级后的 UserDetails认证成功认证失败
启动类与配置
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);
}
}
yaml
# application.yml
spring:
datasource:
url: jdbc:h2:mem:testdb
driver-class-name: org.h2.Driver
username: sa
password:
jpa:
hibernate:
ddl-auto: update
show-sql: true
验证效果
启动应用后,可通过控制台或接口观察密码升级。假设数据库中有一条用户记录,password 字段存储为 {MD5}5f4dcc3b5aa765d61d8327deb882cf99(对应 password)。登录成功后,再次查询数据库,密码已变为 {bcrypt}$2a$10$...,而用户登录过程完全无感。
总结
通过自定义 UserDetailsService,我们可以将用户数据源完全切换至数据库或任何外部系统;结合 UserDetailsPasswordService,能够零停机、平滑地实现密码编码算法升级。
整个过程中,Optional 让空值处理更加优雅,Lombok 的 @With 则保证了不可变对象的便捷修改。这种组合方式已成为 Spring Security 深度定制的推荐范式。