Web認證與WebAuthn
編者按:本文作者奇舞團前端開發工程師何文力,同時也是 W3C CSS 工作組成員。
W3C Credential Management API
Credential Management API 是一套給提供給網站用於儲存使用者登陸資訊的 API,簡單地說可以作為一種自動賬號密碼填充的功能:
navigator.credentials.store({
type : 'password' ,
id : 'id' ,
password : '1234567'
} )
下一次登入的時候,我們可以通過上一次儲存的賬號密碼直接登入:
navigator.credentials
. get ( { password : true } )
. then ( credential = > {
if ( ! credential ) return
fetch ( '/login' , {
method : 'POST' ,
body : JSON . stringify ( {
username : credential . id ,
password : credential . password
} ) .
credentials : 'include'
} ) . then ( res = > {
// ....
} )
} )
FIDO2 WebAuthn
那麼,WebAuthn 又是幹嘛的呢?它是 Credential Management API 的公鑰擴充套件, FIDO2 無密碼輸入認證體驗的 web 部分標準 ,同時也是 W3C 的官方標準。接下來就來簡單瞭解一下 WebAuthn。
WebAuthn的驗證流程
完整的認證流程採用了一種 挑戰-應答 (challenge-response)的模式:
-
首先客戶端要求使用者輸入使用者名稱發起挑戰請求
-
隨後伺服器根據使用者名稱對客戶端發起挑戰
-
隨後客戶端進行迴應,如果此時服務端驗證迴應是滿足的,那麼認證成功
WebAuthn 主要分為四個層面:
-
使用者層面:包含輸入使用者名稱,以及生物認證等操作
-
API層面:建立公鑰,產生斷言驗證
-
協議層面:規定挑戰-應答流程,對抗釣魚,對抗重放攻擊等實現
-
硬體層面:在硬體上實現公鑰的產生和斷言驗證的產生的協議: CTAP2
使用 WebAuthn 進行使用者註冊
使用者註冊主要需要如下的步驟:
-
收集使用者的基本資訊,如使用者名稱等(不需要設定密碼)
-
將資訊發給服務端進行驗證(如有沒有被註冊過等)
-
服務端對此向客戶端發起挑戰
-
客戶端生成認證資訊並響應給服務端
-
服務端驗證客戶端的響應,註冊完成
收集資訊併發送給服務端
這個步驟我們只要將資訊收集好傳送給後端進行驗證即可:
axios.post('/auth/register', {
username ,
password
} )
服務端根據這些資訊判斷某個使用者名稱是否已經被註冊過,這些邏輯就不在這裡編寫了
服務端向客戶端發起挑戰
要建立認證資訊,我們需要呼叫 credential 的 create 方法,使用公鑰的方式( publicKey )進行生成, publicKey 主要接受以下的引數,這些引數均需要由認證提供方(服務端)進行生成:
dictionary PublicKeyCredentialCreationOptions {
required PublicKeyCredentialRpEntity rp ;
required PublicKeyCredentialUserEntity user ;
required BufferSource challenge ;
required sequence < PublicKeyCredentialParameters > pubKeyCredParams ;
unsigned long timeout ;
sequence < PublicKeyCredentialDescriptor > excludeCredentials = [ ] ;
AuthenticatorSelectionCriteria authenticatorSelection ;
AttestationConveyancePreference attestation = "none" ;
AuthenticationExtensionsClientInputs extensions ;
} ;
下面對必填引數進行簡單介紹:
-
rp: 代表了進行認證方 (Replying Party) 的資訊,我們需要提供一個名字(name) 引數即可
-
user: 代表了認證方正在進行驗證的使用者資訊,必須攜帶使用者名稱(name)、顯示名稱(displayName)以及使用者id (Buffer)
-
Challenge: 代表了服務端給客戶端傳送的"挑戰"字串
-
pubKeyCredParams: 輸入一個數組,對公鑰生成演算法進行協商,演算法選項越往前,代表服務更青睞於這一種生成方式
當註冊資訊驗證沒有問題之後,我們就可以開始發起挑戰了
const crypto = require('crypto')
function requestChallenge ( { name , displayName , id } ) {
return {
challenge : crypto . randomBytes ( 32 , ( _ , buffer ) = > buffer . toString ( 'hex' ) ) ,
rp : {
name : 'Example Company'
} ,
user : {
id ,
name ,
displayName
} ,
pubKeyCredParams : [
{ type : 'public-key' , alg : - 7 } , // COSEAlgorithmIdentifier -7 ES256
{ type : 'public-key' , alg : - 257 } // COSEAlgorithmIdentifier -257 RS256
]
}
}
客戶端生成認證資訊
現在,我們已經拿到了服務端給的挑戰資訊 ,還需要稍稍處理一下才能直接給到 create 方法中,
前面提到 challenge 以及 user.id 必須是一個 buffer, 但是 JSON 不能傳輸 buffer, 我們在上面的方法中都已經處理成hex字串了,所以我們需要將字串轉換回 buffer。
axios
axios
. post ( '/auth/register' , info )
. then ( data = > {
data . challenge = Buffer . from ( data . challenge , 'hex' )
data . user . id = Buffer . from ( data . user . id , 'hex' )
// 生成
navigator . credentials . create ( { publicKey : data } ) ;
} )
如果一切正確,那麼瀏覽器將會彈出認證視窗:
瀏覽器會列出所有支援的認證方式,選擇一個你喜歡的認證方式進行認證:
認證完成之後,我們會從認證器中得到如下的響應 ,將響應傳送到到服務端進行驗證:
同樣的,buffer 要轉換一下
服務端對響應進行驗證
我們主要對 attestationObject 進行驗證
function verifyAuthnticator(resp) => {
const att = resp . response . attestationObject
if ( att . fmt === 'fido-u2f' ) {
const authData = parseAuthData ( att . authData )
if ( ! ( authData . flags & 0x01 ) ) {
return new Error ( '認證時需要使用者參與' )
}
const publicKey = COSEECDHAtoPKCS ( authData . COSEPublucKey )
const signatureBase = Buffer . concat ( [
Buffer . from ( [ 0x00 ] ) ,
authData . rpIdHash ,
crypto . createHash ( 'SHA@%^' ) . update ( resp . response . clientDataJSON ) . digest ( ) ,
authData . credID ,
publicKey
] )
const PEMCertificate = ASN1toPEM ( att . attStmt . x5c [ 0 ] )
const signature = att . attStmt . sig ;
const success = crypto . createVerify ( 'SHA256' )
. update ( signatureBase )
. verify ( PEMCertificate , signature )
return {
fmt : 'fido-u2f' ,
publicKey ,
counter : authData . counter ,
credID : authData . CredID
} ;
}
return false ;
}
在這個例子中,我們對 fido-u2f 的認證格式進行驗證:
-
我們對 authData 按照規定解開
我們只要對這個 buffer 進行 slice 解開即可,需要注意的是:CredID 的長度由前面的 CredID Len 指定:
function parseAuthData(buffer) {
const credIdLen = buffer . slice ( 56 , 2 ) . readUInt16BE ( 0 ) // 56 - 58
return {
rpIdHash : buffer . slice ( 0 , 32 ) , // 0-32
flags : buffer . slice ( 33 , 1 ) , // 33
counter : buffer . slice ( 34 , 4 ) , // 34 - 38
aaguid : buffer . slice ( 39 , 16 ) , // 39 - 55
CredId : buffer . slice ( 55 , credIdLen ) , // 58 - x
COSEPublicKey : buffer . slice ( 55 + credIdLen + 1 , 77 )
}
}
將 COSE 公鑰轉換為 PKCS 作為公鑰
const cbor = require('cbor')
function COSEECDHAtoPKCS ( COSEPublicKey ) {
const cose = cbor . decodeAllSync ( COSEPublicKey ) [ 0 ] ;
return Buffer . concat ( [
Buffer . from ( [ 0x04 ] ) ,
cose . get ( - 2 ) ,
cose . get ( - 3 )
] )
}
將 X.509 轉為 PEM
function ASN1toPEM(buffer) {
let type = "" ;
// SPKI DER
if ( buffer . length === 65 && buffer [ 0 ] === 0x04 ) {
buffer = Buffer . concat ( [
// 3059... 字串, SPKI在DER格式中是固定的
new Buffer . from ( '3059301306072a8648ce3d020106082a8648ce3d030107034200' , 'hex' ) ,
buffer
] )
type = 'PUBLIC KEY'
} else {
type = 'CERTIFICATE'
}
const base64 = buffer . toString ( 'base64' )
let PEM = '' ;
for ( let i = 0 ; i < Math . ceil ( base64 . length / 64 ) ; i ++ ) {
PEM + = base64 . substr ( 64 * i , 64 ) + '\n' ;
}
return `-----BEGIN ${ type } -----\n ${ PEM } -----END ${ type } -----\n`
}
驗證完畢之後,我們只需要儲存 publicKey, counter 以及 CredID 即可完成與某個裝置的認證關聯,結束註冊流程。
使用 WebAuthn 進行使用者認證
我們對使用者註冊完成之後,下次使用者再進入時,就可以通過同一個裝置直接認證登入了,在流程上,與註冊相差不太大
-
收集使用者的使用者名稱(不需要密碼)
-
將資訊發給服務端進行驗證(檢查是否已經註冊)
-
服務端對此向客戶端發起挑戰
-
客戶端生成斷言認證資訊並響應給服務端
-
服務端驗證客戶端的響應,登入完成
將使用者名稱傳送給服務端併發起挑戰
從頁面將使用者名稱傳送給服務端之後,服務端需要發起挑戰,同時需要將這個使用者已經完成關聯的 CredID 傳送給客戶端,以便客戶端對挑戰迴應正確的響應:
function login(username) {
const user = db . findOne ( { username } )
return {
challenge : '隨機挑戰字串' ,
allowCrendentials : user . auth
}
}
客戶端產生斷言驗證資訊並驗證
axios.post('/login', info)
. then ( res = > {
// 不要忘記 buffer 轉換
navigator . credentials . get ( { publicKey : res } )
} )
產生斷言資訊之後,將資訊傳送回服務端進行驗證,這裡和註冊階段有一些不同的是:
-
attestationObject 變成 attestationData 了
-
驗證過程中,由於我們在註冊階段已經儲存了 publicKey,那麼我們只要從資料庫中重新取出來驗證即可,也就是不需要對 COSE 進行轉換了
-
authData 結構的變化
其他
Counter 的驗證
客戶端在每一次成功的驗證時,Counter 都會增加,客戶端在使用私鑰簽名時,會將 Counter 一同簽名,那麼重放攻擊時,只要 Counter 沒增加可以拒絕登入,當攻擊者修改 Counter 時,在簽名驗證階段將會無法通過。
WebAuthn 的好處
-
消除弱密碼問題,認證全由客戶端完成
-
杜絕中間人/重放攻擊等
-
對於認證方,即使出現資料洩漏,也沒有認證資訊可以洩漏
WebAuthn 的不方便之處
-
需要使用固定的裝置,如 Yubico 生產的 USB 裝置等
小結
通過對無密碼認證流程的大致瞭解,其在安全性和方便性上都給使用者帶來了很大的提升,期待硬體和網站/軟體廠商的支援。
參考
-
https://www.w3.org/TR/webauthn/
-
https://w3c.github.io/webappsec-credential-management/
-
https://slides.com/fidoalliance/jan-2018-fido-seminar-webauthn-tutorial
-
https://tools.ietf.org/html/rfc8152
-
https://webauthn.guide/
-
https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API/Attestation_and_Assertion
-
https://www.yubico.com/2018/08/10-things-youve-been-wondering-about-fido2-webauthn-and-a-passwordless-world/
-
https://nostdahl.com/2017/08/11/x-509-certificates-explained/
-
https://github.com/NG-Studio-Development/FriendStep/blob/master/app/libs/alexutilities/src/main/java/com/alexutils/helpers/EncryptionHelper.java
關於奇舞週刊
《奇舞週刊》是360公司專業前端團隊「 奇舞團 」運營的前端技術社群。關注公眾號後,直接傳送連結到後臺即可給我們投稿。