1. 概述

本文将演示如何通过 在标准登录表单中添加额外字段 来实现 Spring Security 的自定义认证场景。我们将重点介绍 两种不同实现方案,展示框架的灵活性和多样化应用方式。

第一种方案 采用简单实现,侧重于复用 Spring Security 的核心组件。

第二种方案 提供更高度定制化的实现,更适合复杂业务场景。

本文基于我们之前关于 Spring Security 登录 的文章进行扩展。

2. Maven 配置

使用 Spring Boot 启动器快速搭建项目并引入必要依赖:

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.6.1</version>
    <relativePath/>
</parent>
 
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-thymeleaf</artifactId>
     </dependency>
     <dependency>
        <groupId>org.thymeleaf.extras</groupId>
        <artifactId>thymeleaf-extras-springsecurity5</artifactId>
    </dependency>
</dependencies>

最新版 Spring Boot Security 启动器可在 Maven 中央仓库 获取。

3. 简单实现方案

本方案侧重复用 Spring Security 提供的现成实现,特别是 DaoAuthenticationProviderUsernamePasswordToken

核心组件包括:

  • SimpleAuthenticationFilter - 继承自 UsernamePasswordAuthenticationFilter
  • SimpleUserDetailsService - 实现 UserDetailsService 接口
  • User - 扩展 Spring Security 的 User 类,添加 domain 字段
  • SecurityConfig - Spring Security 配置类,插入自定义过滤器并配置安全规则
  • login.html - 收集 usernamepassworddomain 的登录页面

3.1 简单认证过滤器

SimpleAuthenticationFilter 中,从请求提取 domain 和 username 字段。我们将这些值拼接后创建 UsernamePasswordAuthenticationToken 实例。

然后将该 token 传递给 AuthenticationProvider 进行认证

public class SimpleAuthenticationFilter
  extends UsernamePasswordAuthenticationFilter {

    @Override
    public Authentication attemptAuthentication(
      HttpServletRequest request, 
      HttpServletResponse response) 
        throws AuthenticationException {

        // ...

        UsernamePasswordAuthenticationToken authRequest
          = getAuthRequest(request);
        setDetails(request, authRequest);
        
        return this.getAuthenticationManager()
          .authenticate(authRequest);
    }

    private UsernamePasswordAuthenticationToken getAuthRequest(
      HttpServletRequest request) {
 
        String username = obtainUsername(request);
        String password = obtainPassword(request);
        String domain = obtainDomain(request);

        // ...

        String usernameDomain = String.format("%s%s%s", username.trim(), 
          String.valueOf(Character.LINE_SEPARATOR), domain);
        return new UsernamePasswordAuthenticationToken(
          usernameDomain, password);
    }

    // 其他方法
}

3.2 简单用户详情服务

UserDetailsService 接口定义了 loadUserByUsername 方法。我们的实现会解析 username 和 domain,然后通过 UserRepository 获取 User

public class SimpleUserDetailsService implements UserDetailsService {

    // ...

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        String[] usernameAndDomain = StringUtils.split(
          username, String.valueOf(Character.LINE_SEPARATOR));
        if (usernameAndDomain == null || usernameAndDomain.length != 2) {
            throw new UsernameNotFoundException("Username and domain must be provided");
        }
        User user = userRepository.findUser(usernameAndDomain[0], usernameAndDomain[1]);
        if (user == null) {
            throw new UsernameNotFoundException(
              String.format("Username not found for domain, username=%s, domain=%s", 
                usernameAndDomain[0], usernameAndDomain[1]));
        }
        return user;
    }
}

3.3 Spring Security 配置

我们的配置与标准 Spring Security 配置不同之处在于:通过 addFilterBefore 将自定义过滤器插入到默认过滤器之前

@Override
protected void configure(HttpSecurity http) throws Exception {

    http
      .addFilterBefore(authenticationFilter(), 
        UsernamePasswordAuthenticationFilter.class)
      .authorizeRequests()
        .antMatchers("/css/**", "/index").permitAll()
        .antMatchers("/user/**").authenticated()
      .and()
      .formLogin().loginPage("/login")
      .and()
      .logout()
      .logoutUrl("/logout");
}

我们仍可使用现成的 DaoAuthenticationProvider,因为配置了我们的 SimpleUserDetailsService该服务知道如何解析 username 和 domain 字段

public AuthenticationProvider authProvider() {
    DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
    provider.setUserDetailsService(userDetailsService);
    provider.setPasswordEncoder(passwordEncoder());
    return provider;
}

由于使用了自定义过滤器,我们配置了 AuthenticationFailureHandler 来正确处理登录失败:

public SimpleAuthenticationFilter authenticationFilter() throws Exception {
    SimpleAuthenticationFilter filter = new SimpleAuthenticationFilter();
    filter.setAuthenticationManager(authenticationManagerBean());
    filter.setAuthenticationFailureHandler(failureHandler());
    return filter;
}

3.4 登录页面

登录页面收集额外的 domain 字段,该字段会被 SimpleAuthenticationFilter 提取:

<form class="form-signin" th:action="@{/login}" method="post">
 <h2 class="form-signin-heading">请登录</h2>
 <p>示例:用户名 / 域 / 密码</p>
 <p th:if="${param.error}" class="error">无效的用户名、密码或域</p>
 <p>
   <label for="username" class="sr-only">用户名</label>
   <input type="text" id="username" name="username" class="form-control" 
     placeholder="用户名" required autofocus/>
 </p>
 <p>
   <label for="domain" class="sr-only">域</label>
   <input type="text" id="domain" name="domain" class="form-control" 
     placeholder="域" required autofocus/>
 </p>
 <p>
   <label for="password" class="sr-only">密码</label>
   <input type="password" id="password" name="password" class="form-control" 
     placeholder="密码" required autofocus/>
 </p>
 <button class="btn btn-lg btn-primary btn-block" type="submit">登录</button><br/>
 <p><a href="/index" th:href="@{/index}">返回首页</a></p>
</form>

运行应用并访问 http://localhost:8081,点击安全页面链接会显示登录页。可以看到额外的域字段

Spring Security 额外字段登录页

3.5 方案总结

第一个示例中,我们通过"伪造"用户名字段,成功复用了 DaoAuthenticationProviderUsernamePasswordAuthenticationToken

因此,我们以最少的配置和代码实现了额外登录字段的支持

4. 自定义实现方案

第二种方案与第一种类似,但更适合复杂业务场景。

核心组件包括:

  • CustomAuthenticationFilter - 继承自 UsernamePasswordAuthenticationFilter
  • CustomUserDetailsService - 自定义接口,声明 loadUserbyUsernameAndDomain 方法
  • CustomUserDetailsServiceImpl - 实现 CustomUserDetailsService
  • CustomUserDetailsAuthenticationProvider - 继承自 AbstractUserDetailsAuthenticationProvider
  • CustomAuthenticationToken - 继承自 UsernamePasswordAuthenticationToken
  • User - 扩展 Spring Security 的 User 类,添加 domain 字段
  • SecurityConfig - Spring Security 配置类
  • login.html - 收集 usernamepassworddomain 的登录页面

4.1 自定义认证过滤器

CustomAuthenticationFilter 中,从请求提取 username、password 和 domain 字段。这些值用于创建 CustomAuthenticationToken 实例,然后传递给 AuthenticationProvider

public class CustomAuthenticationFilter 
  extends UsernamePasswordAuthenticationFilter {

    public static final String SPRING_SECURITY_FORM_DOMAIN_KEY = "domain";

    @Override
    public Authentication attemptAuthentication(
        HttpServletRequest request,
        HttpServletResponse response) 
          throws AuthenticationException {

        // ...

        CustomAuthenticationToken authRequest = getAuthRequest(request);
        setDetails(request, authRequest);
        return this.getAuthenticationManager().authenticate(authRequest);
    }

    private CustomAuthenticationToken getAuthRequest(HttpServletRequest request) {
        String username = obtainUsername(request);
        String password = obtainPassword(request);
        String domain = obtainDomain(request);

        // ...

        return new CustomAuthenticationToken(username, password, domain);
    }

4.2 自定义用户详情服务

CustomUserDetailsService 接口定义了 loadUserByUsernameAndDomain 方法。

CustomUserDetailsServiceImpl 实现该接口,委托给 CustomUserRepository 获取 User

 public UserDetails loadUserByUsernameAndDomain(String username, String domain) 
     throws UsernameNotFoundException {
     if (StringUtils.isAnyBlank(username, domain)) {
         throw new UsernameNotFoundException("Username and domain must be provided");
     }
     User user = userRepository.findUser(username, domain);
     if (user == null) {
         throw new UsernameNotFoundException(
           String.format("Username not found for domain, username=%s, domain=%s", 
             username, domain));
     }
     return user;
 }

4.3 自定义用户详情认证提供者

CustomUserDetailsAuthenticationProvider 继承自 AbstractUserDetailsAuthenticationProvider,委托给 CustomUserDetailService 获取用户。最关键的是实现 retrieveUser 方法

注意:必须将认证 token 转换为 CustomAuthenticationToken 才能访问自定义字段:

@Override
protected UserDetails retrieveUser(String username, 
  UsernamePasswordAuthenticationToken authentication) 
    throws AuthenticationException {
 
    CustomAuthenticationToken auth = (CustomAuthenticationToken) authentication;
    UserDetails loadedUser;

    try {
        loadedUser = this.userDetailsService
          .loadUserByUsernameAndDomain(auth.getPrincipal()
            .toString(), auth.getDomain());
    } catch (UsernameNotFoundException notFound) {
 
        if (authentication.getCredentials() != null) {
            String presentedPassword = authentication.getCredentials()
              .toString();
            passwordEncoder.matches(presentedPassword, userNotFoundEncodedPassword);
        }
        throw notFound;
    } catch (Exception repositoryProblem) {
 
        throw new InternalAuthenticationServiceException(
          repositoryProblem.getMessage(), repositoryProblem);
    }

    // ...

    return loadedUser;
}

4.4 方案总结

第二种方案与简单方案几乎相同。通过实现自定义的 AuthenticationProviderCustomAuthenticationToken,我们避免了在用户名字段上添加自定义解析逻辑的麻烦。

5. 结论

本文实现了 Spring Security 表单登录中添加额外字段的两种方式:

  1. 简单方案:通过自定义解析逻辑适配用户名字段,最小化代码量,复用 DaoAuthenticationProviderUsernamePasswordAuthentication
  2. 自定义方案:通过扩展 AbstractUserDetailsAuthenticationProvider 并提供自定义的 CustomUserDetailsServiceCustomAuthenticationToken 实现字段支持

所有源代码可在 GitHub 获取。


原始标题:Extra Login Fields with Spring Security | Baeldung

« 上一篇: JavaDoc 入门指南
» 下一篇: Java Spliterator 详解