﻿# 使用 .NET 實作 TOTP (Time-based One-Time Password) 的注意事項

前陣子在研究 [TOTP](https://datatracker.ietf.org/doc/html/rfc6238) (Time-based One-Time Password) 的實作方式，發現還蠻容易的，這篇文章我就來記錄一下實作的注意事項。

![OTP](https://stwillblogassets.blob.core.windows.net/files/images/external/stwillblogassets.blob.core.windows.net/67c89f101af1b93dccd5-8cb82714-1d2d-4aee-9159-1da022e2561a.webp)

### TOTP 基本原理

`TOTP` 的全名為 `Time-based One-Time Password`，一種基於時間的一次性密碼演算法，該演算法已經成為 [RFC 6238](https://datatracker.ietf.org/doc/html/rfc6238) 國際標準，常用來做為雙因子驗證 (2FA) 的其中一種方式。

`TOTP` 結合一個**私鑰**與**當前時間戳**，使用一個**密碼雜湊函數**來產生**一次性密碼**。由於**網路延遲**與**時鐘不同步**可能導緻密碼接收者不得不嘗試多次遇到正確的時間來進行身分驗證，時間戳通常以 `30` 秒為間隔，從而避免反覆嘗試。

> 稍微完整一點的介紹請參見 [基於時間的一次性密碼演算法](https://zh.wikipedia.org/wiki/%E5%9F%BA%E4%BA%8E%E6%97%B6%E9%97%B4%E7%9A%84%E4%B8%80%E6%AC%A1%E6%80%A7%E5%AF%86%E7%A0%81%E7%AE%97%E6%B3%95) 維基百科文件！

所以要完成 `TOTP` 實作，大致的流程如下：

1.  先由伺服器產生一組隨機的密鑰 (Secret Key)
2.  將此密鑰產生一組 [Key Uri Format](https://github.com/google/google-authenticator/wiki/Key-Uri-Format) 網址
3.  伺服器將此 `Key Uri` 網址輸出成 QR Code 讓使用者透過 `Microsoft Authenticator` 或 `Google Authenticator` 掃描加入
4.  使用者透過 `Microsoft Authenticator` 或 `Google Authenticator` 查看顯示的 6 位數字
5.  使用者輸入 6 位數字的一次性密碼並傳給伺服器，伺服器驗證使用者輸入的 6 位數字是否正確

### 基本綁定流程

1.  在 .NET 專案中加入 [Otp.NET](https://www.nuget.org/packages/Otp.NET) 與 [QRCoder](https://www.nuget.org/packages/QRCoder) 兩個 NuGet 套件
    
    ```bash
    dotnet add package Otp.NET
    dotnet add package QRCoder
    ```
    
2.  伺服器產生一組隨機的密鑰 (Secret Key)
    
    預設使用 `SHA1` 演算法產生一組 20 bytes 的密鑰
    
    ```csharp
    var secret = Base32Encoding.ToString(OtpNet.KeyGeneration.GenerateRandomKey());
    ```
    
3.  伺服器將此密鑰產生一組 Key Uri 網址
    
    Key Uri 網址格式如下：(參考 [Key Uri Format](https://github.com/google/google-authenticator/wiki/Key-Uri-Format) 文件)
    
    ```
    otpauth://TYPE/LABEL?PARAMETERS
    ```
    
    由於我們會用到 `Otp.NET` 套件中的 `OtpUri` 類別來產生 Key Uri 網址：
    
    ```csharp
    var issuer = "Duotify"; // 顯示在 APP 中的發行者名稱
    var label = "user@example.com"; // 顯示在 APP 中的標題
    var keyUri = new OtpUri(OtpType.Totp, secret, label, issuer).ToString()
    ```
    
    > 若 `issuer` 設定為中文，在 Google Authenticator 之中有可能會出現 URLEncode 過的結果，但在 Microsoft Authenticator 通常沒問題。
    
    輸出的網址大概長這樣：
    
    ```
    otpauth://totp/Duotify:user%40example.com?secret=AAEAOCIW3JNIWSYMA2GOXB7PSORV3I5D&issuer=Duotify&algorithm=SHA1&digits=6&period=30
    ```
    
    > 這裡有許多可以客製化的地方，例如 `period` 可以決定每個 time step window 要多久(預設為 30 秒)，還有 `digits` 可以設定 OTP 的位數(預設為 6 位數，你也可以設定成 8 位)，其中 `algorithm` 則可以換成強度更高的 SHA256 或其他 SHA 演算法。
    
4.  伺服器將此 Key Uri 網址輸出成 QRCode 讓使用者掃描
    
    ```csharp
    byte[] image = PngByteQRCodeHelper.GetQRCode(keyUri, QRCodeGenerator.ECCLevel.Q, 10);
    ```
    
    ![QRCode](https://stwillblogassets.blob.core.windows.net/files/images/external/stwillblogassets.blob.core.windows.net/1b0e9fdef4a93d49c1f5-a1ebbdb7-9131-4e30-9622-74aaeedff784.webp)
    
5.  使用者透過 `Microsoft Authenticator` 或 `Google Authenticator` 等 APP 掃描綁定
    
    ![Google Authenticator](https://stwillblogassets.blob.core.windows.net/files/images/external/stwillblogassets.blob.core.windows.net/537c10f99487c2a9ca10-654e0654-ee9e-45e7-96f5-97da7dd1883b.webp)
    

### 基本驗證流程

1.  使用者透過 `Microsoft Authenticator` 或 `Google Authenticator` 查看顯示的 6 位數字
    
2.  伺服器透過當初的密鑰建立 `Totp` 物件實例，並驗證使用者輸入的 6 位數字是否正確
    
    這裡的注意事項就很多，請注意我的程式碼註解部分：
    
    ```csharp
    var userInput = "Your 6 digits TOTP password";
    
    Totp totpInstance = new Totp(Base32Encoding.ToBytes(secret));
    
    // otpVerification 用來驗證 OTP 的「一次性」
    var otpVerification = new Dictionary<long, bool>();
    
    // 用來設定 OTP 的延長時間 (可以延長幾次) (每次就是一個 time step window)
    var window = VerificationWindow.RfcSpecifiedNetworkDelay;
    
    long timeStepMatched;
    if (totpInstance.VerifyTotp(userInput, out timeStepMatched, window))
    {
        // 我們應該對使用者輸入的 OTP 進行記錄，避免重複使用，確保一次性密碼的「一次性」
        if (otpVerification.ContainsKey(timeStepMatched))
        {
            Console.WriteLine($"您輸入的 OTP 已經使用過，無法再次使用！");
        }
        else
        {
            // 我們應該對使用者輸入的 OTP 進行記錄，避免重複使用，確保一次性密碼的「一次性」
            otpVerification.Add(timeStepMatched, true);
    
            // 在同一個 time step window 下，在你驗證成功後會寫入一個 timeStepMatched 數值
            // 該數值包含一個當次 time step window 的固定數值，你可以透過這個數值判斷是否 OTP 已經驗證過
            Console.WriteLine($"驗證通過");
        }
    }
    else
    {
        Console.WriteLine($"驗證失敗");
    }
    ```
    

### 其他安全考量

1.  使用 TOTP 是有可能受到暴力破解的，所以應該要限制使用者輸入錯誤的次數，並且在輸入錯誤的次數達到上限時，暫時鎖定使用者帳號，避免被暴力破解。
    
    畢竟在 30 秒內輸入 999,999 (六位數) 次驗證，以現在的電腦來說，也不是什麼難事。
    
2.  伺服器在驗證使用者輸入的 OTP 時，當輸入正確時，應該在未來的 60 秒內避免該 OTP 再次被使用，以免使用者在這段時間內被進行重送攻擊(replay attack)。
    
    如果你在驗證成功後，沒有對使用者輸入的 OTP 進行記錄，那麼使用者在這段時間內，站在使用者旁邊的人就很有機會在 30 秒內透過手機輸入一樣的 OTP 代碼通過驗證。
    
3.  由於伺服器產生的 Secret Key (密鑰) 非常重要，應盡可能做到保密，避免意外外流。
    
    例如：不要將 Secret Key 寫在程式碼中，或是不要將 Secret Key 寫在網頁 HTML 中，或是不要透過 HTTP GET 傳遞 Secret Key (因為會被寫入到伺服器記錄檔中)，或是不要將 Secret Key 寫在註解中，任何不可靠的儲存媒體都應該避免。
    

### 相關連結

-   [RFC 6238 - TOTP: Time-Based One-Time Password Algorithm](https://datatracker.ietf.org/doc/html/rfc6238)
-   [基於時間的一次性密碼演算法 - 維基百科，自由的百科全書](https://zh.wikipedia.org/wiki/%E5%9F%BA%E4%BA%8E%E6%97%B6%E9%97%B4%E7%9A%84%E4%B8%80%E6%AC%A1%E6%80%A7%E5%AF%86%E7%A0%81%E7%AE%97%E6%B3%95)
-   [下載並安裝 Microsoft Authenticator 應用程式 - Microsoft 支援服務](https://support.microsoft.com/zh-tw/account-billing/%E4%B8%8B%E8%BC%89%E4%B8%A6%E5%AE%89%E8%A3%9D-microsoft-authenticator-%E6%87%89%E7%94%A8%E7%A8%8B%E5%BC%8F-351498fc-850a-45da-b7b6-27e523b8702a?WT.mc_id=DT-MVP-4015686)
-   [Google Authenticator OpenSource](https://github.com/google/google-authenticator)
    -   [Key Uri Format · google/google-authenticator Wiki](https://github.com/google/google-authenticator/wiki/Key-Uri-Format)
-   [kspearrin/Otp.NET](https://github.com/kspearrin/Otp.NET): A .NET implementation of TOTP and HOTP for things like two-factor authentication codes.
-   [codebude/QRCoder](https://github.com/codebude/QRCoder/): A pure C# Open Source QR Code implementation
