1. 概述
為網站提供流暢的登錄體驗需要精妙的平衡。一方面,我們希望具有不同程度計算機知識的用户能夠儘快完成登錄;另一方面,我們需要確保訪問我們系統的人員身份,以防發生潛在的災難性安全事件。
在本教程中,我們將演示如何在基於 Spring Boot 的應用程序中使用一次性令牌登錄。 這種機制在易用性和安全性之間取得了良好的平衡,並且自 Spring Boot 3.4 版本起,在僅使用 Spring Security 6.4 或更高版本 時,即可內置支持。
2. 一次性令牌登錄簡介
傳統的計算機應用程序中,用户身份驗證是通過提供一個包含用户名和密碼的表單來實現的。如果用户忘記了自己的密碼,那會怎樣呢?通常的做法是提供“忘記密碼”按鈕。
當用户點擊此按鈕時,後端會向用户發送一條消息,其中包含一個具有時間限制的令牌,允許用户重置自己的密碼。
然而,對於許多應用程序,用户不經常訪問網站,或者不希望記住密碼。 在這些情況下,用户傾向於不斷使用重置密碼功能,這會造成沮喪,並在某些情況下導致憤怒的客户支持電話。以下是一些屬於此類應用程序的示例:
- 社區網站(俱樂部、學校、教堂、遊戲)
- 文檔分發/簽署服務
- 彈出式營銷網站
以下是一次性令牌登錄(簡稱 OTT)的工作原理:
- 用户提供他的用户名,通常與他的電子郵件地址對應
- 系統生成一個具有時間限制的令牌,並通過非標準方式(例如電子郵件、短信消息、移動通知等)發送該令牌
- 用户在電子郵件/消息應用程序中打開消息並點擊提供的鏈接,該鏈接包含一次性令牌
- 用户的設備瀏覽器打開該鏈接,將用户引導回系統的 OTT 登錄位置
- 系統檢查嵌入在鏈接中的令牌值。如果該令牌有效,則允許訪問,並且用户可以繼續操作。 另一種方法是顯示一個令牌提交表單,提交該表單將完成登錄過程
3. 何時應使用 OTT?
在為給定應用程序考慮一次登錄機制之前,最好先評估其優缺點:
| 優點 | 缺點 |
|---|---|
| 無需管理用户密碼,從而也消除了安全風險 | 基於單因素認證,至少從應用程序端點 |
| 易於使用和理解,即使是非技術人員也能輕鬆上手 | 容易受到中間人攻擊 |
我們可能會思考:為什麼不使用社交登錄? 從技術角度來看,基於 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 上獲取:
5. OTT 配置
在當前版本中,為應用程序啓用 OTT 需要我們提供一個 SecurityFilterChain Bean:
@Bean
SecurityFilterChain ottSecurityFilterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(ht -> ht.anyRequest().authenticated())
.formLogin(withDefaults())
.oneTimeTokenLogin(withDefaults())
.build();
}
在此,關鍵在於使用新引入的 oneTimeTokenLogin() 方法,該方法是在 6.4 版本中作為 DSL 配置項推出的。
如往常一樣,此方法允許我們自定義機制的所有方面。在本例中,我們僅使用 Customizer.withDefaults() 來接受默認值。
此外,請注意我們已將 formLogin() 添加到配置中。 如果沒有它,Spring Security 將默認使用 Basic 身份驗證,這與 OTT 不兼容。
最後,在 authorizeHttpRequests() 部分,我們僅添加了要求所有請求進行身份驗證的配置。
6. 發送令牌
OTT 機制沒有內置的方法來實現將令牌實際交付給用户的功能。 如 文檔中所述,這是有意的設計決策,因為實現此功能的方式實在太多了。
相反,令牌交付的責任由應用程序代碼承擔,該代碼必須暴露一個實現 OneTimeTokenGenerationSuccessHandler 接口的 Bean。
該接口只有一個方法,handle(),它接收當前 Servlet 請求、響應以及最重要的,一個 OneTimeToken 對象。
該對象具有以下屬性:
- tokenValue:生成的令牌,需要發送給用户的令牌
- username:告知的用户名
- expiresAt:生成的令牌到期的時間(Instant)
典型的實現流程如下:
- 使用提供的用户名作為鍵來查找所需的交付詳情。例如,這些詳情可能包括電子郵件地址或電話號碼以及用户的區域設置
- 構建一個將用户引導到 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);
}
}請注意,“/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 表單的用户名字段中輸入該用户名並點擊“發送令牌”按鈕時,我們應該到達令牌提交表單:
現在,如果我們查看應用程序日誌,我們將會看到類似的消息:
c.b.s.ott.service.FakeOttSenderService : Sending token to username 'user'. token=a0e3af73-0366-4e26-b68e-0fdeb23b9bb2, expiresAt=...要完成登錄過程,只需將令牌值複製並粘貼到表單中,然後點擊 登錄 按鈕。 這樣,您將看到一個歡迎頁面,其中顯示當前用户名:
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 的應用程序中添加該機制。