Kaynağa Gözat

Saving credentials on the server

Lukas Angerer 2 yıl önce
ebeveyn
işleme
fa10fcb7fa
6 değiştirilmiş dosya ile 168 ekleme ve 51 silme
  1. 28 0
      Base64UrlConverter.cs
  2. 11 7
      CredentialManager.cs
  3. 24 0
      OptionsCache.cs
  4. 3 1
      Program.cs
  5. 16 0
      data/foo! that bar.json
  6. 86 43
      wwwroot/main.js

+ 28 - 0
Base64UrlConverter.cs

@@ -0,0 +1,28 @@
+namespace Passwordless;
+
+public static class Base64UrlConverter
+{
+    public static byte[] Decode(string value)
+    {
+        var base64 = value
+            .Replace('-', '+')
+            .Replace('_', '/');
+        
+        if (base64.Length % 4 != 0)
+        {
+            base64 += new string('=', 4 - base64.Length % 4);
+        }
+        
+        return Convert.FromBase64String(base64);
+    }
+
+    public static string Encode(byte[] value)
+    {
+        var base64 = Convert.ToBase64String(value);
+
+        return base64
+            .Replace('+', '-')
+            .Replace('/', '_')
+            .TrimEnd('=');
+    }
+}

+ 11 - 7
CredentialManager.cs

@@ -8,28 +8,29 @@ namespace Passwordless;
 public class CredentialManager
 {
     private readonly IFido2 _fido2;
+    private readonly OptionsCache _optionsCache;
     private readonly JsonSerializerOptions _jsonOptions;
 
-    public CredentialManager(IFido2 fido2)
+    public CredentialManager(IFido2 fido2, OptionsCache optionsCache)
     {
         _fido2 = fido2;
+        _optionsCache = optionsCache;
 
         _jsonOptions = new JsonSerializerOptions()
         {
             WriteIndented = true,
-
         };
     }
     
     public CredentialCreateOptions BuildCredentialOptions(string login)
     {
-        var loginDisplay = Encoding.UTF8.GetString(Convert.FromBase64String(login));
+        var loginDisplay = Encoding.UTF8.GetString(Base64UrlConverter.Decode(login));
         var loginName = NameTransform.ToFileName(loginDisplay);
 
         var user = new Fido2User
         {
             DisplayName = loginDisplay,
-            Id = Convert.FromBase64String(login),
+            Id = Base64UrlConverter.Decode(login),
             Name = loginName,
         };
 
@@ -46,6 +47,7 @@ public class CredentialManager
         };
         
         var options = _fido2.RequestNewCredential(user, new List<PublicKeyCredentialDescriptor>(), authenticatorSelection, AttestationConveyancePreference.None, extensions);
+        _optionsCache.Set(options);
         
         return options;
     }
@@ -55,16 +57,18 @@ public class CredentialManager
         // 2. Create callback so that lib can verify credential id is unique to this user
         static Task<bool> Callback(IsCredentialIdUniqueToUserParams args, CancellationToken cancellationToken)
         {
-            return Task.FromResult(!File.Exists($"./data/{args.CredentialId}"));
+            return Task.FromResult(!File.Exists($"./data/{args.User.Name}"));
         }
 
-        var options = BuildCredentialOptions(login);
+        var loginDisplay = Encoding.UTF8.GetString(Base64UrlConverter.Decode(login));
+        var loginName = NameTransform.ToFileName(loginDisplay);
+        var options = _optionsCache.Get(loginName);
 
         var success = await _fido2.MakeNewCredentialAsync(attestationResponse, options, Callback);
 
         if (success.Status == "ok")
         {
-            await using var fileStream = File.OpenWrite($"./data/{success.Result!.User.Name}");
+            await using var fileStream = File.OpenWrite($"./data/{success.Result!.User.Name}.json");
             await JsonSerializer.SerializeAsync(fileStream, success.Result, _jsonOptions);
         }
         

+ 24 - 0
OptionsCache.cs

@@ -0,0 +1,24 @@
+using Fido2NetLib;
+using Microsoft.Extensions.Caching.Memory;
+
+namespace Passwordless;
+
+public class OptionsCache
+{
+    private readonly IMemoryCache _cache;
+
+    public OptionsCache(IMemoryCache cache)
+    {
+        _cache = cache;
+    }
+
+    public CredentialCreateOptions Get(string key)
+    {
+        return _cache.Get<CredentialCreateOptions>(key) ?? throw new Exception("Credential options not found - probably due to creation timeout");
+    }
+
+    public void Set(CredentialCreateOptions options)
+    {
+        _cache.Set(options.User.Name, options, TimeSpan.FromMinutes(5));
+    }
+}

+ 3 - 1
Program.cs

@@ -15,6 +15,8 @@ builder.Services.AddFido2(options =>
     options.Origins = ["http://localhost:5172"];
     options.TimestampDriftTolerance = 300000;
 });
+builder.Services.AddMemoryCache();
+builder.Services.AddTransient<OptionsCache>();
 builder.Services.AddTransient<CredentialManager>();
 
 var app = builder.Build();
@@ -34,7 +36,7 @@ app.MapGet("/buildCredentialOptions", ([FromQuery] string login, CredentialManag
     .WithName("BuildCredentialOptions")
     .WithOpenApi();
 
-app.MapGet("/registerCredential", async ([FromQuery] string login, [FromBody] AuthenticatorAttestationRawResponse attestationResponse, CredentialManager credMan) => 
+app.MapPost("/registerCredential", async ([FromQuery] string login, [FromBody] AuthenticatorAttestationRawResponse attestationResponse, CredentialManager credMan) => 
         await credMan.RegisterCredential(login, attestationResponse))
     .WithName("RegisterCredential")
     .WithOpenApi();

+ 16 - 0
data/foo! that bar.json

@@ -0,0 +1,16 @@
+{
+  "PublicKey": "pQECAyYgASFYIBnFltBBpqPITFPaiFqPbo32lhoIhPhFHRcuNGnZpf6SIlggmZDFL0X8ReCQCab3kmf3PI92hrE5zv18dTARjP2DMDQ",
+  "User": {
+    "name": "foo! that bar",
+    "id": "Rm9vISBUaGF0IEJhcg",
+    "displayName": "Foo! That Bar"
+  },
+  "CredType": "none",
+  "Aaguid": "00000000-0000-0000-0000-000000000000",
+  "AttestationCertificate": null,
+  "AttestationCertificateChain": [],
+  "CredentialId": "GnHRok22QEXKljO7aI/Fl7wTxzUVmsw1BZmR0xi/3C5vWxOvh6E/XloTs0jAdNC2AsqLtPWygHe/MBLa5kmLvQ==",
+  "Counter": 4,
+  "status": null,
+  "errorMessage": null
+}

+ 86 - 43
wwwroot/main.js

@@ -1,19 +1,47 @@
 
-coerceToArrayBuffer = function (thing, name) {
-    if (typeof thing === "string") {
-        // base64url to base64
-        thing = thing.replace(/-/g, "+").replace(/_/g, "/");
-
-        // base64 to Uint8Array
-        const str = atob(thing);
-        const bytes = new Uint8Array(str.length);
-        for (let i = 0; i < str.length; i++) {
-            bytes[i] = str.charCodeAt(i);
-        }
-        return bytes;
+function base64UrlEncode(value) {
+    const encoded = btoa(value);
+    return encoded
+        .replace(/\+/g, "-") // '+' => '-'
+        .replace(/\//g, "_") // '/' => '_'
+        .replace(/=*$/g, ""); // trim trailing '=' padding
+}
+
+function base64UrlDecode(value) {
+    const base64 = value
+        .replace(/-/g, "+")
+        .replace(/_/g, "/");
+    return atob(base64);
+}
+
+function coerceToArrayBuffer(value) {
+    if (!(typeof value === "string")) {
+        throw new Error(`Invalid input type. Expected string but got ${typeof(value)}`);
+    }
+    // base64url to base64
+    const str = base64UrlDecode(value);
+
+    // base64 to Uint8Array
+    const bytes = new Uint8Array(str.length);
+    for (let i = 0; i < str.length; i++) {
+        bytes[i] = str.charCodeAt(i);
     }
+    return bytes;
 };
 
+function coerceToBase64Url(value) {
+    if (!(value instanceof Uint8Array)) {
+        throw new Error(`Invalid input type. Expected Uint8Array but got ${typeof(value)}`);
+    }
+    
+    let str = "";
+    for (let i = 0; i < value.byteLength; i++) {
+        str += String.fromCharCode(value[i]);
+    }
+    
+    return window.btoa(str);
+}
+
 class Registration {
     constructor() {
         document.getElementById('start').addEventListener('click', this.registerAccount.bind(this));
@@ -21,10 +49,43 @@ class Registration {
     
     async registerAccount(event) {
         event.preventDefault();
-        
         const username = 'Foo! That Bar';
-        const login = btoa(username);
+        const login = base64UrlEncode(username);
+        
+        let options;
+        
+        try {
+            options = await this.buildCredentialOptions(login);
+        } catch (e) {
+            console.error("Building create options failed with exception", e);
+            return;
+        }
 
+        // Create the Credential with navigator.credentials.create API
+        let newCredential;
+        try {
+            newCredential = await navigator.credentials.create({
+                publicKey: options
+            });
+        } catch (e) {
+            console.error("credentials.create failed with exception", e);
+            return;
+        }
+
+        console.log("PublicKeyCredential created", newCredential);
+
+        let result;
+        try {
+            result = this.register(login, newCredential);
+        } catch (e) {
+            console.error("Registering new credentials with the server failed with exception", e);
+            return;
+        }
+        
+        console.log("New login created", result);
+    }
+
+    async buildCredentialOptions(login) {
         const response = await fetch(`/buildCredentialOptions?login=${login}`, {
             method: 'GET',
             headers: {
@@ -35,12 +96,10 @@ class Registration {
         const options = await response.json();
 
         if (options.status !== "ok") {
-            console.error("Error in buildCredentialOptions");
-            console.error(options.errorMessage);
-            return;
-        } else {
-            console.log("Got options", options);
+            throw new Error(`Error in buildCredentialOptions: ${options.errorMessage}`);
         }
+        
+        console.log("Got options", options);
 
         // Turn the challenge back into the accepted format of padded base64
         options.challenge = coerceToArrayBuffer(options.challenge);
@@ -53,28 +112,10 @@ class Registration {
         });
 
         console.log("Options Formatted", options);
-
-        // Create the Credential with navigator.credentials.create API
-        
-        let newCredential;
-        try {
-            newCredential = await navigator.credentials.create({
-                publicKey: options
-            });
-        } catch (e) {
-            console.error("credentials.create failed with exception", e);
-        }
-
-        console.log("PublicKeyCredential created", newCredential);
-
-        // try {
-        //     register(newCredential);
-        // } catch (e) {
-        //     console.error("Registering new credentials with the server failed with exception", e);
-        // }
+        return options;
     }
     
-    async register(newCredential) {
+    async register(login, newCredential) {
         // Move data into Arrays in case it is super long
         // TODO: check!
         let attestationObject = new Uint8Array(newCredential.response.attestationObject);
@@ -83,7 +124,7 @@ class Registration {
 
         const data = {
             id: newCredential.id,
-            //rawId: coerceToBase64Url(rawId),
+            rawId: coerceToBase64Url(rawId),
             type: newCredential.type,
             extensions: newCredential.getClientExtensionResults(),
             response: {
@@ -93,9 +134,9 @@ class Registration {
             }
         };
 
-        let response = await fetch(`/registerCredential?login=`, {
-            method: 'POST', // or 'PUT'
-            body: JSON.stringify(formData), // data can be `string` or {object}!
+        let response = await fetch(`/registerCredential?login=${login}`, {
+            method: 'POST',
+            body: JSON.stringify(data),
             headers: {
                 'Accept': 'application/json',
                 'Content-Type': 'application/json'
@@ -109,6 +150,8 @@ class Registration {
         if (result.status !== "ok") {
             throw new Error(`Error creating credential: ${result.errorMessage}`);
         }
+        
+        return result;
     }
 }