UserDetails
定义与作用
UserDetails 是 Spring Security 封装用户核心信息的接口,定义了框架运行认证和授权所需的全部用户属性。UserDetailsService 的 loadUserByUsername() 方法必须返回此接口的实例。
public interface UserDetails extends Serializable {
Collection<? extends GrantedAuthority> getAuthorities();
String getPassword();
String getUsername();
boolean isAccountNonExpired();
boolean isAccountNonLocked();
boolean isCredentialsNonExpired();
boolean isEnabled();
}
手写过滤器的痛点
手写登录时,通常自己定义一个 User 实体类,但各字段含义模糊,与框架的交互全靠手动映射:
// 痛点:自己定义用户类,字段含义不统一,框架不认识
public class HandmadeUser {
private String username;
private String password;
private List<String> roles;
private boolean active; // 到底代表什么?禁用?过期?锁定?
private boolean locked; // 和 active 什么关系?
// 框架无法自动判断账户状态,只能自己写 if-else
}
UserDetails 将用户状态细化为 7 个标准维度,Spring Security 的 DaoAuthenticationProvider 在认证时会自动检查这些状态,无需开发者手动写判断逻辑。
核心原理
七维状态模型
状态字段与异常映射
DaoAuthenticationProvider 在 preAuthenticationChecks 阶段按顺序检查:
| 检查方法 | 返回 false 时抛出 | 含义 |
|---|---|---|
isAccountNonLocked() | LockedException | 账户被锁定(如连续输错密码) |
isEnabled() | DisabledException | 账户被禁用(如管理员后台冻结) |
isAccountNonExpired() | AccountExpiredException | 账户过期(如试用期结束) |
在 postAuthenticationChecks 阶段检查:
| 检查方法 | 返回 false 时抛出 | 含义 |
|---|---|---|
isCredentialsNonExpired() | CredentialsExpiredException | 密码过期(如强制 90 天改密) |
内置 User 类与 Builder 模式
Spring Security 提供了 UserDetails 的标准实现 org.springframework.security.core.userdetails.User,以及链式构建器:
UserDetails user = User.builder()
.username("alice")
.password("{bcrypt}$2a$10$...")
.roles("USER", "ADMIN") // 自动添加 ROLE_ 前缀
.accountExpired(false)
.accountLocked(false)
.credentialsExpired(false)
.disabled(false)
.build();
示例一:自定义 UserDetails 扩展业务字段
场景说明
业务需要在 UserDetails 中携带用户ID、部门ID等扩展信息,供 Controller 和 Service 层直接使用,避免重复查询数据库。
操作前配置
使用框架内置的 User 类,只能存用户名、密码和角色,无法携带业务ID:
@Override
public UserDetails loadUserByUsername(String username) {
UserEntity entity = userMapper.findByUsername(username);
return new org.springframework.security.core.userdetails.User(
entity.getUsername(),
entity.getPassword(),
AuthorityUtils.createAuthorityList("ROLE_USER")
);
// 后续 Controller 中需要再用 username 查一次数据库才能拿到 userId
}
操作后配置
自定义 UserDetails 实现类:
public class CustomUserDetails implements UserDetails {
private final Long userId;
private final String username;
private final String password;
private final Long departmentId;
private final Collection<? extends GrantedAuthority> authorities;
private final boolean accountNonExpired;
private final boolean accountNonLocked;
private final boolean credentialsNonExpired;
private final boolean enabled;
// 全参构造器、getter...
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public String getPassword() { return password; }
@Override
public String getUsername() { return username; }
@Override
public boolean isAccountNonExpired() { return accountNonExpired; }
@Override
public boolean isAccountNonLocked() { return accountNonLocked; }
@Override
public boolean isCredentialsNonExpired() { return credentialsNonExpired; }
@Override
public boolean isEnabled() { return enabled; }
// 扩展业务字段
public Long getUserId() { return userId; }
public Long getDepartmentId() { return departmentId; }
}
UserDetailsService 返回自定义实现:
@Service
public class CustomUserDetailsService implements UserDetailsService {
@Autowired
private UserMapper userMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
UserEntity entity = userMapper.findByUsername(username);
if (entity == null) {
throw new UsernameNotFoundException("用户不存在");
}
return new CustomUserDetails(
entity.getId(), // userId
entity.getUsername(),
entity.getPassword(),
entity.getDepartmentId(),
AuthorityUtils.createAuthorityList("ROLE_" + entity.getRole()),
true, true, true, entity.isActive()
);
}
}
Controller 中直接获取扩展字段:
@RestController
@RequestMapping("/api/user")
public class UserController {
@GetMapping("/profile")
public ResponseEntity<?> profile(@AuthenticationPrincipal UserDetails userDetails) {
// 传统方式:只能拿到 username
String username = userDetails.getUsername();
// 扩展后:强转为 CustomUserDetails 拿到业务ID
if (userDetails instanceof CustomUserDetails) {
CustomUserDetails custom = (CustomUserDetails) userDetails;
Long userId = custom.getUserId();
Long deptId = custom.getDepartmentId();
return ResponseEntity.ok(Map.of(
"username", username,
"userId", userId,
"departmentId", deptId
));
}
return ResponseEntity.ok(Map.of("username", username));
}
}
结果分析
- 自定义
UserDetails让认证信息自然携带业务上下文,避免 Controller 重复查库 @AuthenticationPrincipal直接注入当前UserDetails,类型安全且无需手动从SecurityContextHolder获取
示例二:利用账户状态字段实现试用期管理
场景说明
SaaS 平台的用户有试用期,试用到期后账户自动失效。通过 isAccountNonExpired() 控制,无需在业务层写判断逻辑。
操作前配置
业务层手动判断试用期,代码分散且容易遗漏:
@Service
public class TrialService {
public void doSomething(Long userId) {
User user = userMapper.findById(userId);
if (user.getTrialEndDate().isBefore(LocalDate.now())) {
throw new RuntimeException("试用期已结束"); // 每个业务方法都要写
}
// ...
}
}
操作后配置
将试用期状态纳入 UserDetails:
public class TrialUserDetails implements UserDetails {
private final String username;
private final String password;
private final Collection authorities;
private final boolean accountNonExpired; // 由试用期计算得出
private final boolean accountNonLocked;
private final boolean credentialsNonExpired;
private final boolean enabled;
private final LocalDate trialEndDate;
// ... 实现方法 ...
@Override
public boolean isAccountNonExpired() {
return LocalDate.now().isBefore(trialEndDate);
}
}
@Service
public class TrialUserDetailsService implements UserDetailsService {
@Autowired
private UserMapper userMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
UserEntity entity = userMapper.findByUsername(username);
if (entity == null) {
throw new UsernameNotFoundException("用户不存在");
}
return new TrialUserDetails(
entity.getUsername(),
entity.getPassword(),
AuthorityUtils.createAuthorityList("ROLE_" + entity.getRole()),
true, true, true, entity.isActive(),
entity.getTrialEndDate()
);
}
}
配置 DaoAuthenticationProvider:
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(trialUserDetailsService)
.passwordEncoder(new BCryptPasswordEncoder());
}
结果分析
- 当用户试用期到期,
isAccountNonExpired()返回false DaoAuthenticationProvider在preAuthenticationChecks阶段抛出AccountExpiredException- 用户登录直接被拒绝,业务层完全不需要写任何试用期判断逻辑
- 试用期恢复后,数据库更新
trialEndDate,下次登录自动恢复
易错场景:忘记重写 equals/hashCode 导致会话异常
问题描述
自定义 UserDetails 后,Spring Security 的并发会话控制(maximumSessions)或 RememberMe 功能出现奇怪行为,比如同一用户被判定为不同会话。
原因分析
Spring Security 的 SessionRegistryImpl 内部使用 SessionInformation 管理会话,其中用户标识基于 UserDetails 的 hashCode 和 equals。如果自定义 UserDetails 没有重写这两个方法,默认使用对象引用比较,导致同一用户每次登录构造的新对象被视为不同用户。
错误代码
public class CustomUserDetails implements UserDetails {
private Long userId;
private String username;
// 没有重写 equals/hashCode!
}
正确做法
基于唯一标识(通常是 username 或 userId)重写:
public class CustomUserDetails implements UserDetails {
// ... 字段和 getter ...
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
CustomUserDetails that = (CustomUserDetails) o;
return Objects.equals(username, that.username);
}
@Override
public int hashCode() {
return Objects.hash(username);
}
}
面试考点:
UserDetails的 7 个方法分别对应什么业务含义?isAccountNonExpired()和isCredentialsNonExpired()的区别?- 自定义
UserDetails时,为什么建议重写equals/hashCode?UserDetails中的getPassword()返回的是明文还是密文?(密文,由PasswordEncoder比对)