如何在Spring Webflux中實(shí)現(xiàn)雙因素認(rèn)證
譯文【51CTO.com快譯】目前,在大多情況下,尤其是對(duì)于企業(yè)級(jí)應(yīng)用、或處理敏感數(shù)據(jù)的應(yīng)用(如:財(cái)務(wù)應(yīng)用)場(chǎng)景中,多因素身份認(rèn)證(Multi-factor authentication,MFA)已成為了最常見(jiàn)的處理方法。此外,MFA也被相關(guān)法律要求在越來(lái)越多的行業(yè)中是強(qiáng)制執(zhí)行(尤其是在歐盟)。因此,如果您正在開(kāi)發(fā)應(yīng)用程序,那么大概率會(huì)以某種形式啟用雙(或多)因素身份認(rèn)證。
在本文中,我將向您展示如何為使用Spring Webflux,來(lái)構(gòu)建的響應(yīng)式API,以實(shí)現(xiàn)兩因素身份認(rèn)證。該應(yīng)用主要使用電子郵件和密碼對(duì)作為第一安全因素,并采用用戶設(shè)備上應(yīng)用程序(如:Google Authenticator)所生成的一次性代碼(TOTP)作為第二安全因素。
兩因素身份認(rèn)證是如何工作的
從技術(shù)上講,兩(或多)因素身份認(rèn)證是一個(gè)安全過(guò)程,用戶必須提供兩個(gè)或更多安全因素來(lái)讓自己得到認(rèn)證。也就是說(shuō),用戶需要提供除密碼以外的另一個(gè)標(biāo)識(shí)符,例如:一次性密碼、硬件令牌、生物特征(如:指紋)等。
該安全過(guò)程涉及到如下步驟:
- 用戶輸入電子郵件(用戶名)和密碼。
- 除了第一憑據(jù),用戶還要提交由認(rèn)證應(yīng)用生成的一次性代碼。
- 應(yīng)用程序在對(duì)電子郵件(用戶名)和密碼進(jìn)行身份認(rèn)證的同時(shí),也使用在注冊(cè)過(guò)程中頒發(fā)的用戶密鑰來(lái)認(rèn)證一次性代碼
由此可見(jiàn),與使用短信傳遞口令代碼相比,使用諸如Google Authenticator、Microsoft Authenticator、以及FreeOTP等身份認(rèn)證應(yīng)用,既能夠避免SIM卡遭受攻擊(請(qǐng)參見(jiàn)--
https://www.theverge.com/2017/6/17/15772142/how-to-set-up-two-factor-authentication),又能夠無(wú)需蜂窩網(wǎng)絡(luò)或互聯(lián)網(wǎng)連接,進(jìn)行正常認(rèn)證。
應(yīng)用示例
下面,我們將逐步構(gòu)建一個(gè)使用兩因素身份認(rèn)證技術(shù)的簡(jiǎn)單REST API。該API要求用戶提供電子郵件密碼對(duì),和由應(yīng)用生成的短代碼。在此,我使用Android版的Google Authenticator來(lái)生成TOTP。其源代碼的github庫(kù)鏈接為--
https://github.com/mednikoviurii/spring-twofactor-example。該應(yīng)用會(huì)用到JDK 11、Maven、以及用于存儲(chǔ)用戶個(gè)人信息的MongoDB。其項(xiàng)目組織結(jié)構(gòu)如下圖所示:
應(yīng)用示例的項(xiàng)目結(jié)構(gòu)
在此,我不會(huì)遍歷地介紹每一個(gè)組成部分,而只會(huì)專注于AuthService、TokenManager和TotpManager。這些部分主要負(fù)責(zé)身份的認(rèn)證流程。它們分別提供了以下功能:
- AuthService –該組件主要用于存儲(chǔ)、認(rèn)證和授權(quán)所有的業(yè)務(wù)邏輯,其中包括:注冊(cè)、登錄和令牌認(rèn)證。
- TokenManager–該組件通過(guò)抽象代碼,以生成和認(rèn)證JWT令牌。它能夠使得主要業(yè)務(wù)邏輯的實(shí)現(xiàn)與具體的JWT庫(kù)相互獨(dú)立。在此,我使用是Nimbus JOSE-JWT(請(qǐng)參見(jiàn)--https://connect2id.com/products/nimbus-jose-jwt/examples)。
- TotpManager–作為另一種抽象,它能夠?qū)?shí)現(xiàn)與基本邏輯相隔離。TotpManager既可被用于生成用戶的密鑰,又可以斷言(assert,可以立即為驗(yàn)證)給出的短代碼。在此,我使用的是TOTP Java庫(kù)(https://github.com/samdjstevens/java-totp)來(lái)實(shí)現(xiàn),當(dāng)然您也可以選用其他的庫(kù)。
由于在此僅關(guān)注認(rèn)證組件,因此我們將從用戶的創(chuàng)建過(guò)程(注冊(cè))開(kāi)始,同時(shí)涉及到密鑰的生成和令牌的頒發(fā)。接著,我們將進(jìn)入登錄流程,涉及一個(gè)由用戶提供的短代碼的斷言。
實(shí)現(xiàn)注冊(cè)流程
下面,我們將完成一個(gè)注冊(cè)的過(guò)程,其中涉及以下步驟:
- 從客戶端獲取注冊(cè)請(qǐng)求。
- 檢查該用戶是否存在。
- 對(duì)密碼進(jìn)行哈希。
- 生成一個(gè)密鑰。
- 將用戶存儲(chǔ)到數(shù)據(jù)庫(kù)中。
- 頒發(fā)JWT。
- 返回帶有用戶ID、私鑰和令牌的響應(yīng)。
我將主要的業(yè)務(wù)邏輯(AuthServiceImpl)與令牌的生成,以及密鑰的產(chǎn)生分離開(kāi)來(lái)。
一般步驟
主要組件AuthServiceImpl會(huì)接受SignupRequest,并返回SignupResponse。在后臺(tái),它負(fù)責(zé)整個(gè)注冊(cè)的邏輯。下面是具體的實(shí)現(xiàn)代碼:
Java
- 1. @Override
- 2. public Mono<SignupResponse> signup(SignupRequest request) {
- 3. // generating a new user entity params
- 4. // step 1
- 5. String email = request.getEmail().trim().toLowerCase();
- 6. String password = request.getPassword();
- 7. String salt = BCrypt.gensalt();
- 8. String hash = BCrypt.hashpw(password, salt);
- 9. String secret = totpManager.generateSecret();
- 10. User user = new User(null, email, hash, salt, secret);
- 11. // preparing a Mono
- 12. Mono<SignupResponse> response = repository.findByEmail(email)
- 13. .defaultIfEmpty(user) // step 2
- 14. .flatMap(result -> {
- 15. // assert, that user does not exist
- 16. // step 3
- 17. if (result.getUserId() == null) {
- 18. // step 4
- 19. return repository.save(result).flatMap(result2 -> {
- 20. // prepare token
- 21. // step 5
- 22. String userId = result2.getUserId();
- 23. String token = tokenManager.issueToken(userId);
- 24. SignupResponse signupResponse = new SignupResponse();
- 25. signupResponse.setUserId(userId);
- 26. signupResponse.setSecretKey(secret);
- 27. signupResponse.setToken(token);
- 28. signupResponse.setSuccess(true);
- 29.
- 30. return Mono.just(signupResponse);
- 31. });
- 32. } else {
- 33. // step 6
- 34. // scenario - user already exists
- 35. SignupResponse signupResponse = new SignupResponse();
- 36. signupResponse.setSuccess(false);
- 37.
- 38. return Mono.just(signupResponse);
- 39. }
- 40. });
- 41. return response;
下面,讓我們逐步解讀上述實(shí)現(xiàn)的過(guò)程。在邏輯判讀中:如果當(dāng)前用戶是新用戶,我們將對(duì)其進(jìn)行注冊(cè);如果該用戶已經(jīng)存在于數(shù)據(jù)庫(kù)之中,那么我們就必須拒絕該請(qǐng)求。具體步驟為:
- 我們根據(jù)請(qǐng)求數(shù)據(jù)創(chuàng)建一個(gè)新的用戶實(shí)體,并生成一個(gè)相應(yīng)的密鑰。
- 如果該用戶過(guò)去不存在,則將給出的新實(shí)體作為其默認(rèn)實(shí)體。
- 檢查存儲(chǔ)庫(kù)的調(diào)用結(jié)果。
- 將用戶保存在數(shù)據(jù)庫(kù)中,并獲取其userId。
- 頒發(fā)JWT。
- 如果用戶已經(jīng)存在,則返回一個(gè)拒絕響應(yīng)。
相比以漏洞和安全問(wèn)題而聞名的SHA函數(shù),我在此選用jBcrypt庫(kù)(請(qǐng)參見(jiàn)-- https://www.mindrot.org/projects/jBCrypt/),來(lái)產(chǎn)生各種安全的哈希和salt(鹽)。如不你不太熟悉jBcrypt的話,請(qǐng)參見(jiàn)教程--
https://dzone.com/articles/password-encryption-and-decryption-using-bcrypt,以獲取更多信息。
生成密鑰
接下來(lái),我們需要實(shí)現(xiàn)一個(gè)用來(lái)生成新的密鑰的函數(shù)。它是由TotpManager.generateSecret()內(nèi)部抽象而來(lái)。下面是它的代碼:
Java:
- 1. @Override
- 2. public String generateSecret() {
- 3. SecretGenerator generator = new DefaultSecretGenerator();
- 4. return generator.generate();
- 5. }
測(cè)試
實(shí)現(xiàn)了注冊(cè)邏輯之后,我們需要測(cè)試它是否能夠按預(yù)期進(jìn)行認(rèn)證。首先,讓我們調(diào)用signup端點(diǎn)以創(chuàng)建一個(gè)新的用戶。其結(jié)果對(duì)象應(yīng)當(dāng)包含我們需要添加到應(yīng)用生成器(如:Google Authenticator)的userId、令牌和密鑰:
成功注冊(cè)
不過(guò),我們應(yīng)當(dāng)禁止同一封電子郵件兩次進(jìn)行注冊(cè)。在此,我們通過(guò)斷言,以保證應(yīng)用在創(chuàng)建新用戶之前,去檢查現(xiàn)有的電子郵件列表:
登錄響應(yīng)對(duì)象
登錄
下面,我們來(lái)討論登錄流程。該流程包括兩個(gè)主要部分:認(rèn)證電子郵件的密碼憑據(jù),以及認(rèn)證由用戶提供的一次性代碼。和上一節(jié)一樣,我們首先介紹登錄所涉及的步驟:
- 從客戶端獲取登錄請(qǐng)求。
- 在數(shù)據(jù)庫(kù)中找到該用戶。
- 使用請(qǐng)求中提供的密碼進(jìn)行斷言。
- 斷言一次性代碼。
- 返回帶有令牌的登錄響應(yīng)。
而JWT的生成過(guò)程與注冊(cè)的過(guò)程比較類似。
一般步驟
作為該示例的功能重點(diǎn),AuthServiceImpl.login將實(shí)現(xiàn)主要的業(yè)務(wù)邏輯。首先,我們需要通過(guò)在數(shù)據(jù)庫(kù)中請(qǐng)求電子郵件,來(lái)查找用戶;否則,我們需要提供帶有空字段的默認(rèn)值。也就是說(shuō),讓user.getUserId() == null,以表示該用戶并不存在,登錄流程隨即中止。
接著,我們需要斷言密碼的匹配。當(dāng)我們將密碼的哈希值存儲(chǔ)在數(shù)據(jù)庫(kù)中時(shí),就需要使用存儲(chǔ)的salt對(duì)請(qǐng)求中的密碼進(jìn)行哈希處理,進(jìn)而斷言這兩個(gè)值。
如果密碼匹配,我們需要使用之前存儲(chǔ)的密鑰值來(lái)認(rèn)證提交的代碼。認(rèn)證成功與否的結(jié)果,將在產(chǎn)生JWT和創(chuàng)建LoginResponse對(duì)象后得出。以下便是此部分的最終源代碼:
Java
- 1. @Override
- 2. public Mono<LoginResponse> login(LoginRequest request) {
- 3. String email = request.getEmail().trim().toLowerCase();
- 4. String password = request.getPassword();
- 5. String code = request.getCode();
- 6. Mono<LoginResponse> response = repository.findByEmail(email)
- 7. // step 1
- 8. .defaultIfEmpty(new User())
- 9. .flatMap(user -> {
- 10. // step 2
- 11. if (user.getUserId() == null) {
- 12. // no user
- 13. LoginResponse loginResponse = new LoginResponse();
- 14. loginResponse.setSuccess(false);
- 15.
- 16. return Mono.just(loginResponse);
- 17. } else {
- 18. // step 3
- 19. // user exists
- 20. String salt = user.getSalt();
- 21. String secret = user.getSecretKey();
- 22. boolean passwordMatch = BCrypt.hashpw(password, salt).equalsIgnoreCase(user.getHash());
- 23. if (passwordMatch) {
- 24. // step 4
- 25. // password matched
- 26. boolean codeMatched = totpManager.validateCode(code, secret);
- 27. if (codeMatched) {
- 28. // step 5
- 29. String token = tokenManager.issueToken(user.getUserId());
- 30. LoginResponse loginResponse = new LoginResponse();
- 31. loginResponse.setSuccess(true);
- 32. loginResponse.setToken(token);
- 33. loginResponse.setUserId(user.getUserId());
- 34.
- 35. return Mono.just(loginResponse);
- 36. } else {
- 37. LoginResponse loginResponse = new LoginResponse();
- 38. loginResponse.setSuccess(false);
- 39. return Mono.just(loginResponse);
- 40. }
- 41. } else {
- 42. LoginResponse loginResponse = new LoginResponse();
- 43. loginResponse.setSuccess(false);
- 44.
- 45. return Mono.just(loginResponse);
- 46. }
- 47. }
- 48. });
- 49. return response;
- 50. }
可見(jiàn),后臺(tái)的邏輯步驟為:
- 提供具有空字段的默認(rèn)用戶實(shí)體。
- 檢查該用戶是否確實(shí)存在。
- 從請(qǐng)求和salt處生成密碼的哈希,并存儲(chǔ)在數(shù)據(jù)庫(kù)中。
- 斷言密鑰是否能夠確實(shí)匹配。
- 認(rèn)證一次性代碼,并頒發(fā)JWT。
斷言一次性代碼
為了認(rèn)證由應(yīng)用生成的一次性代碼,我們必須向TOTP庫(kù)提供相應(yīng)的代碼和密鑰,并將它們保存為用戶實(shí)體的一部分。具體代碼如下:
Java
- 1. @Override
- 2. public boolean validateCode(String code, String secret) {
- 3. TimeProvider timeProvider = new SystemTimeProvider();
- 4. CodeGenerator codeGenerator = new DefaultCodeGenerator();
- 5. CodeVerifier verifier = new DefaultCodeVerifier(codeGenerator, timeProvider);
- 6. return verifier.isValidCode(secret, code);
- 7. }
測(cè)試
最后,我們可以通過(guò)測(cè)試,以認(rèn)證登錄的過(guò)程是否如期運(yùn)行。我們將由Google Authenticator生成的代碼作為登錄請(qǐng)求的負(fù)載,去調(diào)用login端點(diǎn)。
如下圖所示,為了檢查處密碼錯(cuò)誤的情況,我們需要將進(jìn)程終止在密碼斷言階段:
由于密碼錯(cuò)誤,登錄被拒絕
至此,我們已經(jīng)創(chuàng)建了一個(gè)簡(jiǎn)單的REST API,它可以通過(guò)Spring Webflux(請(qǐng)參見(jiàn)--
https://www.mednikov.tech/two-factor-authentication-for-spring-webflux-apis/)的TOTP來(lái)提供兩因素身份認(rèn)證。如前文所述,為了更專注于身份認(rèn)證的邏輯,我們省略了所有的其他部分。
如果您對(duì)該示例的完整代碼感興趣,請(qǐng)參見(jiàn)--https://github.com/mednikoviurii/spring-twofactor-example。
參考文獻(xiàn)
- Dhiraj Ray的《使用jBCrypt實(shí)現(xiàn)密鑰的加、解密》(2017)--https://dzone.com/articles/password-encryption-and-decryption-using-bcrypt。
- Sanjay Patel的《如何在Spring應(yīng)用中使用Nimbus JOSE和JWT》Natural Programmer Blog(2018)--https://www.naturalprogrammer.com/blog/17852/spring-framework-nimbus-jose-jwt。
- Scott Brady的《使用Nimbus JOSE和JWT創(chuàng)建帶有簽名的JWT》(2019)--https://www.scottbrady91.com/Kotlin/Creating-Signed-JWTs-using-Nimbus-JOSE-JWT。
原標(biāo)題:Two-Factor Authentication in Spring Webflux REST API ,作者:Yuri Mednikov
【51CTO譯稿,合作站點(diǎn)轉(zhuǎn)載請(qǐng)注明原文譯者和出處為51CTO.com】