Spring Security 中一次令牌登入指南
1. 概述
為網站提供流暢的登入體驗需要微妙的平衡。一方面,我們希望具有不同電腦等級的使用者能夠盡快完成登入。另一方面,我們需要確保存取我們系統的人員的身份,否則將面臨潛在的災難性安全事件的風險。
在本教程中,我們將展示如何在基於 Spring Boot 的應用程式中使用一次性令牌登入。該機制在易用性和安全性特性之間取得了良好的平衡,並且從 Spring Boot 3.4 版本開始,在使用Spring Security 6.4 或更高版本時受到開箱即用的支援。
2. 什麼是一次性令牌登入?
在電腦應用程式中識別使用者的傳統方法是提供一個表單,讓使用者在其中提供使用者名稱和密碼。現在,如果使用者忘記了他/她的密碼怎麼辦?常見的方法是提供「忘記密碼」按鈕。
當使用者點擊此按鈕時,後端會向使用者發送一條訊息,其中包含一個限時令牌,允許使用者重新定義他/她的密碼。
然而,對於一系列應用程序,用戶不需要經常訪問該網站和/或費心保存其密碼。在這些情況下,使用者往往會不斷使用重設密碼功能,這會產生挫折感,在某些情況下還會引發憤怒的客戶支援電話。以下是屬於此類別的一些應用程式:
- 社區網站(俱樂部、學校、教會、遊戲)
- 文件分發/簽署服務
- 彈出式行銷網站
相反,一次性令牌登入(或簡稱 OTT)機制的工作原理如下:
- 使用者告知他/她的使用者名,該使用者名稱通常對應於他/她的電子郵件地址
- 系統產生一個有時間限制的令牌並使用帶外機制發送它,可以是電子郵件、簡訊、行動通知或類似的
- 用戶在電子郵件/訊息應用程式中打開訊息,然後點擊提供的鏈接,其中包含一次性令牌
- 用戶的裝置瀏覽器打開該鏈接,這會將他帶回系統的 OTT 登入位置
- 系統檢查連結中嵌入的令牌值。如果有效,則授予存取權限,使用者可以繼續操作。或者,顯示令牌提交表單,提交後即可完成登入程序
3.我們什麼時候應該使用OTT?
在考慮給定應用程式的一次性登入機制之前,最好先了解其優點和缺點:
Pros |
Cons |
---|---|
無需管理用戶密碼,這也消除了安全風險 | 基於單一因素的身份驗證,至少來自應用程式的端點 |
即使不懂科技的使用者也易於使用和理解 | 容易受到中間人攻擊 |
我們現在可能會想:為什麼不使用社群登入呢?從技術角度來看,社群登入通常是基於OAuth2/OIDC,比OTT更安全。
然而,啟用它需要更多的操作工作(例如,請求和維護每個提供者的客戶端 ID),並且考慮到共享個人資料的意識增強,可能會導致參與度下降。
4. 使用 Spring Boot 和 Spring Security 實現 OTT
讓我們創建一個簡單的 Spring Boot 應用程序,該應用程式使用自 3.4 版本以來提供的 OTT 支援。像往常一樣,我們首先添加所需的 Maven 依賴項:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>3.4.1<version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
<version>3.4.1<version>
</dependency>
這些相依性的最新版本可在 Maven Central 上找到:
-
[spring-boot-starter-web](https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-web)
-
[spring-boot-starter-security](https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-security)
5.OTT配置
在目前版本中,為應用程式啟用 OTT 需要我們提供SecurityFilterChain
bean:
@Bean
SecurityFilterChain ottSecurityFilterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(ht -> ht.anyRequest().authenticated())
.formLogin(withDefaults())
.oneTimeTokenLogin(withDefaults())
.build();
}
這裡的關鍵點是使用 6.4 版本中作為 DSL 配置的一部分引入的新oneTimeTokenLogin()
方法。像往常一樣,此方法允許我們自訂機制的各個方面。然而,在我們的例子中,我們只使用Customizer.withDefaults()
來接受預設值。
另外,請注意我們已在配置中新增了formLogin()
。如果沒有它,Spring Security 將預設使用基本身份驗證,這與 OTT 不能很好地配合。
最後,在authorizeHttpRequests()
部分,我們剛剛新增了一個需要對所有請求進行身份驗證的配置。
6. 發送令牌
OTT機制沒有內建方法來實現實際交付代幣給用戶。正如文件中所解釋的,這是一個經過深思熟慮的設計決策,因為實現此功能的方法太多了。
相反,OTT 將此責任委託給應用程式程式碼,應用程式程式碼必須公開實作OneTimeTokenGenerationSuccessHandler
介面的 bean 。或者,我們可以直接透過配置 DSL 傳遞該介面的實作。
此介面有一個方法, handle(),
它取得目前 servlet 請求、回應,以及最重要的OneTimeToken
物件。後者俱有以下屬性:
-
tokenValue
:我們需要發送給用戶的生成令牌 -
username
: 通知的使用者名 -
expiresAt
:產生的令牌到期的時刻
典型的實施將經過以下步驟:
- 使用提供的使用者名稱作為鍵來尋找所需的交付詳細資訊。例如,這些詳細資訊可能包括電子郵件地址或電話號碼以及使用者的區域設置
- 建立將使用者帶到 OTT 登入頁面的 URL
- 準備並向用戶發送帶有 OTT 連結的訊息
- 向用戶端發送重新導向回應,將瀏覽器傳送至 OTT 登入頁面
在我們的實作中,我們選擇將與步驟 1 到 3 相關的職責拆分給專用的OttSenderService
。
對於步驟 4,我們將重定向詳細資訊委託給 Spring Security 的RedirectOneTimeTokenGenerationSuccessHandler
。這是最終的實現:
public class OttLoginLinkSuccessHandler implements OneTimeTokenGenerationSuccessHandler {
private final OttSenderService senderService;
private final OneTimeTokenGenerationSuccessHandler redirectHandler = new RedirectOneTimeTokenGenerationSuccessHandler("/login/ott");
// ... constructor omitted
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
OneTimeToken oneTimeToken) throws IOException, ServletException {
senderService.sendTokenToUser(oneTimeToken.getUsername(),
oneTimeToken.getTokenValue(), oneTimeToken.getExpiresAt());
redirectHandler.handle(request, response, oneTimeToken);
}
}
請注意傳遞給 RedirectOneTimeTokenGenerationSuccessHandler 的“/login/ott”
子參數RedirectOneTimeTokenGenerationSuccessHandler.
這對應於令牌提交表單的預設位置,並且可以使用 OTT DSL 配置到不同的位置。
至於OttSenderService
,我們將使用一個假髮件人實現,將令牌儲存在按使用者名稱索引的 Map 中並記錄其值:
public class FakeOttSenderService implements OttSenderService {
private final Map<String,String> lastTokenByUser = new HashMap<>();
@Override
public void sendTokenToUser(String username, String token, Instant expiresAt) {
lastTokenByUser.put(username, token);
log.info("Sending token to username '{}'. token={}, expiresAt={}", username,token,expiresAt);
}
@Override
public Optional<String> getLastTokenForUser(String username) {
return Optional.ofNullable(lastTokenByUser.get(username));
}
}
請注意, OttSenderService
有一個可選方法,允許我們恢復使用者名稱的令牌。此方法的主要目的是簡化單元測試的實現,正如我們將在自動化測試部分中看到的那樣。
7. 手動測試
讓我們透過簡單的導航測試來檢查 OTT 機制應用程式的行為。一旦我們透過 IDE 或使用mvn spring-boot:run
啟動它,請使用您選擇的瀏覽器並導航到http://localhost:8080
。應用程式將傳回一個登入頁面,其中包含接受使用者名稱/密碼的標準表單和 OTT 表單:
由於我們沒有提供任何UserDetailsService
,Spring Boot 的自動配置會建立一個預設的使用者名為「user」的使用者。一旦我們將其輸入到 OTT 表單使用者名字段並點擊「發送令牌」按鈕,我們應該會進入令牌提交表單:
現在,如果我們查看應用程式日誌,我們將看到以下訊息:
cbsott.service.FakeOttSenderService : Sending token to username 'user'. token=a0e3af73-0366-4e26-b68e-0fdeb23b9bb2, expiresAt=...
要完成登入過程,只需將令牌值複製並貼上到表單中,然後按一下Sign In
按鈕。結果,我們將得到一個顯示目前使用者名稱的歡迎頁面:
8. 自動化測試
測試 OTT 登入流程需要導覽一系列頁面,因此我們將使用 Jsoup 庫來幫助我們。
完整的程式碼可在線獲取,它經歷了我們在手動測試中經歷的相同步驟,並在此過程中添加了檢查。
唯一棘手的部分是訪問生成的令牌。這就是OttSenderService
介面上可用的查找方法派上用場的地方。由於我們利用 Spring Boot 的測試基礎設施,因此我們可以簡單地將服務注入到我們的測試類別中並使用它來查詢令牌:
@Test
void whenLoginWithOtt_thenSuccess() throws Exception {
// ... Jsoup setup and initial navigation omitted
var optToken = this.ottSenderService.getLastTokenForUser("user");
assertTrue(optToken.isPresent());
var homePage = conn.newRequest(baseUrl + tokenSubmitAction)
.data("token", optToken.get())
.data("_csrf",csrfToken)
.post();
var username = requireNonNull(homePage.selectFirst("span#current-username")).text();
assertEquals("user",username);
}
9. 結論
在本教程中,我們描述了一次性令牌登入機制以及如何將其新增至基於 Spring Boot 的應用程式。
與往常一樣,所有程式碼都可以在 GitHub 上取得。