[C#] 初探 WebAuth 簡單範例 (登入+驗證邦定裝置實作)

2022-11-14

接續上篇文章  初探 WebAuth 簡單範例 (註冊+綁定裝置實作) ,這一篇我們就是要繼續實作登入+驗證裝置的部分



接續之前已經註冊用戶+綁定裝置了,今天這一篇會先登入,確認身分,之後在 javscript 端 呼叫  navigator.credentials.get({ publicKey: makeAssertionOptions })

將裝置資料傳回後進行驗證


登入,主要先確定帳號密碼是不是在資料庫中(範例我只是寫入檔案系統)

Javascript code:

$('#login').click(async function (event) { event.preventDefault(); var userid = $('#userId').val(); var userpass = $('#userPass').val(); // prepare form post data var data = new FormData(); data.append('userid', userid); data.append('userpass', userpass); // send to server for registering let makeAssertionOptions; try { var res = await fetch('/api/auth/assertionOptions', { method: 'POST', body: data, // data can be `string` or {object}! headers: { 'Accept': 'application/json' } }); makeAssertionOptions = await res.json(); } catch (e) { showErrorAlert("Request to server failed", e); } console.log("Assertion Options Object", makeAssertionOptions); // show options error to user if (makeAssertionOptions.status !== "ok") { console.log("Error creating assertion options"); console.log(makeAssertionOptions.errorMessage); showErrorAlert(makeAssertionOptions.errorMessage); return; } // todo: switch this to coercebase64 const challenge = makeAssertionOptions.challenge.replace(/-/g, "+").replace(/_/g, "/"); makeAssertionOptions.challenge = Uint8Array.from(atob(challenge), c => c.charCodeAt(0)); // fix escaping. Change this to coerce makeAssertionOptions.allowCredentials.forEach(function (listItem) { var fixedId = listItem.id.replace(/\_/g, "/").replace(/\-/g, "+"); listItem.id = Uint8Array.from(atob(fixedId), c => c.charCodeAt(0)); }); console.log("Assertion options", makeAssertionOptions); Swal.fire({ title: 'Logging In...', text: 'Tap your security key to login.', imageUrl: "/images/securitykey.min.svg", showCancelButton: true, showConfirmButton: false, focusConfirm: false, focusCancel: false }); // ask browser for credentials (browser will ask connected authenticators) let credential; try { credential = await navigator.credentials.get({ publicKey: makeAssertionOptions }) } catch (err) { showErrorAlert(err.message ? err.message : err); } try { await verifyAssertionWithServer(credential); } catch (e) { showErrorAlert("Could not verify assertion", e); } }); /** * Sends the credential to the the FIDO2 server for assertion * @param {any} assertedCredential */ async function verifyAssertionWithServer(assertedCredential) { // Move data into Arrays incase it is super long let authData = new Uint8Array(assertedCredential.response.authenticatorData); let clientDataJSON = new Uint8Array(assertedCredential.response.clientDataJSON); let rawId = new Uint8Array(assertedCredential.rawId); let sig = new Uint8Array(assertedCredential.response.signature); const data = { id: assertedCredential.id, rawId: coerceToBase64Url(rawId), type: assertedCredential.type, extensions: assertedCredential.getClientExtensionResults(), response: { authenticatorData: coerceToBase64Url(authData), clientDataJson: coerceToBase64Url(clientDataJSON), signature: coerceToBase64Url(sig) } }; let response; try { let res = await fetch("api/auth/makeAssertion", { method: 'POST', // or 'PUT' body: JSON.stringify(data), // data can be `string` or {object}! headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' } }); response = await res.json(); } catch (e) { showErrorAlert("Request to server failed", e); throw e; } console.log("Assertion Object", response); // show error if (response.status !== "ok") { console.log("Error doing assertion"); console.log(response.errorMessage); showErrorAlert(response.errorMessage); return; } // show success message await Swal.fire({ title: 'Logged In!', text: 'You\'re logged in successfully.', type: 'success', timer: 2000 }); // redirect to dashboard to show keys //window.location.href = "/"; }


之後透過 ajax 到  webapi 進行驗證後呼叫  navigator.credentials.get({ publicKey: makeAssertionOptions }) ,並且傳回給 server 進行驗證是不是有該

clientResponse.Id

C# code:

[HttpPost] [Route("assertionOptions")] [Produces("application/json")] public ActionResult AssertionOptionsPost([FromForm] string userid, [FromForm] string userpass, [FromForm] string userVerification) { try { var existingCredentials = new List<PublicKeyCredentialDescriptor>(); if (!string.IsNullOrEmpty(userid)) { // 1. Get user from DB var user = AuthStorageUtil.GetUserById(userid) ?? throw new ArgumentException("user was not registered"); if (AuthStorageUtil.GetMD5(System.Text.Encoding.UTF8.GetBytes(userpass)) != user.Pass) { throw new ArgumentException("user data error"); } // 2. Get registered credentials from database existingCredentials = AuthStorageUtil.GetCredentialsByUser(userid).Select(c => c.Descriptor).ToList(); } var exts = new AuthenticationExtensionsClientInputs() { UserVerificationMethod = true }; // 3. Create options var uv = string.IsNullOrEmpty(userVerification) ? UserVerificationRequirement.Discouraged : userVerification.ToEnum<UserVerificationRequirement>(); var options = _fido2.GetAssertionOptions( existingCredentials, uv, exts ); // 4. Temporarily store options, session/in-memory cache/redis/db HttpContext.Session.SetString("fido2.assertionOptions", options.ToJson()); // 5. Return options to client // return Json(options); return Ok(options); } catch (Exception e) { return Ok(new CredentialCreateOptions { Status = "error", ErrorMessage = e.Message }); // return Json(new AssertionOptions { Status = "error", ErrorMessage = FormatException(e) }); } } [HttpPost] [Route("makeAssertion")] [Produces("application/json")] public async Task<IActionResult> MakeAssertion([FromBody] AuthenticatorAssertionRawResponse clientResponse, CancellationToken cancellationToken) { try { // 1. Get the assertion options we sent the client var jsonOptions = HttpContext.Session.GetString("fido2.assertionOptions"); var options = AssertionOptions.FromJson(jsonOptions); // 2. Get registered credential from database //var creds = AuthStorageUtil.GetStoreCredentialByUserId(, clientResponse.Id) ?? throw new Exception("Unknown credentials"); var userId= AuthStorageUtil.GetUserIdByCredentialsById(clientResponse.Id); if (userId == null) { throw new Exception("Unknown credentials"); } var creds = AuthStorageUtil.GetStoreCredentialByUserId(userId, clientResponse.Id); if (creds == null) { throw new Exception("Unknown credentials"); } // 3. Get credential counter from database var storedCounter = creds.SignatureCounter; // 4. Create callback to check if userhandle owns the credentialId //IsUserHandleOwnerOfCredentialIdAsync callback = static async (args, cancellationToken) => //{ // var storedCreds = await DemoStorage.GetCredentialsByUserHandleAsync(args.UserHandle, cancellationToken); // return storedCreds.Exists(c => c.Descriptor.Id.SequenceEqual(args.CredentialId)); // return true; //}; // 5. Make the assertion var res = await _fido2.MakeAssertionAsync(clientResponse, options, creds.PublicKey, storedCounter, null, cancellationToken: cancellationToken); // 6. Store the updated counter // DemoStorage.UpdateCounter(res.CredentialId, res.Counter); // 7. return OK to client return Ok(res); } catch (Exception e) { //return Json(new AssertionVerificationResult { Status = "error", ErrorMessage = FormatException(e) }); return Ok(new AssertionVerificationResult { Status = "error", ErrorMessage = FormatException(e) }); } }


目前寫到這到一個段落,其實中間有許多格式的部分我也略過沒有敘述,當然你也可以詳細的鑽下去 或是可以參考這一篇 https://blog.techbridge.cc/2019/08/17/webauthn-intro/

這一篇寫得非常的詳細,感謝各位前輩留下的東西讓我實作上面也比較方便,如果有不懂的細節就直接看我的 code 吧,也可以幫助您快速實作

當然如果您覺得我寫得很不清不楚這邊有最原始的程式碼 https://fido2-net-lib.passwordless.dev/ 應該也可以幫助到您,因為這距離我之前寫好有點時間了

所以很多東西也沒有說得很仔細,不過就先筆記一下,避免之後自己忘記


Demo Source Code: https://github.com/donma/FIDO2SimpleSample

Online Sample: https://dmauth.azurewebsites.net/


參考文獻:

https://webcodingcenter.com/web-apis/Web-Authentication-(WebAuthn)

https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Array/slice

https://fidoalliance.org/specs/fido-v2.0-rd-20170927/fido-client-to-authenticator-protocol-v2.0-rd-20170927.html

https://stackoverflow.com/questions/59799729/webauthn-getting-the-credentialpublickey-length

https://stackoverflow.com/questions/8936984/uint8array-to-string-in-javascript

https://stackoverflow.com/questions/54045911/webauthn-byte-length-of-the-credential-public-key

重點參考:

https://blog.techbridge.cc/2019/08/17/webauthn-intro/

https://www.wenwoha.com/blog_detail-150.html

https://www.readfog.com/a/1648324634492375040

https://github.com/passwordless-lib/fido2-net-lib


範例改寫參考:

https://fido2-net-lib.passwordless.dev/


當麻許的碎念筆記 2014 | Donma Hsu Design.