[C#] 初探 WebAuth 簡單範例 (註冊+綁定裝置實作)

2022-11-14

之前再研究的時候需要用到 WebAuth 所以研究了一下,然後拆解了一個範例,拆得比較簡單,這也是為何之前,我都在研究關於 CBOR 的原因

因為在案例裡面很常出現。


我把原本案例改成三個步驟

1.註冊會員

2.登入會員+綁定裝置

3.登入會員+驗證裝置


這篇就主要針對註冊會員,還有登入+綁定裝置


1. 首先安裝一下 Fido2.AspNet


2.其實在 Fido2.AspNet 中有一個  Fido2User 的物件 對我寫 sample 來說夠用只是少了一個 password 所以我直接繼承來使用

using Fido2NetLib; namespace Fido2SimpleSample.Models { public class DFido2User: Fido2User { public string Pass { get; set; } } }


3. 註冊用戶的部分,其實也沒啥好說的,中間我也沒用到任何資料庫單純寫檔案紀錄資料


[HttpPost] [Route("registuser")] [Produces("application/json")] public IActionResult RegistUser([FromForm] string userid, [FromForm] string displayName, [FromForm] string userpass) { try { if (string.IsNullOrEmpty(userid)) { return Ok(new CredentialCreateOptions { Status = "error", ErrorMessage = "userid cannot be null" }); } var user = AuthStorageUtil.GetUserById(userid); if (user == null) { user = new DFido2User { DisplayName = displayName, Name = userid, Id = Encoding.UTF8.GetBytes(userid), Pass = AuthStorageUtil.GetMD5(System.Text.Encoding.UTF8.GetBytes(userpass)) }; AuthStorageUtil.SaveUserData(user); return Ok(new CredentialCreateOptions { Status = "ok" }); } else { return Ok(new CredentialCreateOptions { Status = "error", ErrorMessage = "regist already." }); } } catch (Exception e) { return Ok(new CredentialCreateOptions { Status = "error", ErrorMessage = e.Message }); } }


4.登入會員+綁定裝置,其實在後端在登入後會產生一個新的憑證放在 session (  fido2.attestationOptions ) 中,當然這邊你可以改寫到 redis 或是其他Temp DB 

JS Code:

$('#login').click(async function (event) { event.preventDefault(); var userid = $('#userId').val(); var userpass = $('#userPass').val(); // possible values: none, direct, indirect let attestation_type = "none"; // possible values: <empty>, platform, cross-platform let authenticator_attachment = ""; // possible values: preferred, required, discouraged let user_verification = "preferred"; // possible values: true,false let require_resident_key = false; // prepare form post data var data = new FormData(); data.append('userid', userid); data.append('userpass', userpass); data.append('attType', attestation_type); data.append('authType', authenticator_attachment); data.append('userVerification', user_verification); data.append('requireResidentKey', require_resident_key); // send to server for registering let makeCredentialOptions; try { makeCredentialOptions = await fetchMakeCredentialOptions(data); } catch (e) { console.error(e); let msg = "Something wen't really wrong"; showErrorAlert(msg); } console.log("Credential Options Object", makeCredentialOptions); if (makeCredentialOptions.status !== "ok") { console.log("Error creating credential options"); console.log(makeCredentialOptions.errorMessage); showErrorAlert(makeCredentialOptions.errorMessage); return; } // Turn the challenge back into the accepted format of padded base64 makeCredentialOptions.challenge = coerceToArrayBuffer(makeCredentialOptions.challenge); // Turn ID into a UInt8Array Buffer for some reason makeCredentialOptions.user.id = coerceToArrayBuffer(makeCredentialOptions.user.id); makeCredentialOptions.excludeCredentials = makeCredentialOptions.excludeCredentials.map((c) => { c.id = coerceToArrayBuffer(c.id); return c; }); if (makeCredentialOptions.authenticatorSelection.authenticatorAttachment === null) makeCredentialOptions.authenticatorSelection.authenticatorAttachment = undefined; console.log("Credential Options Formatted", makeCredentialOptions); Swal.fire({ title: 'Registering...', text: 'Tap your security key to finish registration.', imageUrl: "/images/securitykey.min.svg", showCancelButton: true, showConfirmButton: false, focusConfirm: false, focusCancel: false }); //Part 2 console.log("Creating PublicKeyCredential..."); let newCredential; try { newCredential = await navigator.credentials.create({ publicKey: makeCredentialOptions }); } catch (e) { var msg = "Could not create credentials in browser. Probably because the username is already registered with your authenticator. Please change username or authenticator." console.error(msg, e); showErrorAlert(msg, e); } console.log("PublicKeyCredential Created", newCredential); try { registerNewCredential(newCredential); } catch (e) { showErrorAlert(err.message ? err.message : err); } }); async function fetchMakeCredentialOptions(formData) { //formData.set('authType', 'platform') // formData.set('authType', 'cross-platform'); let response = await fetch('/api/auth/makeCredentialOptions', { method: 'POST', // or 'PUT' body: formData, // data can be `string` or {object}! headers: { 'Accept': 'application/json' } }); let data = await response.json(); return data; } // This should be used to verify the auth data with the server async function registerNewCredential(newCredential) { // Move data into Arrays incase it is super long let attestationObject = new Uint8Array(newCredential.response.attestationObject); let clientDataJSON = new Uint8Array(newCredential.response.clientDataJSON); let rawId = new Uint8Array(newCredential.rawId); const data = { id: newCredential.id, rawId: coerceToBase64Url(rawId), type: newCredential.type, extensions: newCredential.getClientExtensionResults(), response: { AttestationObject: coerceToBase64Url(attestationObject), clientDataJson: coerceToBase64Url(clientDataJSON) } }; let response; try { response = await registerCredentialWithServer(data); } catch (e) { showErrorAlert(e); } console.log("Credential Object", response); // show error if (response.status !== "ok") { console.log("Error creating credential"); console.log(response.errorMessage); showErrorAlert(response.errorMessage); return; } // show success Swal.fire({ title: 'Registration Successful!', text: 'You\'ve registered successfully.', type: 'success', timer: 2000 }); // redirect to dashboard? //window.location.href = "/dashboard/" + state.user.displayName; } async function registerCredentialWithServer(formData) { let response = await fetch('/api/auth/makeCredential', { method: 'POST', // or 'PUT' body: JSON.stringify(formData), // data can be `string` or {object}! headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' } }); let data = await response.json(); return data; }


C# Code:

[HttpPost] [Route("makeCredentialOptions")] [Produces("application/json")] public IActionResult MakeCredentialOptions([FromForm] string userid, [FromForm] string userpass, [FromForm] string attType, [FromForm] string authType, [FromForm] bool requireResidentKey, [FromForm] string userVerification) { try { if (string.IsNullOrEmpty(userid)) { return Ok(new CredentialCreateOptions { Status = "error", ErrorMessage = "user id null." }); } if (string.IsNullOrEmpty(userpass)) { return Ok(new CredentialCreateOptions { Status = "error", ErrorMessage = "userpass null." }); } // 1. Get user from DB by username var user = AuthStorageUtil.GetUserById(userid); if (user == null) { return Ok(new CredentialCreateOptions { Status = "error", ErrorMessage = "User Not Existed." }); } if (AuthStorageUtil.GetMD5(System.Text.Encoding.UTF8.GetBytes(userpass)) != user.Pass) { return Ok(new CredentialCreateOptions { Status = "error", ErrorMessage = "User Data Error." }); } // 2. Get user existing keys by username //var existingKeys = DemoStorage.GetCredentialsByUser(user).Select(c => c.Descriptor).ToList(); var existingKeys = AuthStorageUtil.GetCredentialsByUser(user.Name).Select(e => e.Descriptor).ToList(); ; // 3. Create options var authenticatorSelection = new AuthenticatorSelection { RequireResidentKey = requireResidentKey, UserVerification = userVerification.ToEnum<UserVerificationRequirement>() }; if (!string.IsNullOrEmpty(authType)) authenticatorSelection.AuthenticatorAttachment = authType.ToEnum<AuthenticatorAttachment>(); var exts = new AuthenticationExtensionsClientInputs() { Extensions = true, UserVerificationMethod = true, }; var options = _fido2.RequestNewCredential(user, existingKeys, authenticatorSelection, attType.ToEnum<AttestationConveyancePreference>(), exts); // 4. Temporarily store options, session/in-memory cache/redis/db HttpContext.Session.SetString("fido2.attestationOptions", options.ToJson()); // 5. return options to client return Ok(options); } catch (Exception e) { return Ok(new CredentialCreateOptions { Status = "error", ErrorMessage = e.Message }); // return Problem(); } } [HttpPost] [Route("makeCredential")] [Produces("application/json")] public async Task<IActionResult> MakeCredential([FromBody] AuthenticatorAttestationRawResponse attestationResponse, CancellationToken cancellationToken) { try { // 1. get the options we sent the client var jsonOptions = HttpContext.Session.GetString("fido2.attestationOptions"); var options = CredentialCreateOptions.FromJson(jsonOptions); // 2. Create callback so that lib can verify credential id is unique to this user //if (AuthStorageUtil.IsCredentialIdExisted(attestationResponse.Id)) { // return Ok(new CredentialMakeResult(status: "error", errorMessage:"Credentail Existed", result: null)); //} IsCredentialIdUniqueToUserAsyncDelegate callback = static async (args, cancellationToken) => { // var users = await DemoStorage.GetUsersByCredentialIdAsync(args.CredentialId, cancellationToken); if (AuthStorageUtil.IsCredentialIdExisted(args.CredentialId)) return false; return true; }; // 2. Verify and make the credentials var success = await _fido2.MakeNewCredentialAsync(attestationResponse, options, callback, cancellationToken: cancellationToken); // 3. Store the credentials in db var storeCredentail = new StoredCredential { Descriptor = new PublicKeyCredentialDescriptor(success.Result.CredentialId), PublicKey = success.Result.PublicKey, UserHandle = success.Result.User.Id, SignatureCounter = success.Result.Counter, CredType = success.Result.CredType, RegDate = DateTime.Now, AaGuid = success.Result.Aaguid }; AuthStorageUtil.SaveStoredCredential(success.Result.User.Name, storeCredentail); // 4. return "ok" to the client return Ok(success); } catch (Exception e) { return Ok(new CredentialMakeResult(status: "error", errorMessage: FormatException(e), result: null)); } } private string FormatException(Exception e) { return string.Format("{0}{1}", e.Message, e.InnerException != null ? " (" + e.InnerException.Message + ")" : ""); }


result:

如果你是用  Chrome 也可以使用手機驗證

這邊有說不清楚的地方這邊有所有的 source code 您可以直接下載參考


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.