這年頭,多重要素驗證(Mutli-Factor Authentication, MFA)幾乎已成系統安全管控的基本要求,除了帳號密碼之外,使用者必須再提供手機簡訊、身份驗證 App、指紋/瞳孔/臉部識別、IC 卡、USB Key... 等第二項驗證資訊才算登入成功,相較過去密碼一旦外流就被整碗端走,多一層驗證就是多一分保障。

這麼多方法中,驗證器 App 應是最易取得成本最低的選項,不像 IC 卡或 USB Key 需採購硬體設備,指紋或臉部識別需要手機或筆電硬體支援,發送簡訊要串第三方服務且有簡訊費用,使用者只要有手機或平板,裝個 Authenticator App 就能在密碼之外多加一道關卡,安全性大增。

在手機 App 商店搜尋 Authenticator 關鍵字,可以找到許多驗證器 App,Google 跟微軟也有推出自己的版本,除了整合自家登入系統也支援 TOTP (Time-based One-Time Password) 這類以時間為基準的一次性密碼,TOTP 為業界公開標準,只要 App 依循標準實作就可以用,但微軟或 Google 的 App 還是較易獲得一般人的信賴,自然是首選,本篇文章也會以這兩個 App 示範。

開始前,先簡單介紹網站或系統整合 TOTP 當成第二重驗證的流程:

  1. 使用者先以帳號密碼登入系統,選擇啟用一次性密碼作為第二重認證,系統隨機產生一組專屬 Secret (一串 20 個位元組的資料),並加上系統名稱(Issuer)、使用者識別資訊(Label)產生 QR Code。
  2. Authenticator App 掃瞄該 QR Code 取得 Secret,之後便能依據該 Secret 每 30 秒產生一組數字密碼,每組密碼有效期間 30 秒。
    補充:依據 TOTP 演算邏輯,相同的 Secret 在某年某月某日某個時間的密碼值也會相同,因此只要時間準確且演算法一致,不管哪一支手機、哪個 App、哪台伺服器,算出來的密碼都應該是相同的。
  3. 使用者下次登入時,除了輸入帳號密碼,還需打開 Authenticator App 查詢當時的一次性密碼,伺服器依當下時間推算對映的密碼值加以比對,若不一致就拒絕登入,以達到雙重認證效果。

由以上運作原理,歸納資安重點:

  1. TOTP Secret 需每個人不同,系統應妥善保存及傳輸,嚴防外流,一旦遭竊第二重驗證將形同虛設。
  2. Authenticator App 必須儲存 TOTP Secret,也是關鍵資料外流的來源之一,這也是為什麼 Authenticator App 百百種,優先選微軟或 Google 的理由。
  3. Secret 唯一的一次傳輸只會發生掃 QR Code 時,不像一般帳號的密碼每次登入都會使用,甚至怕忘記寫在紙上,加上使用者不知道 Secret 內容,外流機率比一般密碼低很多。也因此處理 QR Code 就得格外小心,建議由程式在記憶體即時產生顯示,用完就消失,不宜用 Email 傳送也不要殘留檔案備份會比較安全。
  4. Authenticator App 可能故障或被刪除、手機可能遺失或重灌,除提醒使用者使用 App 功能備份外,也要預想備援的認證方式。

講完原理,到了令人興奮的實際操作時間。

下面照片中,左邊是微軟的 Authenticator,右邊是 Google 的,都有掃瞄 QR Code 的選項;

接下來我會用 .NET 產生 TOTP Secret 並轉成 QR Code,匯入微軟 Authenticator 後測試一次性密碼驗證,若整套流程能順利運作,將來即可搬進網站,為我們的系統加上多重要素驗證。

程式會用到兩個 NuGet 套件 - Otp.NETQRCoder,不囉嗦,直接上程式:

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Drawing;
using System.IO;
using OtpNet;
using QRCoder;

namespace MSAuthenticatorTest
{
    class Program
    {
        static void Main(string[] args)
        {
            //產生一組 Secret
            var secret = KeyGeneration.GenerateRandomKey();
            var sd = new SecretData
            {
                Issuer = "Darkthread",
                Label = "TOTP測試",
                Secret = Base32Encoding.ToString(secret)
            };
            //產生 QRCode
            //補充說明:此處為求簡便寫成暫存檔以瀏覽器開啟,實際應用時宜全程於記憶體處理資料不落地
            //並於網頁顯示完即銷毁,勿以 Email 或其他方傳遞,以降低外流風險
            var qrCodeImgFile = System.IO.Path.GetTempFileName() + ".png";
            sd.GenQRCode().Save(qrCodeImgFile, System.Drawing.Imaging.ImageFormat.Png);
            //使用預設圖片檢視軟體開啟
            var p = Process.Start(new ProcessStartInfo()
            {
                FileName = $"file:///{qrCodeImgFile}",
                UseShellExecute = true
            });
            while (true)
            {
                Console.WriteLine("輸入一次性密碼進行驗證,或直接按 Enter 結束");
                var pwd = Console.ReadLine();
                if (string.IsNullOrEmpty(pwd)) break;
                Console.WriteLine(" " + sd.ValidateTotp(pwd));
            }
        }

        public class SecretData
        {
            public string Issuer { get; set; }
            public string Label { get; set; }
            public string Secret { get; set; }
            public string GenQRCodeUrl() =>
                $"otpauth://totp/{Label}?issuer={Uri.EscapeDataString(Issuer)}&secret={Uri.EscapeDataString(Secret)}";

            public Bitmap GenQRCode()
            {
                var qrcg = new QRCodeGenerator();
                var data = qrcg.CreateQrCode(GenQRCodeUrl(), QRCodeGenerator.ECCLevel.Q);
                var qrc = new QRCode(data);
                return qrc.GetGraphic(20);
            }

            Totp totpInstance = null;
            public string ValidateTotp(string totp)
            {
                if (totpInstance == null)
                {
                    totpInstance = new Totp(Base32Encoding.ToBytes(this.Secret));
                }
                long timedWindowUsed;
                if (totpInstance.VerifyTotp(totp, out timedWindowUsed))
                {
                    return $"驗證通過 - {timedWindowUsed}";
                }
                else
                {
                    return "驗證失敗";
                }
            }
        }
    }
}

在不到 80 行的程式裡,我宣告了一個 SecretData 型別封裝 TOTP 相關邏輯,一開始用 KeyGeneration.GenerateRandomKey() 產生隨機 TOPT Secret,經過 Base32 編碼串接成一個特殊 URL otpauth://totp/label_name?issuer=issuer_name&secret=Base32編碼內容,這個 URL 可轉成如下 QR Code:

用微軟及 Google Authenticator 掃瞄這個 QR Code,兩個 App 會保存這組 TOTP Secret 並每 30 秒產生一組密碼,如前面所說,Secret 相同,密碼就會相同:

程式端驗證時,用 Secret 當參數建立 Totp 物件,呼叫 VerifyTotp() 可驗證密碼是否有效。VerifyTotp() 還有一個 out timedWindowUsed 參數,會標註此密碼所對映的 30 秒區間,如果要限定每個密碼只能使用一次,可在驗證完成後記下這筆 timedWindowUsed,如發現它被用來驗證第二次時予以拒絕。

就醬,下回老闆客戶嫌棄網站只用密碼管控不安全,要你學學微軟 Google 加上多重因素又不想多花半毛錢,知道該怎麼做了吧?除了遞辭呈,也可以挑戰寫幾行程式為網站增加一次式密碼驗證,讓系統安全提升一個檔次。

Tutorial of how to use Microsoft Authenticator app to add Time-based One-Time Password authenctation to implement MFA on your system.


Comments

# by Ms

參數digits與period都無作用?

# by Cojad

我也有一個30行 php 版本的 Google OTP實作, 歡迎參考使用

# by Cojad

我也有一個30行 php 版本的 Google OTP實作, 歡迎參考使用 https://gist.github.com/Cojad/71d1c9fc835eb225b075cb1a9cd6a780

# by Jeffrey

to Cojad, 好簡潔! 感謝分享。

# by Anderson

請問為什麼這一個範例產生的QR CODE在IOS上用微軟 Authenticator掃描要加入帳戶會發生QR CODE錯誤, 但是在ANDROID上就不會有問題

# by Jeffrey

to Anderson, iOS MS Authenticator 是只有掃這個範例的 QRCode 有問題還是所有 QRCode 都不行?用 Github 當對照組試試 https://docs.github.com/cn/authentication/securing-your-account-with-two-factor-authentication-2fa/configuring-two-factor-authentication

# by Dylan

iOS MS Authenticator 掃描時會發生錯誤是因為範例中的Label字串中有中文, Uri.EscapeDataString( Label )後再次掃描是可以正常使用。

# by 里克威斯特

發現錯字, 倒數第四行, 不是 VeriftyTotp() 而是 VerifyTotp()

# by Jeffrey

to 里克威斯特, 謝,已修正。

Post a comment