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

    • 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
    • 最佳实践

UserDetailsService

定义与作用

UserDetailsService 是 Spring Security 认证流程中负责加载用户信息的核心接口,只定义了一个方法:

public interface UserDetailsService {
    UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

DaoAuthenticationProvider 在认证过程中调用此方法,根据用户提交的用户名获取该用户的完整信息(密码、权限、账户状态),以便后续进行密码比对和状态检查。它相当于认证体系中的数据访问层。

手写过滤器的痛点

手写登录时,用户数据获取通常直接嵌入在认证逻辑里,换数据源时牵一发而动全身:

// 痛点:认证逻辑与数据获取耦合
public class HandmadeAuthService {
    public boolean login(String username, String password) {
        // 直接写 SQL,耦合在业务层
        User user = jdbcTemplate.queryForObject(
            "SELECT * FROM users WHERE username = ?", ...);
        // 换 LDAP 或 Redis 存储时,整个方法都要重写
        return passwordEncoder.matches(password, user.getPassword());
    }
}

UserDetailsService 将"用户数据从哪来"抽象为独立接口,认证逻辑(DaoAuthenticationProvider)只关心"拿到 UserDetails 后做什么",不关心"数据从哪来"。


核心原理

接口位置与调用链

UserDetailsService 处于认证链路的数据供给端,它的返回值直接决定了后续认证能否继续。

常见实现类

实现类数据源适用场景
InMemoryUserDetailsManager内存 Map测试、Demo、配置化用户
JdbcUserDetailsManager标准 JDBC默认表结构(users / authorities)
LdapUserDetailsServiceLDAP 目录企业 AD 域集成
自定义实现任意(MyBatis/JPA/Redis)生产环境主流做法

关键约定

  • 方法契约:loadUserByUsername 必须返回非 null 的 UserDetails,否则抛出 InternalAuthenticationServiceException
  • 用户不存在:应抛出 UsernameNotFoundException,由 DaoAuthenticationProvider 统一包装为 BadCredentialsException(防止用户枚举)
  • 权限字符串:角色以 ROLE_ 为前缀(如 ROLE_ADMIN),权限可自定义(如 SCOPE_read)

示例一:从内存用户升级到数据库用户

场景说明

项目初期使用内存用户快速验证,上线前需要切换到数据库用户,观察 UserDetailsService 的替换过程。

操作前配置

内存用户,配置在代码中:

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication()
            .withUser("admin").password("{noop}admin123").roles("ADMIN")
            .and()
            .withUser("user").password("{noop}user123").roles("USER");
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .authorizeRequests(authorize -> authorize
                .antMatchers("/public/**").permitAll()
                .anyRequest().authenticated()
            )
            .formLogin(Customizer.withDefaults());
    }
}

操作后配置

替换为自定义 UserDetailsService,从数据库加载:

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private DatabaseUserDetailsService databaseUserDetailsService;

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(databaseUserDetailsService)
            .passwordEncoder(passwordEncoder());
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .authorizeRequests(authorize -> authorize
                .requestMatchers(new AntPathRequestMatcher("/public/**")).permitAll()
                .anyRequest().authenticated()
            )
            .formLogin(Customizer.withDefaults());
    }
}
@Service
public class DatabaseUserDetailsService 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("用户不存在: " + username);
        }

        // 使用 Spring Security 内置的 UserBuilder 构建 UserDetails
        return User.builder()
            .username(entity.getUsername())
            .password(entity.getPassword())      // 数据库中已编码的密码
            .roles(entity.getRole().split(","))  // 如 "ADMIN,USER" → [ROLE_ADMIN, ROLE_USER]
            .accountLocked(entity.isLocked())
            .disabled(!entity.isActive())
            .build();
    }
}

Mapper 调用(不展开 MyBatis 配置):

@Mapper
public interface UserMapper {
    UserEntity findByUsername(String username);
}

结果分析

  • DaoAuthenticationProvider 的代码完全不用修改,只需替换 UserDetailsService 实现
  • 认证逻辑与数据获取彻底解耦,体现了 Spring Security 接口设计的核心优势

示例二:多租户场景下的动态数据源切换

场景说明

SaaS 平台需要支持多租户,每个租户有独立的用户数据库。UserDetailsService 需要根据请求中的租户标识动态切换数据源。

操作前配置

单数据源,无法区分租户:

@Service
public class SingleUserDetailsService implements UserDetailsService {
    @Autowired
    private UserMapper userMapper;  // 只连一个库

    @Override
    public UserDetails loadUserByUsername(String username) {
        return userMapper.findByUsername(username);
    }
}

操作后配置

通过 TenantContext 实现动态数据源切换:

@Service
public class MultiTenantUserDetailsService implements UserDetailsService {

    @Autowired
    private TenantDataSourceRouter dataSourceRouter;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 从 ThreadLocal 获取当前租户标识
        String tenantId = TenantContext.getCurrentTenant();
        if (tenantId == null) {
            throw new UsernameNotFoundException("无法确定租户上下文");
        }

        // 切换数据源
        DataSource dataSource = dataSourceRouter.getDataSource(tenantId);
        JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource);

        // 查询租户独立数据库
        Map<String, Object> userMap = jdbcTemplate.queryForMap(
            "SELECT username, password, role, is_active FROM users WHERE username = ?",
            username
        );

        if (userMap == null || userMap.isEmpty()) {
            throw new UsernameNotFoundException("用户不存在: " + username);
        }

        return User.builder()
            .username((String) userMap.get("username"))
            .password((String) userMap.get("password"))
            .roles(((String) userMap.get("role")).split(","))
            .disabled(!Boolean.TRUE.equals(userMap.get("is_active")))
            .build();
    }
}

过滤器在请求进入时设置租户上下文:

@Component
public class TenantContextFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                      HttpServletResponse response,
                                      FilterChain filterChain) throws ServletException, IOException {
        String tenantId = request.getHeader("X-Tenant-ID");
        if (tenantId != null) {
            TenantContext.setCurrentTenant(tenantId);
        }
        try {
            filterChain.doFilter(request, response);
        } finally {
            TenantContext.clear();  // 必须清理,防止线程复用时污染
        }
    }
}

配置 TenantContextFilter 在安全过滤器之前执行:

@Override
protected void configure(HttpSecurity http) throws Exception {
    http
        .addFilterBefore(new TenantContextFilter(), UsernamePasswordAuthenticationFilter.class)
        .authorizeRequests(authorize -> authorize
            .anyRequest().authenticated()
        )
        .formLogin(Customizer.withDefaults());
}

结果分析

  • UserDetailsService 作为纯数据访问接口,天然支持多租户场景的数据源切换
  • 通过 TenantContext 的 ThreadLocal 机制,在 loadUserByUsername 内动态确定从哪个数据库加载用户
  • 请求结束后必须 clear(),防止线程池复用导致租户信息串台

易错场景:缓存用户信息导致权限变更不生效

问题描述

开发者在 UserDetailsService 上加 @Cacheable,用户权限在后台被修改后,前端重新登录仍然拿到旧权限。

错误代码

@Service
public class CachedUserDetailsService implements UserDetailsService {

    @Override
    @Cacheable(value = "user", key = "#username")  // 缓存了!
    public UserDetails loadUserByUsername(String username) {
        return userMapper.findByUsername(username);
    }
}

原因分析

@Cacheable 将 UserDetails 对象缓存后,即使数据库中的角色已更新,认证时仍从缓存读取旧数据。更严重的是,SecurityContextHolder 中的 Authentication 也持有旧的 UserDetails 引用,导致权限变更在会话有效期内完全不生效。

正确做法

方式一:不缓存 UserDetailsService 的返回结果,缓存放在更底层(如数据库查询层设置短 TTL)

方式二:权限变更时主动清除缓存

@Service
public class UserAdminService {
    @CacheEvict(value = "user", key = "#username")
    public void updateUserRole(String username, String newRole) {
        userMapper.updateRole(username, newRole);
    }
}

方式三:每次请求重新加载权限(如果权限变更频繁且实时性要求高)

// 配置 Session 固定刷新策略,或每次请求从数据库重新加载 GrantedAuthority
// 但注意:这会带来性能开销,需要权衡

面试考点:

  • UserDetailsService 和 AuthenticationProvider 的职责边界是什么?
  • 为什么 UserDetailsService 只返回用户信息,不做密码比对?
  • 在多租户或分布式系统中,UserDetailsService 的实现需要注意什么?(线程隔离、数据源切换、缓存一致性)
上一页
UserDetails
下一页
PasswordEncoder