iT邦幫忙

2025 iThome 鐵人賽

DAY 26
0

HMAC Auth - Client的實作

故事

Aries: kongHMAC的設定上看起來也不算複雜,但是這個clock_skew設定多久才是比較好的呢?

Sam: 預設是300秒鐘,其實這沒有一定對的答案,需要透過設定之後,透過一段時間的觀察來確認。如果這次的來源對象是固定的,那問題可能比較好解決,只要跟對方確認伺服器是不是都有固定與NTP Server持續同步時間即可。

Lala: 那這樣300秒是不是看起來有點太多?有沒有建議值呢?

Sam: 如果我沒記錯,像 Windows AD 使用的Kerberos 認證,容許值就是300秒。但一般來說我相信60秒就夠了。不過還是要視對象,如果對象是多數不確定來源,例如APP會被裝在非常多不確定的來源裝置,這時候容許值可能就要提高一些。

Aries: 但現在手機大多數不是都會自動校時?這還是必要的嗎?

Sam: 你說的對,其實理論上不需要調高,但如果這件事情涉及到了廣大客戶,那就有可能要與維運人員時常的確認,有哪些客戶可能因為這個認證方式而被擋下來。我聽說過有些人為了讓自己提早20分鐘起床,那他的手機時鐘可能就會刻意調整早20分鐘。這時就可以確認客戶一定會登入失敗。因此,建議是稍微先放寬,並把這些因為這因素被擋下來的客戶造冊,提供給第一線客服人員了解這個狀況,並主動說明。這樣資訊單位的我們才不會變成箭靶。

Lala: 原來如此,既然都已經設定好了,那我們是不是要跟客戶,要如何透過HMAC的方式來呼叫我們的新產品呢?

Sam: 沒錯,我已經準備好範例程式了,讓我來跟你們簡單說明。

Client http request 的說明

https://ithelp.ithome.com.tw/upload/images/20250929/201628003fSPzamSHT.png
圖26-1 隨時間變化的 header

首先筆者先用圖26-1來說明 client端的請求標頭(request header),要注意到hmac-auth這個驗證方式,僅支援設定在authorization 以及proxy-authorization上。一方面這兩個 header在預設下,都會被 kong識別為不該被留在 log中的機敏資訊,因此在Kibana中就不會看到敏感資料。

另外,在 http 中,絕大多數認證授權的欄位都會被放在authorization中,因此如果有欄位搶奪的問題,還可以轉用proxy-authorization作為替代。

補充參考資料:Kong Plugin Hub - HMAC-Auth中有提及, The plugin validates the digital signature sent in the Proxy-Authorization or Authorization header (in that order).

從圖26-1 就可以看出,每次不同的時間,Authorization中的signature會不斷的改變。這表示在這一刻即使Authorization的內容被拿走,在下一秒鐘就又會變化,讓盜取者無從使用起。

Client 的實作

接下來的Client端範例程式,筆者是透過過去曾經寫過的一個 open source project來說明。如果讀者有興趣,可以下載來跟筆者一起體驗。

山姆大叔的 KongSecurityResearch Project 連結

class Program
{
        // 全域參數
    private static readonly string APPID = "2025_customer-user1";  //Consumer 在 hmacauth_credentials中的username
    private static readonly string APPKey = "4MfcLIDVuxfH67gwuANeFiUTineOHHVs"; //Consumer 在 hmacauth_credentials中的secret
    private static readonly string APIUrl = "http://localhost:8000/FinancalHMAC/2025-09-11";  //本次示範的API rul,Get

    static void Main(string[] args)
    {
        // 呼叫 API 範例
        CallApiWithCurrentTime();
    }

    static void CallApiWithCurrentTime()
    {
        // 取得當下 UTC 時間
        string xdate = DateTime.Now.ToUniversalTime().ToString("r");
        string sAuth = GenerateAuthorizationHeader(xdate);

        // 印出 Authorization 和 x-date
        Console.WriteLine("Authorization: " + sAuth);
        Console.WriteLine("x-date: " + xdate);

        string Result = CallApi(xdate, sAuth);
        Console.WriteLine(Result);
    }

    static string GenerateAuthorizationHeader(string xdate)
    {
        string SignDate = "x-date: " + xdate;
        string Signature = HMAC_SHA256.Signature(SignDate, APPKey);
        return $"hmac username=\"{APPID}\", algorithm=\"hmac-sha256\", headers=\"x-date\", signature=\"{Signature}\"";
    }


    static string CallApi(string xdate, string sAuth)
    {
        string Result = string.Empty;

        ServicePointManager.Expect100Continue = true;
        ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls13;
        using (HttpClient Client = new HttpClient(new HttpClientHandler { AutomaticDecompression = DecompressionMethods.GZip }))
        {
            Client.DefaultRequestHeaders.Add("Authorization", sAuth);
            Client.DefaultRequestHeaders.Add("x-date", xdate);

            try
            {
                var response = Client.GetAsync(APIUrl).Result;
                Console.WriteLine("Status Code: " + (int)response.StatusCode); // 印出數字形式的狀態碼
                Result = response.Content.ReadAsStringAsync().Result;
                Console.WriteLine("API 呼叫成功:");
            }
            catch (Exception ex)
            {
                Console.WriteLine("API 呼叫失敗 (可能因為時間逾時):");
                Result = ex.Message;
            }
        }

        return Result;
    }
}

public class HMAC_SHA256 
{
    public static string Signature(string xDate, string AppKey, string inputCharset = "utf-8")
    {
        Encoding _encode = Encoding.GetEncoding(inputCharset);
        byte[] _byteData = Encoding.GetEncoding(inputCharset).GetBytes(xDate);
        HMACSHA256 _hmac = new HMACSHA256(_encode.GetBytes(AppKey)); // 使用 HMACSHA256

        using (CryptoStream _cs = new CryptoStream(Stream.Null, _hmac, CryptoStreamMode.Write))
        {
            _cs.Write(_byteData, 0, _byteData.Length);
        }
        return Convert.ToBase64String(_hmac.Hash);
    }
}
        

程式碼範例說明如下:

  • 全域變數設定
    程式最開始定義了三個全域變數:APPID(用戶名稱)、APPKey(密鑰)、APIUrl(API 端點)。要注意,這些全域變數分別對應到伺服器端的 hmacauth_credentials 設定。
  • 主流程
    Main 方法會呼叫 CallApiWithCurrentTime(),這個方法負責產生當下 UTC 時間(x-date),並用這個時間戳記搭配密鑰產生 HMAC 簽章,最後組成 Authorization header。
  • 產生 Authorization Header
    透過 GenerateAuthorizationHeader 方法呼叫 HMAC_SHA256.Signature 方法,將 x-date 與 APPKey 經過 HMAC-SHA256 運算後,產生一組 base64 編碼的簽章,並組合成 Authorization header 格式。
  • 發送 API 請求
    CallApi 方法會建立一個 HttpClient,並在 request header 中加入 Authorization 和 x-date。這兩個欄位是 HMAC 認證的關鍵,伺服器端會用相同邏輯驗證簽章是否正確,以及時間是否在允許範圍內(防止重放攻擊)。
  • HMAC_SHA256 實作
    HMAC_SHA256.Signature 方法用 HMACSHA256 演算法,將 x-date 作為訊息、APPKey 作為密鑰,計算出雜湊值並轉成 base64 字串,確保每次請求的簽章都隨時間變化。

上面的實作就代表著,Client每次發動請求都會根據當下時間產生不同的簽章。而伺服器端收到請求後,會用相同方式驗證簽章與時間,這種方式可以有效的防止被中間人竊取之後的重放攻擊。接下來實際執行,並透過可觀測性的trace以及log來確認,是不是實作成功。

Client 端的測試

如果你也要跟著筆者一起測試,記得最上面的全域變數一定要跟著改喔!

  • APPID=kong.hmacauth_credentials.username
  • APPKey=kong.hmacauth_credentials.hmacauth_credentials中的secret
  • APIUrl=這次示範的url

將全域變數變更後,請到專案目錄下,執行dotnet run

https://ithelp.ithome.com.tw/upload/images/20250929/20162800g9XUsSPN8R.png
圖26-2 Client端的測試

示範程式會得到兩個結果,當初筆者是為了測試兩個案例。第一個是為了驗證正確的認證資訊,因此會得到http 200的呼叫成功回應。第二個則是刻意模擬,如果裝置(APP)時間是偏差的狀態,因此刻意送出逾時4分鐘的時間戳記,得到了預期的認證失敗http 401的回應。

https://ithelp.ithome.com.tw/upload/images/20250929/20162800NWiaSHrico.png
圖26-3 log的紀錄

打開kibana的紀錄,可以看到跟示範程式實驗的結果一致,首先先是第一次401錯誤,接著第二次就是 200的正確回應。圖26-3 還有一個細節,箭頭的地方指出hmacauth_credentials.username。Day 25在設定kong的時候有說到,consumer在設定 hmacauth_credentials的時候,是一個list的型別。

這表示一個consumer可以同時存在多種認證資訊(apikey,hmacauth_credentials,jwt)外,也可以同時存在多組hmacauth_credentials。讓我們回頭去思考一下這次客戶的需求內容(註:為確保本行資訊安全,所提供之服務須經安全性連線外,認證授權機制之機敏資訊需可供定期輪替,並確保逾時後授權失效之機制....),是不是有提到可以定期輪替?

通常維運最大的困擾就是,雖說雙方已經約定好例如在 9/22 8:00要完成年度輪替金鑰的作業,但如果仰賴雙方人工作業後雙方核對,偶爾總是會發生服務暫時無法使用(有可能誰早做了或是晚做了)。而kong在這種可以允許存放多組credentials,並可透過kibana辨識攜帶哪一組進行請求的特性,可以讓維運人員先同時存在兩組credentials,並藉由觀察kibana確認consumer是否已輪替金鑰,確保輪替行為完成後,再將舊的credentials刪除。

這表示可以讓雙方不會因為人工作業的失誤造成服務中斷,維運的風險可以降低,真是一舉兩得。

https://ithelp.ithome.com.tw/upload/images/20250929/20162800ZwPw2ZMMQs.png
圖26-4 從Jaeger 的角度來看kong.hmac_auth

最後,筆者其實常常遇到一個困擾,特別是當serviceroute越設定越多的時候,常常會發生混亂。到底某個請求進來,是經過了那些kongplugin的邏輯呢?

這時就可以從圖26-4 來看,Jaeger可以告訴我們答案。可以看到這次的請求中,hmac_auth的設定的確有在trace中出現。表示設定的確生效了~

小結

Lala: 有程式可以幫忙驗證設定是否成功,實在是幫了大忙了。

Sam: 那當然,如果只是透過文件告訴合作對象要如何撰寫程式,其實比不上提供範例程式,而且是驗證設定完成的結果給對方確認還來的直觀。

Aries: 那萬一對方不是用 dotnet,會不會反過來跟我們索取例如python的範例程式呢?

Sam: 我認為這倒不是問題,其實完善整個維運體系,本來就應該要持續累積各種範例,讓合作可以順利進行下去。我們撰寫了範例程式,不論是幾種版本,通常應該就要被累積在內部的知識庫或是repo中,並寫清楚readme.md讓未來的自己或是同伴可以理解,這才是比較好的做法。

Aries: 這樣說也是,未來據說這個API也可能會被用在android APP上,那我跟github copilit來一起撰寫一個java版本的範例好了!

Sam: 那就麻煩你啦~

註:在Java的版本中,筆者與同事其實還有踩到另外一個雷,那是關於Java版本對於GMT format 協議的理解似乎不太一樣,因此在取得時間格式時同事一直沒辦法搞定。

當初是透過grafana中的x-date欄位,確定Java輸出格式的確不同於dotnet,才找到workaround的方式。這部分筆者就不在系列文中贅述,留給讀者們如果遇到這場景時,可以根據筆者提供的線索探索看看。
https://ithelp.ithome.com.tw/upload/images/20250929/20162800RC0KOWZtgx.png
圖26-5 關注x-Date欄位的GMT


上一篇
Day 25 : Kong 的 Routes 共用與 HMAC Auth 實踐 - 1
系列文
解鎖API超能力:我的30天Kong可觀測性與管理實戰之旅26
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言