乐途乐途
主页
  • 计算机基础

    • TCP/IP
    • Linux
    • HTTP
  • 数据库

    • SQL
    • MySQL 5.7
  • 编程语言

    • C
    • C++
    • Java SE
    • Python2
    • Python3
  • 数据格式

    • JSON
    • XML
  • 认证与安全

    • JWT
  • 工具

    • Markdown
  • Git

    • GitFlow
  • Quartz

    • Quartz
  • Java

    • Maven 入门
    • Maven 进阶
    • MyBatis
    • Spring
    • Spring MVC
  • Java

    • Spring Boot
    • Spring Cloud
    • Spring Cloud Alibaba
    • Spring Security
    • Spring AI
    • Spring Batch
    • Kafka
    • Java 设计模式
  • 缓存

    • Redis
  • 搜索引擎

    • Elasticsearch
  • 分布式协调

    • ZooKeeper
联系
阿里云
主页
  • 计算机基础

    • TCP/IP
    • Linux
    • HTTP
  • 数据库

    • SQL
    • MySQL 5.7
  • 编程语言

    • C
    • C++
    • Java SE
    • Python2
    • Python3
  • 数据格式

    • JSON
    • XML
  • 认证与安全

    • JWT
  • 工具

    • Markdown
  • Git

    • GitFlow
  • Quartz

    • Quartz
  • Java

    • Maven 入门
    • Maven 进阶
    • MyBatis
    • Spring
    • Spring MVC
  • Java

    • Spring Boot
    • Spring Cloud
    • Spring Cloud Alibaba
    • Spring Security
    • Spring AI
    • Spring Batch
    • Kafka
    • Java 设计模式
  • 缓存

    • Redis
  • 搜索引擎

    • Elasticsearch
  • 分布式协调

    • ZooKeeper
联系
阿里云
  • 学习路径
  • 第1章 Spring Security 基础

    • 本章定位
    • Spring Security 是什么
    • DelegatingFilterProxy
    • 安全过滤器链
    • SecurityFilterChain
    • 过滤器执行顺序
  • 第2章 认证

    • 本章定位
    • Authentication
    • 认证流程
    • AuthenticationManager
    • ProviderManager
    • DaoAuthenticationProvider
    • UserDetails
    • UserDetailsService
    • PasswordEncoder
    • BCryptPasswordEncoder
    • DelegatingPasswordEncoder
    • 表单登录
    • SecurityContext
    • SecurityContextHolder
    • UsernamePasswordAuthenticationToken
  • 第3章 授权

    • 本章定位
    • 授权模型
    • GrantedAuthority
    • AccessDecisionManager
    • AccessDecisionVoter
    • URL 级别授权
    • 方法级别安全
    • @PreAuthorize
    • @PostAuthorize
    • @PreFilter
    • @PostFilter
    • @Secured
    • RoleHierarchy
  • 第4章 过滤器链

    • 本章定位
    • FilterChainProxy
    • SecurityContextHolderFilter
    • LogoutFilter
    • BasicAuthenticationFilter
    • CsrfFilter
    • CorsFilter
    • HeaderWriterFilter
    • AnonymousAuthenticationFilter
    • RequestCacheAwareFilter
    • ExceptionTranslationFilter
    • FilterSecurityInterceptor
  • 第5章 会话管理

    • 本章定位
    • 会话管理
    • SessionFixation
    • 会话并发控制
    • SessionCreationPolicy
    • RememberMe
  • 第6章 JWT

    • 本章定位
    • JWT
    • JwtDecoder
    • JWT 认证
    • JwtAuthenticationConverter
  • 第7章 OAuth2

    • 本章定位
    • OAuth2 基础
    • OAuth2 Client
    • OAuth2 Resource Server
    • 第三方登录配置
  • 第8章 攻击防护

    • 本章定位
    • CSRF 跨站请求伪造防护
    • CORS 跨域防护
    • Clickjacking 点击劫持防护
    • 安全响应头
    • Session Fixation 会话固定防护
  • 第9章 测试

    • 本章定位
    • 安全测试
    • @WithMockUser
    • 最佳实践

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 比对)
上一页
DaoAuthenticationProvider
下一页
UserDetailsService