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 提供的现成实现,特别是 DaoAuthenticationProvider
和 UsernamePasswordToken
。
核心组件包括:
- ✅ SimpleAuthenticationFilter - 继承自
UsernamePasswordAuthenticationFilter
- ✅ SimpleUserDetailsService - 实现
UserDetailsService
接口 - ✅ User - 扩展 Spring Security 的
User
类,添加domain
字段 - ✅ SecurityConfig - Spring Security 配置类,插入自定义过滤器并配置安全规则
- ✅ login.html - 收集
username
、password
和domain
的登录页面
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,点击安全页面链接会显示登录页。可以看到额外的域字段:
3.5 方案总结
第一个示例中,我们通过"伪造"用户名字段,成功复用了 DaoAuthenticationProvider
和 UsernamePasswordAuthenticationToken
。
因此,我们以最少的配置和代码实现了额外登录字段的支持。
4. 自定义实现方案
第二种方案与第一种类似,但更适合复杂业务场景。
核心组件包括:
- ✅ CustomAuthenticationFilter - 继承自
UsernamePasswordAuthenticationFilter
- ✅ CustomUserDetailsService - 自定义接口,声明
loadUserbyUsernameAndDomain
方法 - ✅ CustomUserDetailsServiceImpl - 实现
CustomUserDetailsService
- ✅ CustomUserDetailsAuthenticationProvider - 继承自
AbstractUserDetailsAuthenticationProvider
- ✅ CustomAuthenticationToken - 继承自
UsernamePasswordAuthenticationToken
- ✅ User - 扩展 Spring Security 的
User
类,添加domain
字段 - ✅ SecurityConfig - Spring Security 配置类
- ✅ login.html - 收集
username
、password
和domain
的登录页面
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 方案总结
第二种方案与简单方案几乎相同。通过实现自定义的 AuthenticationProvider
和 CustomAuthenticationToken
,我们避免了在用户名字段上添加自定义解析逻辑的麻烦。
5. 结论
本文实现了 Spring Security 表单登录中添加额外字段的两种方式:
- 简单方案:通过自定义解析逻辑适配用户名字段,最小化代码量,复用
DaoAuthenticationProvider
和UsernamePasswordAuthentication
- 自定义方案:通过扩展
AbstractUserDetailsAuthenticationProvider
并提供自定义的CustomUserDetailsService
和CustomAuthenticationToken
实现字段支持
所有源代码可在 GitHub 获取。