Java框架快速入门: Spring Security+OAuth2之深度定制UserDetailsService与密码无缝升级

纲要

  • 核心目标:替换 Spring Security 默认的用户加载逻辑,并实现密码编码自动升级
  • 关键组件
    • User 实体:实现 UserDetails,配合 JPA 和 Lombok
    • UserRepository:基于 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 优雅地转换为抛出异常或返回实体。@RequiredArgsConstructorfinal 字段生成构造器注入。

密码升级服务

当系统存在多种密码编码方案时(例如从 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 对象,不修改原对象,完全符合函数式风格。通过 maporElse 处理 Optional,逻辑清晰且无副作用。

安全配置

SecurityConfig 中,我们要做两件事:

  1. 把自定义的 UserDetailsService 注入 AuthenticationManagerBuilder
  2. 配置支持密码升级的 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 深度定制的推荐范式。

相关推荐
土司大王7 小时前
LeetCode 79 单词搜索:Java 回溯模板、网格 DFS 与剪枝优化
java·算法·leetcode·深度优先
白远山7 小时前
智慧场馆解决方案小程序系统开发实战与架构设计指南
java·开发语言·小程序·架构
ECT-OS-JiuHuaShan7 小时前
哲学是迭代学,数学是拓扑学
开发语言·人工智能·学习·算法·机器学习·php·拓扑学
影视飓风TIM7 小时前
C++异常与RAII智能指针笔记
开发语言·c++·笔记
霸道流氓气质7 小时前
Spring AI Alibaba vs LangChain4j:Java AI 框架选型深度指南
java·人工智能·spring
敲代码的嘎仔7 小时前
从零实现视频续播 + 学习进度统计:前端心跳、条件更新、GROUP BY 统计全链路拆解
java·前端·数据库·学习·面试·职场和发展·音视频
BD_Marathon7 小时前
调用DeepSeek官网的DeepSeek模型
开发语言·chrome·python
一个有温度的技术博主7 小时前
深入理解 Spring Boot 自动装配
java·spring boot·后端
草莓熊Lotso7 小时前
【Redis 初阶】C++ 客户端实战:从 RESP 协议到 redis-plus-plus 工程化用法
linux·开发语言·网络·数据库·c++·redis·缓存