1. 簡介
本文將通過在標準登錄表單中Spring Security中添加額外字段來實現自定義身份驗證場景。
我們將重點關注2種不同的方法,以展示框架的多樣性和我們靈活地使用它的方式。
我們的第一種方法將是一個簡單的解決方案,專注於重用現有的 Spring Security 實現。
我們的第二種方法將是一個更自定義的解決方案,可能更適合高級用例。
我們將構建在我們在 Spring Security 登錄文章中討論的概念之上。
2. Maven 設置
我們將使用 Spring Boot Starter 來初始化我們的項目並引入所有必要的依賴。
我們將採用的設置需要父模塊聲明、Web Starter 和 Security Starter;我們還將包含 Thymeleaf:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.2</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 安全啓動器的最新版本可以在 Maven 中央倉庫 找到。
3. 簡單項目設置
在我們的首次方法中,我們將重點重用 Spring Security 提供的實現。特別是,我們將重用 DaoAuthenticationProvider 和 UsernamePasswordToken,因為它們“開箱即用”。
關鍵組件包括:
- SimpleAuthenticationFilter – UsernamePasswordAuthenticationFilter 的擴展
- SimpleUserDetailsService – UserDetailsService 的實現
- User – Spring Security 提供的 User 類的擴展,聲明我們的額外 domain 字段
- SecurityConfig – 我們的 Spring Security 配置,將 SimpleAuthenticationFilter 插入到過濾器鏈中,聲明安全規則並配置依賴關係
- 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);
}
// other methods
}3.2. 簡單的 UserDetails 服務
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 方法,插入我們的 SimpleAuthenticationFilter。
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf(AbstractHttpConfigurer::disable)
.authorizeRequests()
.requestMatchers("/css/**", "/index")
.permitAll()
.requestMatchers("/user/**")
.authenticated()
.and()
.formLogin(httpSecurityFormLoginConfigurer -> httpSecurityFormLoginConfigurer.loginPage("/login").permitAll())
.logout(httpSecurityLogoutConfigurer -> httpSecurityLogoutConfigurer.logoutUrl("/logout").permitAll())
.with(securityConfig(), Customizer.withDefaults());
return http.getOrBuild();
}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());
filter.setAuthenticationSuccessHandler(new SavedRequestAwareAuthenticationSuccessHandler());
filter.setSecurityContextRepository(new HttpSessionSecurityContextRepository());
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 類,聲明我們的額外 domain 字段
- SecurityConfig – 我們的 Spring Security 配置,將 CustomAuthenticationFilter 插入到過濾器鏈中,聲明安全規則並連接依賴項
- login.html – 收集 username, password 和 domain 的登錄頁面
4.1. 自定義身份驗證過濾器
在我們的 CustomAuthenticationFilter 中,我們 從請求中提取用户名、密碼和域名字段。 這些值用於創建我們的 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. 自定義 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. 自定義 <em>UserDetailsAuthenticationProvider</em>
我們的 <em>CustomUserDetailsAuthenticationProvider</em> 繼承了 <em>AbstractUserDetailsAuthenticationProvider</em> 並委託給我們的 <em>CustomUserDetailService</em> 以檢索 <em>User</em>。該類的最重要功能是實現 <em>retrieveUser</em> 方法。
請注意,為了訪問我們的自定義字段,必須將身份驗證令牌轉換為我們的 <em>CustomAuthenticationToken</em>。
@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並使用自定義解析邏輯調整用户名
- 在我們的更定製化的方法中,我們通過擴展 AbstractUserDetailsAuthenticationProvider並提供自定義 CustomUserDetailsService以及 CustomAuthenticationToken來提供自定義字段支持