
接手项目的时候一看代码SecurityConfig 里写的是 InMemoryUserDetailsManager测试环境跑得挺欢。等到联调产品说用户表都建好了你这边什么时候把登录接上我心里就咯噔一下。Spring Security 从内存用户切到数据库用户听起来就是换一个数据来源的事实际动手改的时候才知道密码加密、UserDetailsService、角色权限、会话策略这些全都绕不开。这篇总结就是记录我这次改造的完整过程从内存用户到数据库密码从明文换成 BCrypt一步一步怎么改的、踩了哪些坑都写出来。这个方案适合正在用 Spring Security 做项目、但还停留在内存用户阶段的朋友也适合那种“密码直接明文怼在数据库里”的老项目准备做安全加固的情况。里面涉及的代码和配置都是可以直接抄作业的水平我会把每一步为什么这么写的逻辑也讲清楚不搞那种“配好了能跑但说不出道理”的玄学。1. 从内存用户换到数据库用户到底要解决什么问题1.1 内存用户方案为什么撑不起真实项目先说结论InMemoryUserDetailsManager 是拿来学框架、写 Demo、做单元测试用的不是拿来顶生产环境的。它的本质是在应用启动时把用户名、密码、角色这些数据硬编码进内存应用一重启数据就没了用户根本没法自助注册也没法做用户管理更谈不上跟业务系统的用户体系打通。更麻烦的是一旦用户数量上来哪怕只是几百个人想靠配置文件维护账号密码都是一种灾难。内存用户还有一个隐蔽的问题它容易让人忽略认证流程的本质。很多初学者配完内存用户以后以为 Spring Security 的认证就是“配置里写谁就是谁”完全没搞清楚 UserDetailsService 和 PasswordEncoder 在中间扮演的角色。结果一到换数据库的时候就懵了用户表建好了、Repository 也写了但登录就是不生效报错永远是 Encoded password does not look like BCrypt。这次改造的本质是把“写死在配置里的用户”替换成“从数据库查出来的用户”同时把“明文密码或简单哈希密码”升级为“BCrypt 加密密码”。前者靠自定义 UserDetailsService 实现后者靠 PasswordEncoder 实现。两个东西是并行的缺一个登录都跑不通。1.2 这次改造的完整目标和方案选型我这次项目的技术栈是 Spring Boot 2.7 Spring Security 5.7 MySQL 8.0 JPA。选这套组合没有太多纠结因为项目里已经用了 JPA不想为了安全框架再引入一套 MyBatis 徒增复杂度。如果你用的是 MyBatis-Plus 或者 Spring Data JDBC核心逻辑完全一样把查询用户的那段代码换成对应的写法就行。改完以后要达到几个目标SecurityConfig 里不再出现任何硬编码的用户名密码。用户信息统一从 sys_user 表读取角色从 sys_role 表读取通过中间表关联。密码字段统一存储 BCrypt 密文长度 60 位所有新增用户走加密逻辑不允许明文入库。登录校验沿用 Spring Security 默认的表单登录流程不做过度定制。默认提供一个初始化用户方便后续联调。方案选型上密码加密我直接选了 BCryptPasswordEncoder这是 Spring Security 官方推荐的实现也是目前 Java 生态里最稳的选择。MySQL 端要注意密码字段的长度之前见太多人把密码字段设成 VARCHAR(20)存 BCrypt 直接报 Data truncation这个坑我后面单独讲。2. 工程搭建与表结构设计2.1 创建工程引入依赖如果你的项目是从零开始直接去 Spring Initializr 生成一个工程就行。我这次是在老项目上改造所以只需要确认几件事spring-boot-starter-security 有没有引入、数据库驱动有没有配好、JPA 或 MyBatis 的依赖在不在。pom.xml 里最关键的安全相关依赖就这一段dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-jpa/artifactId /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency注意Spring Boot 2.7 的 spring-boot-starter-security 会自动引入 Spring Security 5.7版本不需要你手动管理。Spring Security 5.7 有一个比较重要的变化就是 WebSecurityConfigurerAdapter 被标记为过时官方推荐用 SecurityFilterChain Bean 的方式做配置。我这次用的就是新的写法代码更简洁也避免了老的继承方式带来的各种诡异问题。2.2 数据表设计权限模型一次想清楚很多教程为了省事只建了一张 user 表里面放一个 role 字符串字段比如 ROLE_ADMIN。这种设计在 Demo 里没问题但真实项目里角色和用户基本是多对多关系而且后续大概率要加权限点。我这次直接按三张表来设计用户表、角色表、用户角色关联表。CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, enabled TINYINT DEFAULT 1, created_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE sys_role ( id BIGINT PRIMARY KEY AUTO_INCREMENT, role_code VARCHAR(50) NOT NULL UNIQUE ); CREATE TABLE sys_user_role ( user_id BIGINT NOT NULL, role_id BIGINT NOT NULL, PRIMARY KEY (user_id, role_id) );这里有两个细节容易踩坑。第一个是 password 字段的长度BCrypt 密文固定是 60 个字符但为了兼容后续可能换加密算法我直接给到 VARCHAR(100)。第二个是 username 字段要加唯一索引Spring Security 的用户名唯一性是这个框架默认假设的如果数据库里出现重复用户名认证逻辑会变得不可预测有时候能登录有时候不能排查起来非常难受。2.3 连接数据库前的配置准备application.yml 里需要把数据源和 JPA 配置好。我顺手打开了 SQL 日志开发阶段能看到 Hibernate 实际执行的 SQL排查问题很有用。spring: datasource: url: jdbc:mysql://localhost:3306/security_demo?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver jpa: hibernate: ddl-auto: update show-sql: true properties: hibernate: format_sql: true这里建议把 ddl-auto 设成 update 还是 validate 要根据项目阶段来。如果你跟我一样是在已有表结构上做改造建议使用 validate 或者干脆手动建表避免 Hibernate 自动改表结构把你坑了。我第一次用 update 的时候它自己给 sys_user 表加了几列看着没问题后来才发现字段类型和预期不一致白白排查了半天。3. 核心改造自定义 UserDetailsService 替换内存用户3.1 理解 UserDetailsService 在认证流程里的位置Spring Security 的认证流程可以简单概括成三步获取用户输入的用户名密码、调用 AuthenticationManager 进行校验、校验成功后把认证信息放进 SecurityContext。AuthenticationManager 本身不关心用户数据存在哪它只负责拿着用户名去调用 UserDetailsService 的 loadUserByUsername 方法拿到一个 UserDetails 对象然后用 PasswordEncoder 比对密码。所以你把内存用户的配置删掉之后Spring Security 默认会去找容器里有没有 UserDetailsService 这个 Bean。如果有就用你提供的如果没有它就自己创建一个基于内存的实现。很多人删了 InMemoryUserDetailsManager 以后报错提示找不到用户正是因为容器里根本没有 UserDetailsService。3.2 实体类和 Repository 的编写要点为了配合 JPA我写了三个实体类但真正跟认证相关的只有 SysUser。这里有个最常见的坑实体类里千万不要直接持有密码字段之外的安全敏感信息更不要把角色字段设计成字符串拼接。我就在项目里见过有人把角色存成 ROLE_ADMIN,ROLE_USER 这种逗号分隔的字符串后来做权限判断的时候各种 not work最后还得拆表。Entity Table(name sys_user) public class SysUser { 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; ManyToMany(fetch FetchType.EAGER) JoinTable(name sys_user_role, joinColumns JoinColumn(name user_id), inverseJoinColumns JoinColumn(name role_id)) private SetSysRole roles new HashSet(); // 省略 getter/setter }我特意把角色抓取策略设成了 EAGER目的很简单loadUserByUsername 返回 UserDetails 的时候需要立刻拿到角色列表如果用 LAZYSession 一关就会报 LazyInitializationException。虽然从性能角度看 EAGER 不一定最优但安全认证场景下每个请求的登录频率本来就低EAGER 反而省心。等以后真有性能瓶颈了再优化也不迟。3.3 自定义 UserDetailsService 实现这是整个改造的枢纽。我实现 Spring Security 提供的 UserDetailsService 接口重写 loadUserByUsername 方法。Service public class CustomUserDetailsService implements UserDetailsService { private final SysUserRepository userRepository; public CustomUserDetailsService(SysUserRepository userRepository) { this.userRepository userRepository; } Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { SysUser user userRepository.findByUsername(username) .orElseThrow(() - new UsernameNotFoundException(用户不存在: username)); return User.builder() .username(user.getUsername()) .password(user.getPassword()) .roles(user.getRoles().stream() .map(SysRole::getRoleCode) .toArray(String[]::new)) .disabled(!user.isEnabled()) .build(); } }这里有几个重点。第一个是返回的 UserDetails 对象可以直接用 Spring Security 提供的 User.builder() 来构建不用自己再造一个实现类。第二个是 roles() 方法会自动把所有角色加上 ROLE_ 前缀所以 sys_role 表里存的是 ADMIN 而不是 ROLE_ADMIN。如果你表里已经存成 ROLE_ADMIN再用 roles() 方法最后会变成 ROLE_ROLE_ADMIN这种低级错误我见过不止一次。第三个重点是异常处理。不要尝试自己吞掉异常然后返回一个空对象一定要抛 UsernameNotFoundException。Spring Security 靠这个异常触发后续的认证失败流程如果你自己不抛认证会静默失败用户看到的现象是登录页面刷一下就弹回来根本不知道发生了什么。3.4 修改 SecurityConfig 注入数据源用户配置类这块我用的新版写法核心是定义一个 SecurityFilterChain Bean。Configuration EnableWebSecurity public class SecurityConfig { private final CustomUserDetailsService userDetailsService; public SecurityConfig(CustomUserDetailsService userDetailsService) { this.userDetailsService userDetailsService; } Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth - auth .requestMatchers(/login, /register, /css/**, /js/**).permitAll() .requestMatchers(/admin/**).hasRole(ADMIN) .anyRequest().authenticated() ) .formLogin(form - form .loginPage(/login) .loginProcessingUrl(/login) .defaultSuccessUrl(/index, true) .failureUrl(/login?errortrue) .permitAll() ) .logout(logout - logout .logoutUrl(/logout) .logoutSuccessUrl(/login) .permitAll() ) .userDetailsService(userDetailsService); return http.build(); } }这里最容易被忽视的是.userDetailsService(userDetailsService)那一行。Spring Security 5.7 之后如果你在配置类里注入了 AuthenticationManager 相关的 Bean可能会出现循环依赖的问题但通常不需要手动配置 AuthenticationManager它会自动从容器里找到 UserDetailsService。加上这一行明确告诉 Spring Security 用哪个服务来加载用户不容易出幺蛾子。4. 密码加密从明文到 BCrypt 的一次到位4.1 为什么不能再明文存储明文密码这个问题说严重一点一旦数据库泄露等于所有用户的账号都裸奔了。很多人觉得“我不可能被拖库”但现实是数据泄露的渠道太多了开发环境配置泄露、日志文件泄露、备份文件泄露、内部人员导出数据任何一条路径出事明文密码都是灾难性的。即使用户的密码在他的系统里可能不重要但很多人习惯一套密码走天下泄露一个就等于泄露了一大片。更不要说合规层面的要求了现在很多等保和隐私合规的检查里密码加密存储都是硬指标。与其等项目审计的时候补不如一开始就做对。业内对密码加密的主流方案是加盐哈希而且这个哈希算法要设计得刻意“慢”让暴力破解的成本高到不值得。MD5 和 SHA-1 这类算法之所以被淘汰就是因为它们太快了GPU 并行每秒能算几十亿次。而 BCrypt 内嵌随机盐且计算有一个可调的工作因子配合良好的实现单次校验就能消耗掉毫秒级甚至更长的 CPU 时间暴力破解的性价比瞬间就低了。4.2 BCrypt 的原理与优势BCrypt 是 OpenBSD 团队设计的密码哈希算法基于 Blowfish 加密算法演变而来。新手可以把它想象成一个“加了调料再慢炖”的过程哪怕两个人密码一模一样只要盐不同最后生成的 60 位字符串也完全不一样。这个特性直接解决了传统哈希的彩虹表攻击问题。一个标准 BCrypt 密文长这样$2a$10$Yd5X6VkFZv/w3HG6f/xq8u8Q6FY1bqRTcX9z0CMpHHw0sN7QvGwuq拆开看$2a$ 是算法版本标识10 是成本因子cost factor后面 22 个字符是盐再后面是 31 个字符的哈希值。这里的成本因子决定了计算强度数值每加 1计算时间大约翻一倍。生产环境一般设 10 到 12 之间太低不安全太高用户登录时会明显感觉卡顿。Spring Security 的 BCryptPasswordEncoder 最大的好处就是省心你只管调用 encode() 生成密文、matches() 校验密码盐的生成和存储框架都自动处理了不需要额外在数据库里再存一个盐字段。这也是它比手工实现加盐哈希更推荐的原因。4.3 注册场景下如何生成加密密码既然密码要加密存储用户注册或者管理员创建用户的地方就得跟着改。原来那种user.setPassword(123456)的写法必须全部替换成先加密再入库。Service public class UserService { private final SysUserRepository userRepository; private final PasswordEncoder passwordEncoder; public UserService(SysUserRepository userRepository, PasswordEncoder passwordEncoder) { this.userRepository userRepository; this.passwordEncoder passwordEncoder; } public void register(String username, String rawPassword) { if (userRepository.findByUsername(username).isPresent()) { throw new RuntimeException(用户名已存在); } SysUser user new SysUser(); user.setUsername(username); user.setPassword(passwordEncoder.encode(rawPassword)); user.setEnabled(true); // 这里根据业务赋值默认角色 userRepository.save(user); } }注意encode() 方法接收的是用户输入的原始密码不是已经处理过的字符串。注册时先校验一下密码长度、复杂度要求如果不符合直接拒绝不要让垃圾数据进到库里。4.4 登录验证时 Spring Security 做了什么密码加密完之后登录验证反而是最简单的部分因为 Spring Security 把流程全部封装好了。它的逻辑可以理解成用户在登录表单输入密码框架拿到这个原文明文调用 PasswordEncoder.matches(rawPassword, encodedPassword) 做比对返回 true 就认证通过否则抛 BadCredentialsException。这里要强调的是matches() 方法会自动解析密文里的盐和成本因子然后按相同参数重新计算哈希进行比对。你不需要关心盐从哪来也不需要关心算法参数配置实现类里全部处理好了。还有些教程喜欢让你在 Controller 里手动调用 authenticationManager.authenticate()我开发时发现对于前后端不分离的传统表单登录完全没这个必要。配置好 loginProcessingUrl 之后Spring Security 会自动拦截 POST 请求、封装 UsernamePasswordAuthenticationToken、调用 AuthenticationManager 完成校验你只需要在成功后跳转到的页面里读取当前的登录用户信息就行。4.5 密码格式与兼容性注意事项改完 BCrypt 之后有一个非常现实的问题必须面对存量数据怎么办。如果你的老系统里已经有大量明文密码或 MD5 密码直接把加密逻辑一换这些老用户全部登录不上了因为 matches() 会把原密码拿去和 MD5 密文比对比对结果永远是 false。处理策略一般是这么几种第一给 sys_user 表加一个 password_type 字段标识这条记录是明文、MD5 还是 BCrypt登录时根据类型分别校验校验通过后顺手升级成 BCrypt。第二如果用户量不大直接强制重置密码数据库批量生成临时密码通知用户第一次登录必须改密。第三如果系统还没上线那就简单了直接清掉所有测试用户重新导入。我做这次改造的时候属于第三种情况所以没有历史包袱。但如果你是给老项目加这个功能建议先评估一下存量用户量级别上来就动刀。5. 升级过程中的常见报错与排查5.1 问题速查表整个改造过程中会有一些非常典型的问题我整理了一张速查表基本覆盖了从配置到运行的大部分异常场景。现象根本原因解决办法There is no PasswordEncoder mapped for the id null内存用户的密码没有加密格式前缀密码统一使用 {bcrypt} 前缀或使用 BCryptPasswordEncoder 生成密文Encoded password does not look like BCrypt密码字段里存的是明文或 MD5确认数据库密码是 BCrypt 格式不要手工往库里塞明文Bad credentials用户名存在但密码错误检查 PasswordEncoder 是否注册为 Bean确认注册时密码已加密UserDetailsService is required容器里没有 UserDetailsService实现 UserDetailsService 接口并通过 Service 注册$2a$10$... 始终校验失败数据库字段截断或字符集问题用 CHAR(60) 或 VARCHAR(100) 存密码检查表的字符集是否为 utf8mb4404 on login POSTloginProcessingUrl 与表单 action 不一致检查表单 form action 和配置的 loginProcessingUrl 完全一致LazyInitializationException角色集合用了默认 LAZYManyToMany 手动改成 FetchType.EAGER5.2 一次让我印象深刻的排查过程这里分享一个我实际遇到的案例。第一次改完启动项目登录表单提交后始终报 Encoded password does not look like BCrypt。我第一反应是数据库里的密码不对去查了一下发现确实是明文 123456因为我当时的初始化 SQL 脚本里直接写了 insert into sys_user values (1, admin, 123456, 1)。这个问题的根源不是代码逻辑而是数据初始化脚本没有同步更新。所以在这里提醒大家改造完代码之后一定要把所有涉及初始化数据的脚本、测试用例、开发环境种子数据全部检查一遍凡是往 sys_user 表里插入数据的 SQL密码字段一律先用 PasswordEncoder 生成密文再填进去别图省事直接写明文。另外还有一个问题是新老教程混杂导致的配置混乱。网上大量 Spring Security 教程还在用 webSecurityConfigurerAdapter 的写法官方已经废弃了两者混用会出现各种奇怪问题。我建议如果你是新项目或者已经升级到 Spring Security 5.7就用 SecurityFilterChain 的写法一步到位后面升级 Spring Boot 3 也少一点迁移成本。关于字符集的问题我再强调一次BCrypt 密文里包含 $ 和 . 等特殊字符表的字符集一定要用 utf8mb4别用 utf8否则某些连接配置下特殊字符可能被截断造成明明数据库里存了正确的 60 位密文但读出来不对的诡异问题。写在最后的一点经验这次改造做完以后我的一个很深感触是Spring Security 的学习曲线陡主要陡在它把太多设计模式封装在框架内部初学者看不到全貌。但只要把 UserDetailsService、PasswordEncoder、SecurityFilterChain 这三个核心概念理清楚了脚手架搭起来就会非常顺。内存用户到数据库用户这一步说白了就是一次“数据来源替换 密码策略升级”没有高深的理论全是实打实的配置和细节。最后再分享一个小技巧调试 Spring Security 的时候把日志级别调到 DEBUG重点看 SECURITY 开头的日志认证流程每个环节都会打印出来哪里断了、为什么断一目了然。比你在 Controller 里各种打日志猜测要高效得多。这次改动之后我那个项目的用户管理模块终于可以正儿八经地上线了不用再跟产品解释为什么用户新增功能一直做不了。你要是也卡在这条路上照着这篇文章改一遍应该能少走不少弯路。