1. 为什么内存用户的Demo一上生产就"翻车"
1.1 上一篇文章留下的"历史遗留问题"
上一篇我们搭建Spring Security的时候,用的是最经典的InMemoryUserDetailsManager,直接把用户名密码写死在代码里。当时跑demo确实爽,启动一个Spring Boot项目,打开登录页输入账号密码就进去了,前后不过十分钟。但如果你跟我一样,学着学着就忍不住想往真实业务上靠,很快就会碰到同一个尴尬:用户的账号密码存在数据库里,你的登录入口还躺在配置文件里,怎么把这两件事接起来?
我第一次尝试改造的时候,脑子里冒出来的第一个念头是"我自己写一个拦截器,在Controller前面判断一下登录状态"。这个想法不能说全错,但Spring Security之所以是Spring Security,就是因为它把认证和授权这一整套链路已经做成了标准流水线,你要做的是把数据库里的数据接到流水线的某个工位上,而不是另起炉灶再焊一条水管。后面我会带你完整走一遍这条流水线,你就明白为什么大家都在说"不要重复造轮子"。
这一篇的内容,我默认你已经掌握了上一篇的基本玩法:依赖怎么加、SecurityConfig怎么写、表单登录怎么生效。如果这些还不熟,建议先回去把基础Demo跑通,因为接下来我的每一步操作都是建立在"项目里已经有Spring Security而且能用内存用户登录"这个前提之上的。
1.2 从内存用户到数据库用户的三个关键变更
很多人第一次接触数据库认证,最大的困惑是:我不就是换了个数据源吗,怎么配置看起来"动了个大手术"?
实话说,代码层面确实不是改一两行配置就完事,但逻辑层面其实就三个点:
第一,认证数据的来源变了。原来用户的身份信息来自InMemoryUserDetailsManager这个写死在内存里的Map,现在要改成从数据库按用户名查出来。这一步对应的是UserDetailsService接口,你需要写一个实现类,让Spring Security在拿到表单提交的用户名之后,调用你的loadUserByUsername方法去查库。
第二,密码校验的方式变了。Spring Security默认不知道你数据库里的密码是什么格式,它只管用PasswordEncoder去比对"用户输入的原密码"和"数据库里取到的密码密文"。从这一刻起,密码绝对不能是明文,哪怕你数据库里存的是明文能登录成功,我也强烈建议你立刻停手,先把密码加密方案定下来。原因我后面用一整章来讲。
第三,权限和角色的加载时机变了。内存用户时代,角色就是写死在UserDetails对象里的一行字符串。数据库时代,角色权限要通过关联表从库里查出来,然后塞进UserDetails.getAuthorities()。权限数据的读取效率、实时性、缓存策略,都会从这里开始牵一发动全身。
这三点理解到位,后面写代码就不会晕。我见过很多新人把UserDetailsService和AuthenticationProvider混为一谈,其实它们只是认证流水线上不同工位的工人,职责是完全分开的。
1.3 认清认证链路里三个角色:Manager、Provider、UserDetailsService
为了后面不踩坑,我先把这条流水线讲清楚,用大白话打个比方。
你可以把AuthenticationManager想象成一家餐厅的大堂经理。有顾客进来,说"我要认证一下"。经理不自己动手做菜,他只负责把请求(也就是Authentication对象)转交给后厨里合适的厨师(AuthenticationProvider)。如果多个认证方式并存(比如账号密码登录 + 短信验证码登录),经理就负责判断这张单子该给哪个后厨。
后厨的DaoAuthenticationProvider是专门处理"账号密码"认证的厨师。这道菜的做法是:先让UserDetailsService去食材仓库(数据库)里按用户名把食材找出来,再把用户输入的原密码放到PasswordEncoder这个调料机里加工一下,和仓库里取回来的密码密文比对。对上了,这道菜就算做成了。
所以关键结论是:你真正要写代码的,基本只有UserDetailsService这一个实现类,把查库和组装权限的逻辑写对它就够。AuthenticationProvider和AuthenticationManager在Spring Boot自动配置里已经帮你接好了,除非你要搞"用户名+验证码双因子认证"这种花活,否则不需要碰它们。
理解了这个链路,再回去看网上那些配置文件,你就不会对着userDetailsService()和passwordEncoder()两个方法发愣了。一个是接食材的,一个是调料的,分工完全不同。
2. 数据库表设计:少画一张关系表,后面全是坑
2.1 按RBAC思路拆表还是按"用户-角色"两张表凑合?
改代码之前先把表设计好。这个顺序不能反,否则后面代码写得再漂亮,数据模型一瘸一拐也很难受。
最常见的做法是经典的RBAC(基于角色的访问控制)模型。抽象一点说,就是把"用户""角色""权限"这三个概念分开,再通过关联表把它们的关系记录下来。很多教程上来就给五张表:用户表、角色表、用户角色关联表、权限表、角色权限关联表。
我见过一些简化方案,只建用户表和角色表,把权限硬编码在代码里。如果你的需求就是"管理员和普通用户看到的菜单不一样",那确实够用。但只要你的业务里出现"给某个角色临时加一个导出权限""不同角色的同一个按钮要显示或隐藏"这类需求,硬编码权限的方案就会让你开始写大量的if/else,越往后越没法收拾。
我的建议是:哪怕是学习项目,也直接把五张表建全。五张表的维护成本其实很低,但收益是数据模型非常干净,权限的扩展空间全留出来了。后面你想加权限点、加角色、做权限变更,完全不用改表结构,只改数据。
2.2 表结构DDL与初始化数据
下面是我实际项目中一直在用的一套简化版RBAC表结构,去掉了审计字段,保留最核心的部分。
CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(64) NOT NULL UNIQUE, password VARCHAR(128) NOT NULL, enabled TINYINT NOT NULL DEFAULT 1, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE sys_role ( id BIGINT PRIMARY KEY AUTO_INCREMENT, role_code VARCHAR(64) NOT NULL UNIQUE, role_name VARCHAR(64) NOT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE sys_user_role ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, role_id BIGINT NOT NULL, UNIQUE KEY uk_user_role (user_id, role_id) ); CREATE TABLE sys_permission ( id BIGINT PRIMARY KEY AUTO_INCREMENT, permission_code VARCHAR(128) NOT NULL UNIQUE, permission_name VARCHAR(128) NOT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE sys_role_permission ( id BIGINT PRIMARY KEY AUTO_INCREMENT, role_id BIGINT NOT NULL, permission_id BIGINT NOT NULL, UNIQUE KEY uk_role_permission (role_id, permission_id) );初始化数据我给一套最基础的,账号和密码一会儿要用来测试:
-- 密码均为:Admin@123 的BCrypt密文 INSERT INTO sys_user (username, password, enabled) VALUES ('admin', '$2a$10$CwTycUXWue0Thq9StjUM0uJ8zB4dF1yQn1U2MxG9eBkP8eQz7y8Si', 1), ('user', '$2a$10$CwTycUXWue0Thq9StjUM0uJ8zB4dF1yQn1U2MxG9eBkP8eQz7y8Si', 1); INSERT INTO sys_role (role_code, role_name) VALUES ('ADMIN', '管理员'), ('USER', '普通用户'); INSERT INTO sys_user_role (user_id, role_id) VALUES (1, 1), (2, 2); INSERT INTO sys_permission (permission_code, permission_name) VALUES ('system:user:list', '查看用户列表'), ('system:user:create', '创建用户'), ('system:report:view', '查看报表'); INSERT INTO sys_role_permission (role_id, permission_id) VALUES (1, 1), (1, 2), (1, 3), (2, 3);注意一个小细节:role_code字段我特意用了ADMIN这种大写形式,这是为了和后面Spring Security的hasRole("ADMIN")保持一致的习惯。实际开发里有团队喜欢存ROLE_ADMIN全量前缀,我更喜欢只存业务代号,把前缀留给Java代码去组装,这样数据库里看着干净,语义也清晰。
2.3 为什么权限不直接挂在用户上
这个问题经常有小伙伴问:既然用户表可以直接关联权限表,为什么中间非要夹一个角色?
说个真实发生过的场景。某次内部系统上线新功能,需要开放给"客服主管"和"运营主管"两个角色,这俩角色分属不同部门,各自又有很多细分的权限差异。如果权限直接挂在用户上,那每个用户都要配一长串权限点,新增一个人要配十几条,漏配一个,功能就用不了。排查的时候还要挨个用户去对权限,效率极低。
用角色中转之后,事情就变成了:给两个角色各加一个相同的权限点,所有人立刻生效。你只需要维护角色和权限的关系,新用户进来只要分配一个角色,权限自动到位。这就是RBAC模型的核心价值:多对多的关系交给中间表去维护,人的管理逻辑里永远只出现"角色"这一个概念。
代码层面,角色和权限查出来之后最终都会汇聚到UserDetails.getAuthorities()里,所以并不存在"挂角色就不能挂权限"的问题。后面我们做细粒度接口控制时,可以直接用权限码来控制,而角色则是权限码的集合。
3. 代码改造:把认证从"写死"改成"查库"
3.1 第一步:让数据库实体能被Easy Code一样查出来
表结构建好了,接下来就是SSM/Spring Boot项目里的常规操作:写实体类、Mapper、Service。这里我不展开具体ORM框架的配置细节,我下面用MyBatis-Plus的写法来演示,因为最近几年大家新起项目用MyBatis-Plus的确实多。如果你用的是原生MyBatis或者JPA,思路完全一样,重点是逻辑而不是某个框架的API。
实体类的核心就两个:SysUser和SysRole,关联查询我用一个额外的方法搞定,不去建太复杂的VO对象。
@Data @TableName("sys_user") public class SysUser { private Long id; private String username; private String password; private Integer enabled; private LocalDateTime createdAt; }@Data @TableName("sys_role") public class SysRole { private Long id; private String roleCode; private String roleName; private LocalDateTime createTime; }然后我需要一个方法,根据userId查出他拥有的所有角色,再根据角色查出所有权限码。这一步用SQL来做最直接,ORM里硬写一堆关联查反而绕。
public interface SysUserMapper extends BaseMapper<SysUser> { @Select(""" SELECT r.role_code FROM sys_user_role ur JOIN sys_role r ON ur.role_id = r.id WHERE ur.user_id = #{userId} """) List<String> selectRoleCodesByUserId(Long userId); @Select(""" SELECT p.permission_code FROM sys_role_permission rp JOIN sys_permission p ON rp.permission_id = p.id JOIN sys_user_role ur ON rp.role_id = ur.role_id WHERE ur.user_id = #{userId} """) List<String> selectPermissionCodesByUserId(Long userId); }提示:SQL里千万不要写成"从sys_role直接join sys_permission"然后忘了拼
sys_user_role。我第一次改造时图省事,在用户详情接口里少关联了一层表,结果查出来的权限是所有用户的权限总和,这个bug很隐蔽,排查了很久。
3.2 第二步:重构UserDetailsService,这是整个改造的核心
现在开始写真正和Spring Security打交道的部分。核心逻辑其实非常紧凑,一个方法而已:
@Service public class DatabaseUserDetailsService implements UserDetailsService { @Autowired private SysUserMapper sysUserMapper; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { SysUser sysUser = sysUserMapper.selectOne( new LambdaQueryWrapper<SysUser>() .eq(SysUser::getUsername, username) ); if (sysUser == null) { throw new UsernameNotFoundException("用户不存在"); } // 查角色和权限 List<String> roleCodes = sysUserMapper.selectRoleCodesByUserId(sysUser.getId()); List<String> permissionCodes = sysUserMapper.selectPermissionCodesByUserId(sysUser.getId()); // 组装成GrantedAuthority集合 List<GrantedAuthority> authorities = new ArrayList<>(); // 角色转成 ROLE_ 前缀 for (String roleCode : roleCodes) { authorities.add(new SimpleGrantedAuthority("ROLE_" + roleCode)); } // 权限码直接作为权限标识 for (String permissionCode : permissionCodes) { authorities.add(new SimpleGrantedAuthority(permissionCode)); } return new User( sysUser.getUsername(), sysUser.getPassword(), sysUser.getEnabled() == 1, true, // accountNonExpired true, // credentialsNonExpired true, // accountNonLocked authorities ); } }我直接用了Spring Security自带的org.springframework.security.core.userdetails.User,没有自定义UserDetails实现类。很多人喜欢自定义一个SecurityUser把 userId、部门ID也塞进去方便后续取用,这个需求是合理的。我的经验是:如果你只是往UserDetails里塞业务字段,But 后续代码可以通过Authentication取得UserDetails后再强转成自己的类型,所以你可以自定义。但前提是要在实现类里老老实实把构造函数的六个参数都传对,尤其是"是否启用"这个布尔值,因为它直接对应数据库里的enabled字段。
这里有一个初学者最容易掉的坑:User的构造方法里有一个enabled参数,如果你查出来的用户被禁用(比如离职账号),这里传false,Spring Security会直接抛出DisabledException,压根不让你进行密码比对。这个行为很多人意想不到,但它其实是安全设计——禁用账号就不该有校验密码的机会。
3.3 第三步:SecurityConfig里的装配变化
改造完UserDetailsService,回到SecurityConfig,这一步把内存用户时代的配置替换成数据库驱动的方式。
@Configuration @EnableWebSecurity public class SecurityConfig { @Autowired private DatabaseUserDetailsService userDetailsService; @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { return config.getAuthenticationManager(); } @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(userDetailsService) .passwordEncoder(passwordEncoder()); } }看到AuthenticationManagerBuilder的写法,很多用Spring Boot 3 / Spring Security 6版本的读者可能会愣一下。在Spring Security 5.7之后,官方推荐用SecurityFilterChainBean的方式代替WebSecurityConfigurerAdapter,AuthenticationManager也不再通过继承类的方式来构建。上面这段是经典写法,如果你的项目是Spring Boot 3.x,我建议你把configure(AuthenticationManagerBuilder)这段换成直接声明UserDetailsService和PasswordEncoder为Bean,让自动配置自己把它们接上。
@Configuration @EnableWebSecurity public class SecurityConfig { @Bean public UserDetailsService userDetailsService() { return new DatabaseUserDetailsService(); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth -> auth .requestMatchers("/login", "/css/**", "/js/**").permitAll() .requestMatchers("/admin/**").hasRole("ADMIN") .anyRequest().authenticated() ) .formLogin(form -> form .loginPage("/login") .defaultSuccessUrl("/index") .permitAll() ) .logout(logout -> logout.logoutSuccessUrl("/login?logout")); return http.build(); } }注意:在Spring Boot 3环境下,如果项目里同时存在多个
UserDetailsServiceBean,或者你手动new了DatabaseUserDetailsService而没有加@Service注解,自动配置可能不会帮你把它接进认证链路。最稳的做法是给实现类加@Service,然后在SecurityFilterChain里什么都不做,让自动配置链自动发现它。
无论你用的是版本几,脑子里时刻记住那条流水线:AuthenticationManager->DaoAuthenticationProvider->UserDetailsService->PasswordEncoder。配置虽然变了,链条没变。
4. 密码加密:BCrypt不是"可选项",是"必选项"
4.1 先说说MD5为什么不够,再提明文
数据库认证改造完成后的第一件事,就是审视密码字段。我见过不少老项目,数据库里password字段存的是明文,或者简单MD5。你要是问我为什么不能这么干,我的回答很直接:只要有一个人能拿到数据库的读取权限,所有账号就全部沦陷了。
明文的问题不用多说。MD5的坑很多人没意识到:它太快了,一台普通的机器每秒能算上亿次MD5,配合彩虹表,绝大多数弱密码一两秒就能逆向出来。就算你加了固定盐(salt),只要盐是写死在代码里的,一样是掩耳盗铃。有人提出用SHA-256,结论一样,它们都属于"快速哈希",不适合存密码。
密码哈希需要的是慢哈希函数,故意把计算速度拖慢,让攻击者批量尝试的成本暴涨。BCrypt就是这个思路的典型实现,Spring Security默认支持它,也是我强烈推荐你使用的方案。
4.2 BCrypt的工作原理:盐、工作因子和那个"莫名其妙"的密文
第一次看到BCrypt密文的人通常会问:为什么同一串明文密码,每次调用encode生成的密文都不一样?
这里的关键就是"随机盐"的概念。BCrypt会把随机生成的盐(通常22个字符)嵌入到最终密文里。所以你每次加密同一个密码,得到的密文都不同,但验证的时候它能从密文里把盐提取出来重新计算,因此不需要你在数据库里单独存一个盐字段。这一点非常重要,因为很多人以前做"加盐MD5",需要额外维护一个salt列,逻辑复杂度高且容易漏配。BCrypt把盐的管理收进了算法内部,简单可靠。
BCrypt密文一般长这样:
$2a$10$CwTycUXWue0Thq9StjUM0uJ8zB4dF1yQn1U2MxG9eBkP8eQz7y8Si拆开来看,$2a$是算法版本标识,10是工作因子(cost factor),后面的部分是盐和哈希值的组合。工作因子10意味着计算哈希时要做 (2^{10}=1024) 次轮转迭代。每增加1,计算量翻倍,加密耗时也翻倍。合理范围通常设在10~12,具体取多少要看你的服务器性能。我在本地开发一般用10,生产环境压测后取12。
@Test void bcryptTest() { BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(10); String encoded1 = encoder.encode("Admin@123"); String encoded2 = encoder.encode("Admin@123"); System.out.println(encoded1); System.out.println(encoded2); System.out.println(encoder.matches("Admin@123", encoded1)); // true System.out.println(encoder.matches("Admin@1234", encoded1)); // false }跑一次你就明白了,两次输出完全不一样,但matches都能正确校验通过。
4.3 PasswordEncoder的Bean定义注意事项
配置里的BCryptPasswordEncoder要当心几个细节。
第一,全局只能有一个PasswordEncoder的Bean。如果你的项目里某个配置类自己又声明了一个,启动时就会报PasswordEncoder类型冲突。出了这个错别懵,不是代码逻辑问题,纯粹是Bean重复定义。我习惯在SecurityConfig里定义一个,其他地方要用就@Autowired注入,绝不重复声明。
第二,BCryptPasswordEncoder构造函数的strength参数一旦设定,在运行时不要随便改。如果数据库里已有密文是cost=10加密的,你把Bean改成cost=12,验证仍然能通过(因为BCrypt能从密文里读出当时的工作因子),但新注册的用户会用新因子加密,老密文和新密文的强度就不一致了。统一的做法是:先定好值,上线前在测试环境压测一把,上线后不要动。
第三,有些老系统历史遗留的密码可能是MD5或SHA-256加密的,这时候别急着全部重置。Spring Security提供了DelegatingPasswordEncoder,它可以让你同时支持多种加密方式。但对学习项目来说,我的建议更简单粗暴:把这些老账号的密码统一重置成BCrypt版本,趁早摆脱兼容负担。后面我分享一个踩坑经历,就是因为老库里混着好几种加密格式,排查得头皮发麻。
5. 接口权限控制:从URL拦截到方法级注解
5.1 基于HttpSecurity的URL权限配置
数据库认证接好之后,接下来就是"谁可以访问哪个接口"的授权问题。最基础的做法是在SecurityFilterChain里按URL路径配置,就像前面代码里写的:
.authorizeHttpRequests(auth -> auth .requestMatchers("/admin/**").hasRole("ADMIN") .requestMatchers("/report/**").hasAnyRole("ADMIN", "USER") .anyRequest().authenticated() )这里有一个很重要的认知:hasRole("ADMIN")判断的不是数据库里那个role_code字符串,而是GrantedAuthority里有没有ROLE_ADMIN这个前缀标识。因为我们在UserDetailsService里组装的时候写了"ROLE_" + roleCode,所以这里hasRole("ADMIN")才能匹配上。如果你组装的时候没加前缀而直接塞了ADMIN,那么要匹配就该写hasAuthority("ADMIN")。这个"前缀机制"是Spring Security的一个隐性约定,很多人一开始被绕晕,其实记熟了就好:hasRole自动加前缀,hasAuthority不加。
URL级配置适合粗粒度拦截,比如/admin/**只能管理员进,/user/**登录就能进。但真实业务里经常有更细的需求:"查看用户列表"管理员可以、运营可以,但"创建用户"只有管理员可以。如果全靠URL配置,一个/user/**下的创建接口你根本没法在URL级区分。这时候就该上方法级安全。
5.2 方法级安全:@PreAuthorize、@Secured、@RolesAllowed怎么选
Spring Security提供了方法级安全注解,开启方式很简单。Spring Boot 3 / Spring Security 6用的是:
@Configuration @EnableMethodSecurity public class SecurityConfig { // ... }Spring Security 5.6之前的旧项目用的是@EnableGlobalMethodSecurity(prePostEnabled = true),你要是维护老项目可能会遇到,知道它是旧版写法即可。
开启之后,你就可以直接在Controller或者Service方法上写权限注解:
@RestController @RequestMapping("/user") public class UserController { @GetMapping @PreAuthorize("hasAuthority('system:user:list')") public List<SysUser> list() { return userService.list(); } @PostMapping @PreAuthorize("hasAuthority('system:user:create')") public SysUser create(@RequestBody SysUser user) { return userService.create(user); } }这里我用的是权限码system:user:list,而不是角色ADMIN。为什么?因为权限码更细。角色"ADMIN"可能拥有一堆权限,但你现在只关心这一个操作。用权限码控制,后续角色变更多个、权限组合变了,代码不用改,只要改角色-权限关联数据就行。
三种注解的区别:
| 注解 | 需要开启配置 | 表达式能力 | 适用场景 |
|---|---|---|---|
| @PreAuthorize | @EnableMethodSecurity | 最强,支持SpEL表达式,能写 &&、||、自定义Bean方法 | 现代项目首选 |
| @Secured | 旧版@EnableGlobalMethodSecurity(securedEnabled = true) | 只能写角色名列表 | 老项目遗留代码 |
| @RolesAllowed | 需要引入JSR-250注解并开启enableJsr250 | 只能写角色名,语义和@Secured类似 | 兼容JavaEE规范的项目 |
我的结论很明确:新代码一律用@PreAuthorize。它能写表达式,比如@PreAuthorize("hasAnyAuthority('system:user:list', 'system:report:view')"),也支持自定义校验Bean,比如@PreAuthorize("@authService.checkOwner(#id)"),这个扩展空间是另两个注解给不了的。
5.3 hasRole与hasAuthority的使用场景
前面提过一次前缀区别,但实际使用中更麻烦的是:什么时候用角色,什么时候用权限码。
我的建议是分两层来控制:粗粒度用角色,细粒度用权限码。比如:
- 菜单显示控制、首页跳转逻辑这种粗粒度场景,判断
hasRole('ADMIN'),简单直接。 - 具体某一个写操作、某一个按钮,用
hasAuthority('system:user:create'),精细可控。
一个小技巧:角色本质上也是一条GrantedAuthority,它的标识是ROLE_开头。所以你可以在数据库里把"能查看报表"定义成一个权限码,然后把"ADMIN角色拥有该权限码"写在角色-权限关联表里。这样既保持了角色管理的简单性,又获得了权限控制的精确性。这就是前面设计五张表的意义所在,现在你应该能体会到为什么我用role_code而不是直接拿角色名做权限判断。
6. 实测中容易踩的四个坑(排查链路分享)
6.1 坑一:数据库密码没加密,或者PasswordEncoder不匹配
症状很典型:登录页面提交之后,Spring Security直接返回错误页,后台日志也没有异常堆栈。查看日志里有一行There is no PasswordEncoder mapped for the id "null"或者类似的警告时,基本上就是密码格式或加密器不匹配。
我第一次做数据库认证时,直接从库里复制了一段别人生成的BCrypt密文,当时没注意这是$2a$还是$2b$,结果密码怎么验证都失败。后来用{bcrypt}前缀的方式才恍然大悟——Spring Security的DelegatingPasswordEncoder在解析密文时,如果没有前缀会默认采用你指定的PasswordEncoder,但一旦密文格式不标准,匹配机制就会出问题。
排查链路供参考:
- 先在数据库里执行一条查询,把当前用户的
password字段原样打印出来。 - 写一个最简单的单元测试,用
passwordEncoder.matches(原始密码, 数据库密文)看返回的是不是true。这一步能快速定位问题是出在加密器还是出在数据本身。 - 如果是
false,用passwordEncoder.encode(原始密码)重新生成一条密文,UPDATE进数据库,再测一次。 - 如果还是
false,检查你的UserDetailsService里从数据库取密码字段时有没有被框架做过二次处理(比如MyBatis的类型处理器把加密串截断了)。
这个坑的核心教训是:不要把"校验逻辑正确"建立在"数据库里的密文一定正确"这个假设上。用单元测试把密码校验从整个认证链路里单独摘出来验证,是最有效的定位手段。
6.2 坑二:角色加进数据库了,但接口还是403
症状:数据库里用户角色关联表数据没问题,登录也成功了,但访问一个@PreAuthorize("hasRole('ADMIN')")的接口还是403。
这种问题绝大多数出在UserDetailsService组装authorities的时候。有个细节:Spring Security在hasRole判断时会拿ROLE_ADMIN去和GrantedAuthority.getAuthority()比较,你在数据库里存的是ADMIN,代码里忘了加ROLE_前缀,结果自然匹配不上。
排查链路供参考:
- 登录成功后,在任意一个能进入的Controller方法里,把当前登录用户的权限列表打印出来:
@RequestMapping("/debug/auth") @ResponseBody public Object debugAuth() { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); return authentication.getAuthorities(); }- 看看返回的集合里到底是
[ADMIN, system:user:list]还是[ROLE_ADMIN, system:user:list]。少了ROLE_前缀就是组装的问题。 - 顺便检查
sys_user_role和sys_role两张表关联是否成功。我遇到过一种情况,selectRoleCodesByUserId的SQL里关联条件写错,导致永远查不到角色,这种情况接口403的原因就是权限列表压根为空。
记住一个心法:403不是你想当然的"角色名对不对",而是"当前用户的authorities里到底有没有那个标识"。所以第一步永远是拿到真实的authorities,用数据说话,而不是盯着配置文件猜。
6.3 坑三:权限修改后不实时生效,会话里还是旧权限
数据库里把某个用户加了一个角色,理论上下次登录就生效,但实际测试发现:该用户退出再重新登录,权限没变;甚至换一台机器登录也是旧权限。如果遇到这种情况,先别急着怀疑Spring Security,检查你的会话管理。
Spring Security默认的登录会话会缓存用户信息,但缓存的是Authentication对象,这个对象的getAuthorities()在认证通过那一刻就被快照了。你改了数据库,除非强制用户重新走一次完整认证流程,否则它不会自动刷新。更麻烦的是,如果你用了Spring Session把会话数据存到Redis,那这个快照可能存在于Redis里,排查起来容易怀疑人生。
常见解决方案就三招:
- 第一招:修改权限后,让该用户强制退出登录,重新走一次认证。最简单,适合权限变更不频繁的系统。
- 第二招:在
UserDetailsService的查询逻辑里加一层缓存失效机制。比如权限变更时清掉该用户的缓存键,下次读取重新查库。 - 第三招:用JWT这类无状态认证,每次请求都重新解析令牌并重新查询权限。牺牲一点性能换取实时性,很多微服务体系里确实这么干。
别忘了另一个隐蔽点:UsernamePasswordAuthenticationFilter成功认证后会把Authentication放进SecurityContextHolder,后续请求如果走了过滤器链重新读取信息,可能会从Session里拿旧数据。这就是为什么"重启服务后才生效"的现象让很多人困惑——因为重启把Session清了,重新登录走了新数据。
6.4 坑四:懒加载把UserDetails查询搞崩了
这个坑在项目用了JPA/Hibernate时特别常见。loadUserByUsername方法里查出了SysUser实体,但角色权限是通过懒加载关联查询获取的。当Spring Security在事务外的某个地方触发懒加载时,直接抛LazyInitializationException。
排查链路供参考:
- 确认
loadUserByUsername是否被事务管理。如果Service层方法没加@Transactional,而你又在方法返回之后才访问懒加载集合,必炸。 - 一个更稳妥的思路:不要在实体关联上依赖懒加载,而是像我前面的代码那样,用显式SQL把角色和权限一次性查出来。这样
loadUserByUsername返回时,所有数据都已经装进List<String>,和实体生命周期完全解耦。 - 如果确实想在实体上维护关联关系,那就要保证认证流程中
loadUserByUsername执行期间有活跃的事务。但我要泼一盆冷水:Spring Security的过滤链可能在事务提交前或提交后访问UserDetails,这个事务边界非常难控,远不如"能查到的数据都提前查好"这个方案省心。
这个坑暴露的其实是服务分层设计的弱点——认证是属于安全层的事,不是业务层的事。安全层调用你的Service方法,你不能要求安全层去理解你的JPA实体生命周期。所以,让loadUserByUsername返回一个完全独立的POJO对象、不持有任何数据库实体引用,是最不惹麻烦的做法。我自己后来写UserDetailsServiceImpl,基本都是返回org.springframework.security.core.userdetails.User,里面只有字符串和boolean,干净利落地绕开所有实体缓存和懒加载问题。
我个人的体会是,Spring Security的学习曲线里,最陡的一段不是理解过滤器链,而是把"认证数据从内存迁到数据库"这一步。只要把UserDetailsService和PasswordEncoder这两个点吃透,后面的权限注解、方法级安全都是顺水推舟的事。如果你也正在走这条路,建议按我上面的顺序一步一步来:先建表,再改造认证,加上加密,最后配权限注解。每一步都跑通验证过,再进入下一步。这样即便中间出了bug,你也知道问题大概率出现在新动的那一块,排查范围小,心里不慌。