1. 简介
在本文中,我们将通过向标准登录表单添加一个额外字段来使用Spring Security实现自定义身份验证场景。
我们将专注于2 种不同的方法,以展示框架的多功能性和我们可以使用它的灵活方式。
我们的第一个方法将是一个简单的解决方案,它侧重于重用现有的核心 Spring Security 实现。
我们的第二种方法将是一个更加自定义的解决方案,可能更适合高级用例。
我们将建立在我们之前关于 Spring Security login的文章中讨论的概念之上。
2. Maven 设置
我们将使用 Spring Boot 启动器来引导我们的项目并引入所有必要的依赖项。
我们将使用的设置需要父声明、Web 启动器和安全启动器;我们还将包括 thymeleaf :
<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>
可以[在 Maven Central](https://search.maven.org/classic/#search|ga|1|g%3A"org.springframework.boot" AND a%3A"spring-boot-starter-security")找到最新版本的 Spring Boot security starter 。
3. 简单的项目设置
在我们的第一种方法中,我们将专注于重用 Spring Security 提供的实现。特别是,我们将重用DaoAuthenticationProvider和UsernamePasswordToken,因为它们“开箱即用”。
关键组成部分将包括:
- SimpleAuthenticationFilter – UsernamePasswordAuthenticationFilter的扩展
- SimpleUserDetailsService – UserDetailsS ervice的一个实现
- User – Spring Security提供的User类的扩展,它声明了额外的域字段
- SecurityConfig – Spring Security配置,它将SimpleAuthenticationFilter插入过滤器链,声明安全规则并连接依赖项
- login.html – 一个登录页面,包含用户名、密码和域
3.1. 简单的身份验证过滤器
在我们的SimpleAuthenticationFilter中,从请求中提取域和用户名字段。我们连接这些值并使用它们来创建一个UsernamePasswordAuthenticationToken的实例。
然后将令牌传递给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);
}
// other methods
}
3.2. 简单 UserDetails 服务
UserDetailsService定义了一个名为loadUserByUsername的方法。我们的实现提取用户名和域。然后将这些值传递给UserRepository以获取用户:
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将SimpleAuthenticationFilter插入到默认过滤器链之前:
@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配置了它。回想一下,我们的SimpleUserDetailsService知道如何解析用户名和域字段,并在身份验证时返回适当的用户:
public AuthenticationProvider authProvider() {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailsService);
provider.setPasswordEncoder(passwordEncoder());
return provider;
}
因为我们使用了SimpleAuthenticationFilter,所以我们配置了自己的AuthenticationFailureHandler,以确保正确地处理失败的登录尝试:
public SimpleAuthenticationFilter authenticationFilter() throws Exception {
SimpleAuthenticationFilter filter = new SimpleAuthenticationFilter();
filter.setAuthenticationManager(authenticationManagerBean());
filter.setAuthenticationFailureHandler(failureHandler());
return filter;
}
3.4. 登录页面
我们使用的登录页面收集由我们的SimpleAuthenticationFilter提取的额外域字段:
<form class="form-signin" th:action="@{/login}" method="post">
<h2 class="form-signin-heading">Please sign in</h2>
<p>Example: user / domain / password</p>
<p th:if="${param.error}" class="error">Invalid user, password, or domain</p>
<p>
<label for="username" class="sr-only">Username</label>
<input type="text" id="username" name="username" class="form-control"
placeholder="Username" required autofocus/>
</p>
<p>
<label for="domain" class="sr-only">Domain</label>
<input type="text" id="domain" name="domain" class="form-control"
placeholder="Domain" required autofocus/>
</p>
<p>
<label for="password" class="sr-only">Password</label>
<input type="password" id="password" name="password" class="form-control"
placeholder="Password" required autofocus/>
</p>
<button class="btn btn-lg btn-primary btn-block" type="submit">Sign in</button><br/>
<p><a href="/index" th:href="@{/index}">Back to home page</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类的扩展,它声明了额外的域字段
- SecurityConfig: Spring Security配置,将CustomAuthenticationFilter插入到过滤器链中,声明安全规则并连接依赖
- login.html: 包含用户名、密码和域的登录页面
4.1. 自定义身份验证过滤器
在我们的CustomAuthenticationFilter中,我们从请求中提取用户名、密码和域字段。这些值用于创建我们的自定义AuthenticationToken实例,该实例将传递给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. 自定义 UserDetails 服务
我们的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. 自定义 UserDetailsAuthenticationProvider
我们的CustomUserDetailsAuthenticationProvider扩展了AbstractUserDetailsAuthenticationProvider并委托给我们的CustomUserDetailService来检索User。这个类最重要的特性是retrieveUser方法的实现。
请注意,我们必须将身份验证令牌转换为我们的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 上找到。