博客 / 詳情

返回

在 spring 上實現 oneTimePassword

背景

在當前這個項目中,老師説建個 issue 來實現一個萬能一次性密碼,簡稱 OTP;第一次聽的時候,感覺是很厲害的東西,密碼還能一次性、居然還是萬能的。然後參照團隊之前老師和學長們寫的代碼來嘗試實現

OTP 是什麼

其實我們日常生活中的 “驗證碼” 就是一種 OTP。

一句話總結:一次性密碼(OTP) 是一種只能使用一次的密碼。它在首次使用後立即失效,因此即使被黑客竊取,也無法被再次利用,從而極大地提升了安全性。

為什麼需要 OTP

傳統的靜態密碼(自己設置的固定密碼)存在很多風險:

  • 容易被竊取
  • 容易被重複使用
  • 容易被猜測或者暴力破解

然而,OTP 有着“動態變化”和“一次性使用”這兩個核心特性,就可以完美的解決上述的問題

主要的工作原理

OTP的生成通常基於一個“種子”和一個動態變化的因素。這其中的動態因素主要有三種,也對應了三種主流的 OTP 類型:

  • 基於時間(TOTP - 時間同步一次性密碼)

    • 原理:客户端(你的手機App)和服務器共享一個“密鑰”。雙方根據同一個時間戳(通常是30秒一個間隔)和同一個算法,分別獨立生成一個密碼
    • 流程:你登錄時,服務器會檢查你輸入的OTP是否與它當前計算出的密碼,或者前一個時間窗口(考慮到時間輕微不同步)的密碼匹配
    • 優點:不需要網絡連接(短信需要),離線也能生成
    • 例子:銀行安全令牌、認證器App(Google Authenticator, Authy等)

    image.png
    image.png

  • 基於事件(HOTP - 基於HMAC的一次性密碼)
  • 基於短信/郵件(SMS OTP)

    實現過程

    認證流程

    graph TD
      A[用户提交登錄請求] --> B[UsernamePasswordAuthenticationFilter]
      B --> C[生成 UsernamePasswordAuthenticationToken]
      C --> D[AuthenticationManager 調用 ProviderManager]
      D --> E[DaoAuthenticationProvider]
      E --> F[調用 YzBCryptPasswordEncoder.matches]
      
      F --> G{檢查是否為一次性密碼}
      G -->|是| H[驗證一次性密碼]
      H --> I[標記一次性密碼為已使用]
      I --> J[返回 true]
      
      G -->|否| K[調用父類 BCrypt.matches]
      K --> L[正常BCrypt密碼驗證]
      L --> M[返回驗證結果]
      
      J --> N[認證成功]
      M --> N

    結合 spring security 的時序圖

image.png

組件的關係圖

classDiagram
    class YzBCryptPasswordEncoder {
        -OneTimePasswordService oneTimePasswordService
        +matches(CharSequence, String) boolean
    }
    
    class BCryptPasswordEncoder {
        +matches(CharSequence, String) boolean
    }
    
    class OneTimePasswordService {
        +getPassword() Optional~String~
    }
    
    class DaoAuthenticationProvider {
        -PasswordEncoder passwordEncoder
        +authenticate(Authentication) Authentication
    }
    
    class WebConfig {
        -YzBCryptPasswordEncoder passwordEncoder
        +authenticationManager() AuthenticationManager
        +filterChain() SecurityFilterChain
    }
    
    YzBCryptPasswordEncoder --|> BCryptPasswordEncoder : 繼承
    YzBCryptPasswordEncoder --> OneTimePasswordService : 依賴
    DaoAuthenticationProvider --> YzBCryptPasswordEncoder : 使用
    WebConfig --> YzBCryptPasswordEncoder : 創建並注入
            

代碼實現

  1. 在 YzBCryptPasswordEncoder 校驗密碼的時候,加入對"是否啓用超密碼"的驗證
  2. 實現獲取 OTP 的密碼的 service 方法
  3. 在 webConfig 中注入 YzBCryptPasswordEncoder
  4. 驗證:結合第三方插件來實現登錄驗證

在 YzBCryptPasswordEncoder 中的匹配方法中加入對超密碼的驗證

@Override
    public boolean matches(CharSequence rawPassword, String encodedPassword) {
        if (rawPassword == null) {
            throw new IllegalArgumentException("rawPassword cannot be null");
        }

        logger.debug("當有一次性密碼(每個密碼僅能用一次)且未使用時,驗證用户是否輸入了超密");
        Optional<String> oneTimePassword = this.oneTimePasswordService.getPassword();

        if (oneTimePassword.isPresent() && oneTimePassword.get().equals(rawPassword.toString())) {
            logger.warn("當前正在使用超密碼登陸");
            return true;
        }
         return super.matches(rawPassword, encodedPassword);
    }

實現 OneTimePasswordService 中的 getPassword

可以看出來,我們這裏實現的是基於時間的 OTP
@Service
public class OneTimePasswordServiceImpl implements OneTimePasswordService {
    private final Logger logger = LoggerFactory.getLogger(this.getClass());

    private String password = "";

    private final String token;

    // todo: 下一個 issue 實現通過獲取系統配置中的 ONE_TIME_PASSWORD_TOKEN 來設置 token 的值
    private final String otpToken = "abc";

    public OneTimePasswordServiceImpl() {
        Base32 base32 = new Base32();
        this.token = base32.encodeAsString(otpToken.getBytes());
    }

    /**
     * 僅允許獲取1次,獲取成功之後的 code 值為 null
     * @return
     */
    @Override
    public Optional<String> getPassword() {
        try {
            String password = TimeBasedOneTimePasswordUtil.generateCurrentNumberString(this.token);
            // 每個密碼只能用一次,如果生成的密碼與當前的密碼相同,則説明短時間內請求了兩次,返回 empty
            if (password.equals(this.password)) {
                return Optional.empty();
            } else {
                this.password = password;
            }
        } catch (GeneralSecurityException e) {
            this.logger.error("生成一次性密碼時發生錯誤");
            e.printStackTrace();
        }

        return Optional.of(this.password);
    }
}

結合 Google Authenticator 來登錄我們當前的系統來驗證我們的 OTP 是否設置成功
其中的對應關係:

  • Issuer(標識) = otpToken ("abc")
  • Secret(密鑰) = token (abc Base32編碼後的值)

image.png

✅ 測試成功!

image.png

遺留的問題

背景

想要實現的效果是,使用系統的用户可以根據自己的需要修改 otpToken 的值;即放在系統配置中,但是啓動的時候卻發生了錯誤

問題

image.png

定位錯誤代碼:

image.png

image.png

當時的想法:是不是初始化系統配置中的 ONE_TIME_PASSWORD_TOKEN 的時候沒有初始化成功,然後構造函數去找的時候才沒有找到呢

image.png

結果卻並不是這樣的,這裏的初始化斷點甚至沒有運行就直接報錯了!
💡 spring 的執行順序本身就是先執行 構造函數 再進行 CommandLineRunner 的初始化
所以問題的關鍵是OneTimePasswordServiceImpl 構造方法運行的時間,早於 CommandLineRunner 的執行時間


💣 Spring 的生命週期順序是:

  1. 構造 service Bean(此時還沒有執行 CommandLineRunner)
  2. 初始化 AOP、依賴注入等
  3. ApplicationContext 啓動完成
  4. !!!!此時才執行CommandLineRunner

也就是説:V012InitOneTimePasswordTokenConfig.run() 只有在所有 Bean 都創建完畢後才會執行

image.png

當前找的方法是:使用 flyaway 來初始化數據,而不是 startup

因為Flyway 的執行發生在數據源初始化後、Bean 構造之前

總結

第一次瞭解了 OTP 是什麼,才發現了在生活中用得很多!然後,這次的代碼編寫幾乎是參考團隊歷史上已有的代碼,所以實現起來很快。在寫的過程中有認真去理解核心的實現邏輯。對之前的 spring security 的瞭解也是更加深了。但是還是不足,以及要開始適當的根據編寫的過程總結一下 spring 的生命週期了

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.