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 機制沒有內置的方法來實現將令牌實際傳遞給用户的操作。如文檔所述,這是有意的設計決策,因為實現此功能的方法實在太多了。
相反,OTT 將此責任委託給應用程序代碼,該代碼必須暴露一個實現 OneTimeTokenGenerationSuccessHandler 接口的 Bean。 另一種方法是,我們可以通過配置 DSL 直接傳遞該接口的實現。
此接口只有一個方法,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);
}
}
請注意,OttLoginLinkSuccessHandler 構造函數參數傳遞給 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 接口中可用的 lookup 方法發揮作用的地方。 鑑於我們利用了 Spring Boot 的測試基礎設施,我們可以簡單地將服務注入到我們的測試類中並使用它來查詢令牌:
@Test
void whenLoginWithOtt_thenSuccess() throws Exception {
// ... Jsoup 設置和初始導航已省略
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 的應用程序中的添加方法。