背景
在當前這個項目中,老師説建個 issue 來實現一個萬能一次性密碼,簡稱 OTP;第一次聽的時候,感覺是很厲害的東西,密碼還能一次性、居然還是萬能的。然後參照團隊之前老師和學長們寫的代碼來嘗試實現
OTP 是什麼
其實我們日常生活中的 “驗證碼” 就是一種 OTP。
一句話總結:一次性密碼(OTP) 是一種只能使用一次的密碼。它在首次使用後立即失效,因此即使被黑客竊取,也無法被再次利用,從而極大地提升了安全性。
為什麼需要 OTP
傳統的靜態密碼(自己設置的固定密碼)存在很多風險:
- 容易被竊取
- 容易被重複使用
- 容易被猜測或者暴力破解
然而,OTP 有着“動態變化”和“一次性使用”這兩個核心特性,就可以完美的解決上述的問題
主要的工作原理
OTP的生成通常基於一個“種子”和一個動態變化的因素。這其中的動態因素主要有三種,也對應了三種主流的 OTP 類型:
-
基於時間(TOTP - 時間同步一次性密碼)
- 原理:客户端(你的手機App)和服務器共享一個“密鑰”。雙方根據同一個時間戳(通常是30秒一個間隔)和同一個算法,分別獨立生成一個密碼
- 流程:你登錄時,服務器會檢查你輸入的OTP是否與它當前計算出的密碼,或者前一個時間窗口(考慮到時間輕微不同步)的密碼匹配
- 優點:不需要網絡連接(短信需要),離線也能生成
- 例子:銀行安全令牌、認證器App(Google Authenticator, Authy等)
- 基於事件(HOTP - 基於HMAC的一次性密碼)
-
基於短信/郵件(SMS OTP)
實現過程
認證流程
結合 spring security 的時序圖
組件的關係圖
代碼實現
- 在 YzBCryptPasswordEncoder 校驗密碼的時候,加入對"是否啓用超密碼"的驗證
- 實現獲取 OTP 的密碼的 service 方法
- 在 webConfig 中注入 YzBCryptPasswordEncoder
- 驗證:結合第三方插件來實現登錄驗證
在 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編碼後的值)
✅ 測試成功!
遺留的問題
背景
想要實現的效果是,使用系統的用户可以根據自己的需要修改 otpToken 的值;即放在系統配置中,但是啓動的時候卻發生了錯誤
問題
定位錯誤代碼:
當時的想法:是不是初始化系統配置中的 ONE_TIME_PASSWORD_TOKEN 的時候沒有初始化成功,然後構造函數去找的時候才沒有找到呢
結果卻並不是這樣的,這裏的初始化斷點甚至沒有運行就直接報錯了!
💡 spring 的執行順序本身就是先執行 構造函數 再進行 CommandLineRunner 的初始化
所以問題的關鍵是OneTimePasswordServiceImpl 構造方法運行的時間,早於 CommandLineRunner 的執行時間
💣 Spring 的生命週期順序是:
- 構造 service Bean(此時還沒有執行 CommandLineRunner)
- 初始化 AOP、依賴注入等
- ApplicationContext 啓動完成
- !!!!此時才執行CommandLineRunner
也就是説:V012InitOneTimePasswordTokenConfig.run() 只有在所有 Bean 都創建完畢後才會執行
當前找的方法是:使用 flyaway 來初始化數據,而不是 startup
因為Flyway 的執行發生在數據源初始化後、Bean 構造之前
總結
第一次瞭解了 OTP 是什麼,才發現了在生活中用得很多!然後,這次的代碼編寫幾乎是參考團隊歷史上已有的代碼,所以實現起來很快。在寫的過程中有認真去理解核心的實現邏輯。對之前的 spring security 的瞭解也是更加深了。但是還是不足,以及要開始適當的根據編寫的過程總結一下 spring 的生命週期了