SpringSecurity系列學習(四-番外):多因子驗證和TOTP


系列導航

SpringSecurity系列

SpringSecurityOauth2系列

多因子驗證

這一節屬於話題外,討論一下多因子認證

單純的用戶名/密碼登陸在某些時候還是不夠安全的,因為大部分用戶會在各種平台采用同樣的密碼,只要有一個平台發生泄漏,那么很可能會影響其他平台,這要有一個平台發生泄漏,那么很可能影響其他平台,所以增加一步或者多步驗證成了目前較流行的方案。

雙因子登陸是最基礎的,也是用戶體驗最好的。比如登陸的時候郵件或者短信驗證,比如指紋,人臉認證等。

TOTP

基於時間的一次性密碼

  • 一次性:
    • 在多步驗證中,通常會在第二步采用隨機密碼,這個密碼一般情況下是一個一次性密碼,也就是驗證之后就拋棄掉了,不允許進行多次使用同一個密碼進行驗證。

    • 但這存在一個問題,如果一直允許用戶輸入新的密碼進行驗證,這等於給了惡意用戶不斷嘗試的機會。

  • 時間性: 為了解決上面的問題,提出了時間性 -> 這個一次性驗證碼是有時效期的。而且在有效期內,這個密碼的生成應該一致的。過了這個時間之后,密碼過期,不能進行認證。這就是基於時間的一次性密碼。它是用一種以當前時間作為輸入的算法生成的。

引入依賴

    <properties>
      ...
        <otp.version>0.2.0</otp.version>
      ...
    </properties>
        ...
        <!-- Java OTP 依賴 -->
        <dependency>
            <groupId>com.eatthepath</groupId>
            <artifactId>java-otp</artifactId>
            <version>${otp.version}</version>
        </dependency>
        ...

工具類

import com.eatthepath.otp.TimeBasedOneTimePasswordGenerator;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import javax.crypto.KeyGenerator;
import javax.crypto.spec.SecretKeySpec;
import java.security.InvalidKeyException;
import java.security.Key;
import java.security.NoSuchAlgorithmException;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.Optional;

/**
 * Totp工具類
 * 用於一次性驗證碼
 *
 * @author 硝酸銅
 * @date 2021/9/22
 */
@Slf4j
@Component
public class TotpUtil {

    /**
     * 密碼有效期,在有效期內,生成的所有密碼都一樣
     */
    private static final long TIME_STEP = 60 * 5L;

    /**
     * 密碼長度
     */
    private static final int PASSWORD_LENGTH = 6;
    private KeyGenerator keyGenerator;
    private TimeBasedOneTimePasswordGenerator totp;

    /*
     * 初始化代碼塊,Java 8 開始支持。這種初始化代碼塊的執行在構造函數之前
     * 准確說應該是 Java 編譯器會把代碼塊拷貝到構造函數的最開始。
     */
    {
        try {
            totp = new TimeBasedOneTimePasswordGenerator(Duration.ofSeconds(TIME_STEP), PASSWORD_LENGTH);
            keyGenerator = KeyGenerator.getInstance(totp.getAlgorithm());
            // SHA-1 and SHA-256 需要 64 字節 (512 位) 的 key; SHA512 需要 128 字節 (1024 位) 的 key
            keyGenerator.init(512);
        } catch (NoSuchAlgorithmException e) {
            log.error("沒有找到算法 {}", e.getLocalizedMessage());
        }
    }

    /**
     * @param time 用於生成 TOTP 的時間
     * @return 一次性驗證碼
     * @throws InvalidKeyException 非法 Key 拋出異常
     */
    public String createTotp(final Key key, final Instant time) throws InvalidKeyException {
        String  format = "%0" + PASSWORD_LENGTH + "d";
        return String.format(format, totp.generateOneTimePassword(key, time));
    }

    public Optional<String> createTotp(final String strKey) {
        try {
            return Optional.of(createTotp(decodeKeyFromString(strKey), Instant.now()));
        } catch (InvalidKeyException e) {
            return Optional.empty();
        }
    }

    /**
     * 驗證 TOTP
     *
     * @param code 要驗證的 TOTP
     * @return 是否一致
     * @throws InvalidKeyException 非法 Key 拋出異常
     */
    public boolean validateTotp(final Key key, final String code) throws InvalidKeyException {
        Instant now = Instant.now();
        return createTotp(key, now).equals(code);
    }

    public Key generateKey() {
        return keyGenerator.generateKey();
    }

    public String encodeKeyToString(Key key) {
        return Base64.getEncoder().encodeToString(key.getEncoded());
    }

    public String encodeKeyToString() {
        return encodeKeyToString(generateKey());
    }

    public Key decodeKeyFromString(String strKey) {
        return new SecretKeySpec(Base64.getDecoder().decode(strKey), totp.getAlgorithm());
    }

    public long getTimeStepInLong() {
        return TIME_STEP;
    }

    public Duration getTimeStep() {
        return totp.getTimeStep();
    }
}

寫個main方法調用一下

public static void main(String[] args) {
        TotpUtil util = new TotpUtil();
        String key = "vVgYlufzmEn0yJwDWYjEmyI6tY1UitlywKbOv8nM1nLioDzZEFCedK8+g1YwsDsA0n9vLC/4skzJE6EYQBSIXw==";
        try {
            String code = util.createTotp(util.decodeKeyFromString(key), Instant.now());
            System.out.println(code);
            System.out.println(util.validateTotp(util.decodeKeyFromString(key),code));
        } catch (InvalidKeyException e) {
            e.printStackTrace();
        }
    }
>>
606187
true

需要注意的是,這種驗證碼的key和JWT的key是不相同的。

JWT的key是整個系統共用的,而驗證碼的key應該是基於用戶的不同而不同。

試想一下,如果整個系統共用一個驗證碼key,整個系統在同一段內時間發布的驗證碼都會一樣,會帶了很大的安全隱患。

多因子用戶認證邏輯

  1. 用戶登陸,根據User表中設定的屬性字段,比如usingMfa,來決定是否啟用多因子驗證流程,或者判斷上次用戶登陸的IP與這一次登陸的IP差異較大,則需要進行多因子認證
  2. 在數據庫中調出這個用戶的key,生成TOTP
  3. 服務端這個時候其實應該返回一個認證失敗的響應,因為如果用戶需要進行多因子認證,那么用戶名密碼登陸成功,只是認證中一個字環節成功,整個認證過程還沒有成功。為了區分和用戶名密碼認證失敗的響應區分開,我們在相應頭中加入X-Authenticate:mfa,reamlm=請求id,這個根據業務需求定義
  4. 客戶端判斷響應頭是否有X-Authenticate:mfa,reamlm=請求id,根據用戶的選擇決定是短信發送還是電子郵件發送,進入不同的頁面去進行短信或者電子郵箱驗證
  5. 用戶進行多因子驗證,完成認證,服務端生成JTW給客戶端

多因子認證的邏輯已經有了,

關於怎么發短信和郵件,這里不多做闡述

主要邏輯代碼:

/**
 * Jwt認證過濾器
 * @author 硝酸銅
 * @date 2021/7/1
 */
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

...
    /**
     * 認證成功邏輯
     */
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {

        //判斷 是否需要多因子認證,如果是,則將用戶信息放入緩存中,返回id
        User user = userService.getByUsername(authResult.getName());
        if(user.isUsingMfa()){

            //將用戶信息存到緩存
            Integer cacheId = cacheService.cache(user);
            try {
                //登錄成功時,返回json格式進行提示
                response.setContentType("application/json;charset=utf-8");
                response.setStatus(HttpServletResponse.SC_OK);
                //為了區分用戶名和密碼認證失敗的響應區分開,添加響應頭
                response.addHeader("X-Authenticate","mfa,reamlm=" + cacheId);

                PrintWriter out = response.getWriter();
                Map<String, Object> map = new HashMap<>();
                map.put("code", HttpServletResponse.SC_UNAUTHORIZED);
                map.put("message", "賬號密碼認證成功,請進行下一步認證");
                out.write(new ObjectMapper().writeValueAsString(map));
                out.flush();
                out.close();
            } catch (Exception e1) {
                e1.printStackTrace();
            }
            return;
        }

        //如果不需要,則直接返回token
        //令牌私鑰
        PrivateKey accessPrivateKey = null;
        PrivateKey refreshPrivateKey = null;
       ...
    }
  ...
}

怎么緩存看業務需求,微服務的話使用redis,如果是體量小的單體應用的話可以考慮使用caffeine,這里就不做闡述了。

接下來的邏輯就簡單了,前端判斷請求頭,判斷賬號密碼認證成功,然后調用多因子認證的接口,參數傳入是短信還是郵箱。服務端接到請求后,根據傳入的參數,從緩存中拿出用戶信息,然后根據用戶的totp key,去生成一個totp驗證碼,發送出去。

用戶從郵件或者短信里面輸入驗證碼,前端調用驗證接口。服務器接到請求,通過id從緩存中拿用戶信息,然后進行驗證(驗證方式是驗證入參驗證碼和使用用戶key生成的totp驗證碼是否一樣,在設置時間內,同一個key生成的驗證碼是相同的),如果相同,則返回token,不相同則返回報錯


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM