構建無密碼認證:passkey 入門與 Go 實現

傳統的密碼認證一直以來都是數字時代的主流身份驗證方式。然而,用戶常常選擇易記的弱密碼並重復使用,導致賬號易受攻擊。密碼泄露、釣魚攻擊等安全問題層出不窮,超過 80% 的數據泄露與密碼相關。

與此同時,頻繁的密碼管理和忘記密碼情況嚴重影響用戶體驗。服務商在安全保存用戶密碼方面的責任也增加了系統建設和維護的成本。爲了應對這些問題,科技行業開始積極探索無密碼認證的方法。

無密碼認證利用設備生物識別、硬件加密和其他更安全的驗證手段,提供了更安全的登錄體驗。在 Thoughtworks 最新一期(第 31 期)技術雷達文檔 [3] 中,一種名爲 passkey[4] 的無密碼認證技術被列入 “試驗” 象限,許多讀者可能在 github[5] 或其他支持 passkey 的站點和應用中使用過這一技術了。

Passkey 是 FIDO 聯盟 (Fast IDentity Online)[6] 提出的一種無密碼認證解決方案 [7]。FIDO 聯盟是一個開放的行業協會,其核心使命是減少世界對密碼的依賴。聯盟成員包括衆多知名的科技公司和組織,如 Google、微軟、Apple、Amazon 等,致力於定義一套開放、可擴展、可互操作的機制,以降低用戶和設備身份驗證時對密碼的依賴。

Passkey 是 FIDO 聯盟的首個無密碼身份認證憑據方案,支持用戶通過與解鎖手機、平板或計算機相同的方式(如生物識別 (比如屏幕指紋、面部識別等)、PIN 碼或圖案)登錄應用程序和網站。目前許多主流設備、操作系統原生應用、瀏覽器和站點都支持 passkey 技術 (如下圖),這使得 passkey 技術在未來的無密碼認證認證領域展現出巨大的潛力。

在這篇文章中,我將對 passkey 技術進行入門介紹,並通過 Go 實現一個簡單的示例供大家參考。

  1. passkey 的工作原理

通過上面的介紹,我們大致知道了 passkey 是密碼的替代品,一旦使用了 passkey,我們登錄網站時就無需再輸入密碼,用於網站對你的身份進行驗證的 passkey 存儲在你的設備本地,你頂多只需通過本地設備的生物識別 (比如指紋、人臉或圖案密碼等) 進行一次解鎖即可。

從技術本質來說,paaskey 就是 “免密登錄服務器” 方案在 Web 服務和終端 App 領域的應用。沒錯!passkey 就是基於非對稱加密實現的一種無密碼認證技術。下圖展示了 Bob 這個用戶登錄不同 Web 服務時使用不同 passkey 的情景:

如果你熟悉非對稱加密的運作原理,你就可以立即 get 到 passkey 的工作原理。

注:在《Go 語言精進之路:從新手到高手的編程思想、方法和技巧 [8]》的第 51 條 “使用 net/http 包實現安全通信” 中有對非對稱加密的全面系統講解以及示例說明。如果你不是很熟悉,可以看一下我的這本書中的內容。

以上圖中的 Web Service1 爲例,用戶 Bob 在註冊時會在其自己的設備 (比如電腦) 上創建一對私鑰與公鑰,比如 Bob 的 bob-ws1-private key 和 bob-ws1-public key,私鑰會保存在 Bob 的設備上,而並不需要保密的公鑰則會發送給 Web Service1 保存。之後,Web Service1 對 Bob 進行身份驗證的時候,只需發送一塊數據給 Bob 設備上的應用 (通常是瀏覽器),應用會申請使用 Bob 的私鑰,這個過程可能需要 bob 輸入設備的用戶密碼或使用生物識別(比如指紋)來授權。使用 Bob 的私鑰對這塊數據進行簽名後,發回 Web Service1,後者通過 Bob 保存在服務器上的公鑰對這塊簽名後的數據進行驗籤,驗籤通過,則 Bob 的身份驗證就通過了!當然這只是基本原理,還有很多場景、交互和技術細節,比如支持在網吧等公共計算機上藉助個人的其他設備(比如手機) 進行基於 passkey 的的身份驗證等,這些需要進一步閱讀相關規範。更多原理細節我們也會在接下來的內容中詳細說明。

不過,在進一步瞭解原理之前,我們先來了解一下 paaskey 與 FIDO、webauthn 之間的關係。

FIDO2[9] 是一個開放的認證標準框架,旨在取代傳統密碼認證。它包含 WebAuthn[10](由 W3C 提供的 WebAPI 規範)和 CTAP[11](客戶端到認證器的協議),即客戶端設備和外部認證器的通信標準。FIDO2 的主要目標是增強網絡安全性,支持無需密碼的安全登錄方式。

WebAuthn 是 FIDO2 的 WebAPI 組件,定義了應用如何在網頁上與瀏覽器協作,以支持基於公鑰的認證方式。它允許瀏覽器和 Web 應用訪問用戶設備上的身份驗證器(如指紋傳感器或 USB 密鑰),並進行認證交互。WebAuthn 作爲 Web 標準,得到了大多數現代瀏覽器的支持。

Passkey 是對 FIDO2 標準的應用,以實現無密碼認證。在技術棧上,Passkey 利用 WebAuthn 和 CTAP 來構建實際應用體驗,從而讓用戶在支持 FIDO2 的 Web 應用中享受無密碼登錄的便捷。這三者共同實現了現代無密碼身份認證的完整生態體系。

下面我們通過一個序列圖具體瞭解一下 paaskey 的工作原理:

上圖展示了 Passkey 的工作流程,包括註冊和認證兩個主要流程。

在 passkey(即基於 WebAuthn 的非密碼認證機制)中,有三個主要的實體:

我們先來看看註冊流程

用戶輸入用戶名並觸發註冊流程,瀏覽器向服務器請求註冊選項,服務器生成隨機挑戰(challenge)並創建註冊選項。

瀏覽器調用 WebAuthn API(navigator.credentials.create),操作系統檢查可用的認證器,並根據認證器類型調用相應的系統 API。 認證器請求用戶驗證(如需要),系統根據請求的用戶驗證級別來決定驗證方式。驗證級別包括無需驗證 (none)、隱式驗證(silent,比如設備已解鎖,使用之前的驗證結果) 以及必須驗證(Required)。如果是必須驗證,系統會顯示驗證提示(密碼 / 生物識別 / PIN 等)。

用戶提供身份驗證信息後,認證器會生成新的公私鑰對,並將私鑰安全存儲在認證器中,公鑰和其他憑證數據 (私鑰簽名後的挑戰數據) 返回給瀏覽器。瀏覽器將公鑰和其他憑證發送給服務器,服務器驗證憑證 (通過公鑰驗籤) 並存儲公鑰,註冊完成。

接下來,我們再來看認證流程

當用戶輸入用戶名並觸發登錄後,瀏覽器會向服務器請求認證選項,服務器生成新的挑戰並返回認證選項。

瀏覽器調用 WebAuthn API (navigator.credentials.get),認證器使用私鑰對挑戰進行簽名,並返回簽名和其他斷言數據給瀏覽器。

瀏覽器將斷言發送給服務器,服務器使用存儲的公鑰驗證簽名,認證完成。

我們看到在整個註冊和身份驗證流程中,用戶都無需記憶複雜的密碼,機密信息(比如傳統的密碼)也無需傳遞給服務器保存,而公鑰本身就是隨意公開分發的,服務端甚至都無需對其進行任何加密處理。由此可以看到:passkey 既提供了更好的安全性,又提供了更好的用戶體驗,是傳統密碼認證的理想替代方案之一。

注:使用另一個設備進行身份驗證的流程,大家可以自行閱讀 passkey 相關規範瞭解。

瞭解了原理之後,我們再來看一個簡單的示例,直觀地看看如何實現基於 passkey 的身份認證。

  1. passkey 身份認證示例

我們使用 Go 實現一個最簡單的基於 passkey 進行註冊和身份驗證的示例。在這個示例裏,我們將使用 webauthn 官方推薦的 Go 包 [12]:go-webauthn/webauthn 來實現服務端對 passkey 登錄的支持。

注:本示例的工作環境爲 Go 1.23.0、macOS 和 Edge 瀏覽器。

這個示例的文件佈局如下:

// intro-to-passkey/demo
$tree -F .
.
├── go.mod
├── go.sum
├── main.go
└── static/
    └── index.html

首先我們通過一個靜態文件服務器提供了前端首頁,並註冊了 4 個 API 端點用於處理 Passkey 註冊和認證:

// intro-to-passkey/demo/main.go
func main() {
    // 靜態文件服務
    http.Handle("/", http.FileServer(http.Dir("static")))

    // API 路由
    http.HandleFunc("/api/register/begin", handleBeginRegistration)
    http.HandleFunc("/api/register/finish", handleFinishRegistration)
    http.HandleFunc("/api/login/begin", handleBeginLogin)
    http.HandleFunc("/api/login/finish", handleFinishLogin)

    log.Println("Server running on http://localhost:8080")
    log.Fatal(http.ListenAndServe(":8080", nil))
}

關鍵的 passkey 配置在 init 函數中:

func init() {            
    var err error        
    webAuthn, err = webauthn.New(&webauthn.Config{
        RPDisplayName: "Passkey Demo",                    // Relying Party Display Name
        RPID:          "localhost",                       // Relying Party ID
        RPOrigins:     []string{"http://localhost:8080"}, //允許的源
    })                   
    if err != nil {      
        log.Fatal(err)   
    }                    
    userDB = NewUserDB() // 初始化內存用戶數據庫
}

運行該 go 程序後,打開 localhost:8080,我們將看到下面頁面:

接下來,我們先來註冊一個用戶的 passkey。在註冊輸入框中輸入 "tonybai",點擊 “註冊”,瀏覽器會彈出下面對話框,提醒用戶將爲 localhost 創建密鑰:

點擊 “繼續”,本地 os 會彈出身份驗證對話框:

輸入你的 os 登錄密碼,便可繼續註冊過程。如果註冊 ok,頁面會顯示下面 “註冊成功” 字樣:

在服務器後端,上述的註冊過程是由兩個 handler 共同完成的,這也是 webauthn 規範確定的流程,大家可以結合上面的序列圖一起看。

首先是處理 / api/register/begin 的 handleBeginRegistration,它的大致邏輯如下:

func handleBeginRegistration(w http.ResponseWriter, r *http.Request) {
    // 1. 驗證用戶名是否已存在
    if _, exists := userDB.users[data.Username]; exists {
        http.Error(w, "User already exists", http.StatusBadRequest)
        return
    }

    // 2. 創建新用戶
    user := &User{
        ID:          []byte(data.Username),
        Name:        data.Username,
        DisplayName: data.Username,
    }
    userDB.users[data.Username] = user

    // 3. 生成註冊選項和會話數據
    options, sessionData, err := webAuthn.BeginRegistration(user)
    
    // 4. 存儲會話數據
    sessionID := storeSession(sessionData)
    http.SetCookie(w, &http.Cookie{
        Name:     "registration_session",
        Value:    sessionID,
        Path:     "/",
        MaxAge:   300,
        HttpOnly: true,
    })

    // 5. 返回註冊選項給客戶端
    json.NewEncoder(w).Encode(options)
}

注意:這段代碼中的 session 與傳統 Web 應用中用於跟蹤用戶登錄狀態的 session 不同。這種 session 機制是 WebAuthn 協議的一部分,用於確保認證流程的安全性:

所以這裏的 session 更像是一個 "挑戰 - 響應" 認證過程中的臨時狀態存儲,而不是用來維持用戶登錄狀態的傳統 session。用戶的登錄狀態管理應該是在這個認證系統之上另外實現的,比如使用 JWT token 或傳統的 session 機制。

handleFinishRegistration 用於處理客戶端發到 / api/register/finish 的完成註冊請求,它的邏輯大致如下:

func handleFinishRegistration(w http.ResponseWriter, r *http.Request) {
    // 1. 獲取並驗證會話
    sessionData, ok := getSession(cookie.Value)
    if !ok {
        http.Error(w, "Invalid session", http.StatusBadRequest)
        return
    }

    // 2. 獲取用戶信息
    username := string(sessionData.UserID)
    user := userDB.users[username]

    // 3. 驗證並完成註冊
    credential, err := webAuthn.FinishRegistration(user, *sessionData, r)
    
    // 4. 保存憑證
    userDB.Lock()
    user.Credentials = append(user.Credentials, *credential)
    userDB.Unlock()

    // 5. 清理會話
    delete(sessionStore, cookie.Value)
}

註冊 passkey 後,我們就可以來基於 passkey 進行登錄了!服務端會使用 passkey 對用戶進行身份驗證。

我們在登錄輸入框中輸入 "tonybai",然後點擊 "Passkey 登錄",本地 os 會彈出身份驗證對話框:

輸入 os 登錄密碼後,便可繼續身份驗證過程,如果服務端身份驗證 ok,頁面會顯示下面 “登錄成功” 字樣:

如果在登錄輸入框中輸入一個未曾註冊過的用戶名,則服務器會驗證失敗,頁面會顯示如下錯誤:

和註冊過程一樣,上述的驗證過程也是由兩個 handler 共同完成的,這也是 webauthn 規範確定的流程。

首先是處理 / api/login/begin 的 handleBeginLogin,它的大致邏輯如下:

func handleBeginLogin(w http.ResponseWriter, r *http.Request) {
    // 1. 驗證用戶是否存在
    user, ok := userDB.users[data.Username]
    if !ok {
        http.Error(w, "User not found", http.StatusNotFound)
        return
    }

    // 2. 生成認證選項和會話數據
    options, sessionData, err := webAuthn.BeginLogin(user)
    
    // 3. 存儲會話數據
    sessionID := storeSession(sessionData)
    http.SetCookie(w, &http.Cookie{
        Name:     "login_session",
        Value:    sessionID,
        Path:     "/",
        MaxAge:   300,
        HttpOnly: true,
    })

    // 4. 返回認證選項給客戶端
    json.NewEncoder(w).Encode(options)
}

之後,是 handleFinishLogin 處理的來自客戶端到 / api/login/finish 的請求,以完成登錄流程:

func handleFinishLogin(w http.ResponseWriter, r *http.Request) {
    // 1. 獲取並驗證會話
    sessionData, ok := getSession(cookie.Value)
    if !ok {
        http.Error(w, "Invalid session", http.StatusBadRequest)
        return
    }

    // 2. 獲取用戶信息
    username := string(sessionData.UserID)
    user := userDB.users[username]

    // 3. 驗證並完成登錄
    _, err = webAuthn.FinishLogin(user, *sessionData, r)
    
    // 4. 清理會話
    delete(sessionStore, cookie.Value)
}

我們看到註冊和登錄都採用兩步驗證流程,每個流程都包含開始和完成兩個步驟,同時使用會話保持認證狀態的連續性。

整個示例的前端基本由 js 代碼完成:

<!DOCTYPE html>
<html>
<head>
    <title>Passkey Demo</title>
    <style>
        .container {
            margin: 20px;
            padding: 20px;
            border: 1px solid #ccc;
        }
        .form-group {
            margin: 10px 0;
        }
        #status {
            margin-top: 20px;
            padding: 10px;
        }
        .error {
            color: red;
        }
        .success {
            color: green;
        }
    </style>
</head>
<body>
    <div class="container">
        <h2>註冊</h2>
        <div class="form-group">
            <input type="text" id="registerUsername" placeholder="用戶名">
            <button onclick="register()">註冊 Passkey</button>
        </div>
    </div>

    <div class="container">
        <h2>登錄</h2>
        <div class="form-group">
            <input type="text" id="loginUsername" placeholder="用戶名">
            <button onclick="login()">Passkey 登錄</button>
        </div>
    </div>

    <div id="status"></div>

    <script>
        // 工具函數:將 ArrayBuffer 轉換爲 Base64URL 字符串
        function bufferToBase64URL(buffer) {
            const bytes = new Uint8Array(buffer);
            let str = '';
            for (const byte of bytes) {
                str += String.fromCharCode(byte);
            }
            return btoa(str)
                .replace(/\+/g, '-')
                .replace(/\//g, '_')
                .replace(/=/g, '');
        }

        // 工具函數:將 Base64URL 字符串轉換爲 ArrayBuffer
        function base64URLToBuffer(base64URL) {
            if (!base64URL) {
                throw new Error('Empty base64URL string');
            }
            const base64 = base64URL.replace(/-/g, '+').replace(/_/g, '/');
            const padLen = (4 - (base64.length % 4)) % 4;
            const padded = base64.padEnd(base64.length + padLen, '=');
            const binary = atob(padded);
            const buffer = new ArrayBuffer(binary.length);
            const bytes = new Uint8Array(buffer);
            for (let i = 0; i < binary.length; i++) {
                bytes[i] = binary.charCodeAt(i);
            }
            return buffer;
        }

        function showStatus(message, isError = false) {
            const status = document.getElementById('status');
            status.textContent = message;
            status.className = isError ? 'error' : 'success';
        }

        // 開始註冊
        async function startRegistration(username) {
            try {
                // 1. 從服務器獲取註冊選項
                const response = await fetch('/api/register/begin'{
                    method: 'POST',
                    headers: {
                        'Content-Type''application/json',
                    },
                    body: JSON.stringify({ username }),
                });

                if (!response.ok) {
                    throw new Error(`Server error: ${response.status}`);
                }

                const responseData = await response.json();
                
                // 確保我們使用的是 publicKey 對象
                const options = responseData.publicKey;
                if (!options) {
                    throw new Error('Invalid server response: missing publicKey');
                }

                // 2. 解碼 challenge
                options.challenge = base64URLToBuffer(options.challenge);

                // 3. 解碼 user.id
                if (options.user && options.user.id) {
                    options.user.id = base64URLToBuffer(options.user.id);
                }

                console.log('Processed options:', options); // 調試輸出

                // 4. 創建憑證
                const credential = await navigator.credentials.create({
                    publicKey: options
                });

                // 5. 準備發送到服務器的數據
                const registrationData = {
                    id: credential.id,
                    rawId: bufferToBase64URL(credential.rawId),
                    type: credential.type,
                    response: {
                        attestationObject: bufferToBase64URL(credential.response.attestationObject),
                        clientDataJSON: bufferToBase64URL(credential.response.clientDataJSON)
                    }
                };

                // 6. 發送註冊數據到服務器
                const finishResponse = await fetch('/api/register/finish'{
                    method: 'POST',
                    headers: {
                        'Content-Type''application/json',
                    },
                    body: JSON.stringify(registrationData)
                });

                if (!finishResponse.ok) {
                    throw new Error(`Server error: ${finishResponse.status}`);
                }

                showStatus('註冊成功!');
            } catch (error) {
                console.error('Registration error:', error);
                showStatus(`註冊失敗: ${error.message}`true);
            }
        }

        // 開始登錄
        async function startLogin(username) {
            try {
                // 1. 從服務器獲取登錄選項
                const response = await fetch('/api/login/begin'{
                    method: 'POST',
                    headers: {
                        'Content-Type''application/json',
                    },
                    body: JSON.stringify({ username }),
                });

                if (!response.ok) {
                    throw new Error(`Server error: ${response.status}`);
                }

                const responseData = await response.json();
                const options = responseData.publicKey;
                
                if (!options) {
                    throw new Error('Invalid server response: missing publicKey');
                }

                // 2. 解碼 challenge
                options.challenge = base64URLToBuffer(options.challenge);

                // 3. 解碼 allowCredentials
                if (options.allowCredentials) {
                    options.allowCredentials = options.allowCredentials.map(credential =({
                        ...credential,
                        id: base64URLToBuffer(credential.id),
                    }));
                }

                // 4. 獲取憑證
                const credential = await navigator.credentials.get({
                    publicKey: options
                });

                // 5. 準備發送到服務器的數據
                const loginData = {
                    id: credential.id,
                    rawId: bufferToBase64URL(credential.rawId),
                    type: credential.type,
                    response: {
                        authenticatorData: bufferToBase64URL(credential.response.authenticatorData),
                        clientDataJSON: bufferToBase64URL(credential.response.clientDataJSON),
                        signature: bufferToBase64URL(credential.response.signature),
                        userHandle: credential.response.userHandle ? bufferToBase64URL(credential.response.userHandle) : null
                    }
                };

                // 6. 發送登錄數據到服務器
                const finishResponse = await fetch('/api/login/finish'{
                    method: 'POST',
                    headers: {
                        'Content-Type''application/json',
                    },
                    body: JSON.stringify(loginData)
                });

                if (!finishResponse.ok) {
                    throw new Error(`Server error: ${finishResponse.status}`);
                }

                showStatus('登錄成功!');
            } catch (error) {
                console.error('Login error:', error);
                showStatus(`登錄失敗: ${error.message}`true);
            }
        }

        // 註冊按鈕處理函數
        function register() {
            const username = document.getElementById('registerUsername').value;
            if (!username) {
                showStatus('請輸入用戶名'true);
                return;
            }
            startRegistration(username);
        }

        // 登錄按鈕處理函數
        function login() {
            const username = document.getElementById('loginUsername').value;
            if (!username) {
                showStatus('請輸入用戶名'true);
                return;
            }
            startLogin(username);
        }
    </script>
</body>
</html>

這段代碼沒有使用任何第三方庫或框架,對 js 略知一二的讀者想必也能看個七七八八。

綜上,我們看到這個示例實現提供了完整的 Passkey 認證功能,但需要注意這是一個演示版本。在生產環境中,還需要考慮更多,比如數據的持久化存儲、更完善的錯誤處理等。

  1. 小結

本文粗略探討了無密碼認證技術中的一種新興方案——passkey。隨着傳統密碼認證的安全隱患日益嚴重,passkey 作爲 FIDO 聯盟提出的解決方案,利用生物識別和硬件加密以及非對稱加密等先進技術,爲用戶提供了更安全、便捷的身份驗證體驗。

在文中,我還詳細介紹了 passkey 的工作原理,包括註冊和登錄流程,強調了非對稱加密在身份驗證中的重要作用。此外,通過一個基於 Go 語言的示例,我們展示瞭如何實現 passkey 的註冊和認證功能,幫助讀者更好地理解其實際應用。

整體來看,passkey 不僅提升了安全性,還改善了用戶體驗,是未來無密碼認證的有力候選方案。隨着 passkey 技術的發展,期待更多應用場景的出現,爲用戶帶來更安全的網絡環境。

本文涉及的源碼可以在這裏 [13] 下載。

  1. 參考資料


參考資料

[1] 

主流身份驗證方式: https://tonybai.com/2023/10/23/understand-go-web-authn-by-example

[2] 

安全保存用戶密碼: https://tonybai.com/2023/11/08/understand-go-web-secret-management-by-example

[3] 

Thoughtworks 最新一期(第 31 期)技術雷達文檔: https://www.thoughtworks.com/content/dam/thoughtworks/documents/radar/2024/10/tr_technology_radar_vol_31_en.pdf

[4] 

passkey: https://fidoalliance.org/passkeys/

[5] 

github: https://docs.github.com/en/authentication/authenticating-with-a-passkey/signing-in-with-a-passkey

[6] 

FIDO 聯盟 (Fast IDentity Online): https://fidoalliance.org/overview/

[7] 

無密碼認證解決方案: https://fidoalliance.org/passkeys/

[8] 

Go 語言精進之路:從新手到高手的編程思想、方法和技巧: https://item.jd.com/13694000.html

[9] 

FIDO2: https://fidoalliance.org/specifications-overview/

[10] 

WebAuthn: https://www.w3.org/TR/webauthn-1/

[11] 

CTAP: https://fidoalliance.org/specifications/download/

[12] 

webauthn 官方推薦的 Go 包: https://webauthn.io/

[13] 

這裏: https://github.com/bigwhite/experiments/tree/master/intro-to-passkey

[14] 

passkey.org: https://passkey.org

[15] 

passkeys.dev: https://passkeys.dev

[16] 

webauthn.guide: https://webauthn.guide/

[17] 

FIDO alliance: https://fidoalliance.org/

[18] 

webauthn.io: https://webauthn.io/

[19] 

WebAuthn 規範: https://www.w3.org/TR/webauthn/

[20] 

FIDO2 文檔: https://fidoalliance.org/fido2/


Gopher Daily(Gopher 每日新聞) - https://gopherdaily.tonybai.com

我的聯繫方式:

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/mrXHK6K6J6lmmmiPw3xaGA