diff --git a/CloudBuilder/CloudBuilder.Core/Application/Options/CloudBuilderOptions.cs b/CloudBuilder/CloudBuilder.Core/Application/Options/CloudBuilderOptions.cs
index 124970d..b5b5513 100644
--- a/CloudBuilder/CloudBuilder.Core/Application/Options/CloudBuilderOptions.cs
+++ b/CloudBuilder/CloudBuilder.Core/Application/Options/CloudBuilderOptions.cs
@@ -49,6 +49,8 @@ namespace CloudBuilder.Core.Application.Options
///
public RedisOptions Redis { get; set; }
+ public WebAuthnOptions WebAuthn { get; set; }
+
/////
///// 获取或设置 Swagger选项
/////
diff --git a/CloudBuilder/CloudBuilder.Core/Application/Options/CloudBuilderOptionsSetup.cs b/CloudBuilder/CloudBuilder.Core/Application/Options/CloudBuilderOptionsSetup.cs
index ff3561f..7145d45 100644
--- a/CloudBuilder/CloudBuilder.Core/Application/Options/CloudBuilderOptionsSetup.cs
+++ b/CloudBuilder/CloudBuilder.Core/Application/Options/CloudBuilderOptionsSetup.cs
@@ -67,6 +67,14 @@ namespace CloudBuilder.Core.Application.Options
options.Redis = redis;
}
+ // WebAuthnOptions
+ section = _configuration.GetSection("CloudBuilder:WebAuthn");
+ WebAuthnOptions webAuthn = section.Get();
+ if (webAuthn != null)
+ {
+ options.WebAuthn = webAuthn;
+ }
+
//// SwaggerOptions
//section = _configuration.GetSection("CloudBuilder:Swagger");
//SwaggerOptions swagger = section.Get();
diff --git a/CloudBuilder/CloudBuilder.Core/Application/Options/WebAuthnOptions.cs b/CloudBuilder/CloudBuilder.Core/Application/Options/WebAuthnOptions.cs
new file mode 100644
index 0000000..994015a
--- /dev/null
+++ b/CloudBuilder/CloudBuilder.Core/Application/Options/WebAuthnOptions.cs
@@ -0,0 +1,47 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace CloudBuilder.Core.Application.Options
+{
+
+ ///
+ /// Plain-old-data wrapper for the credential fields we persist on
+ /// SecurityUser. The frontend never sees this type.
+ ///
+ public class StoredBiometricCredential
+ {
+ public byte[] CredentialId { get; set; } = Array.Empty();
+ public byte[] PublicKey { get; set; } = Array.Empty();
+ }
+
+ public class WebAuthnOptions
+ {
+ ///
+ /// Relying Party ID — the registrable domain suffix of the
+ /// origin's host. For dev: "localhost". For production:
+ ///
+ public string RpId { get; set; } = "localhost";
+
+ ///
+ /// Human-readable relying-party name. Shown in the OS biometric
+ /// prompt ("Use Cloud Builder to sign in to localhost").
+ ///
+ public string RpName { get; set; } = "Cloud Builder";
+
+ ///
+ /// The full origin (scheme + host + port) the user is loading
+ /// the page from. Fido2NetLib verifies the attestation/assertion
+ /// origin matches this exactly. Must be HTTPS in production
+ /// (localhost is exempt).
+ ///
+ public string Origin { get; set; } = "http://localhost:8080";
+
+ ///
+ /// WebAuthn ceremony timeout in milliseconds. Default 60s.
+ ///
+ public int TimeoutMs { get; set; } = 60000;
+ }
+}
diff --git a/CloudBuilder/CloudBuilder.Core/CloudBuilder.Core.csproj b/CloudBuilder/CloudBuilder.Core/CloudBuilder.Core.csproj
index 1bf7432..0f6437b 100644
--- a/CloudBuilder/CloudBuilder.Core/CloudBuilder.Core.csproj
+++ b/CloudBuilder/CloudBuilder.Core/CloudBuilder.Core.csproj
@@ -16,19 +16,20 @@
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
diff --git a/CloudBuilder/CloudBuilder.Core/DependencyInjection/Service/ServicePack.cs b/CloudBuilder/CloudBuilder.Core/DependencyInjection/Service/ServicePack.cs
index 59e7f96..81e94a5 100644
--- a/CloudBuilder/CloudBuilder.Core/DependencyInjection/Service/ServicePack.cs
+++ b/CloudBuilder/CloudBuilder.Core/DependencyInjection/Service/ServicePack.cs
@@ -1,4 +1,5 @@
using CloudBuilder.Core.Application.Builders;
+using CloudBuilder.Core.Application.Options;
using CloudBuilder.Core.Application.Packs;
using CloudBuilder.Core.Extensions;
using CloudBuilder.Core.Service;
@@ -28,6 +29,14 @@ namespace CloudBuilder.Core.DependencyInjection.Service
///
public override IServiceCollection AddServices(IServiceCollection services, CloudBuilderBuilder builder)
{
+ services.AddFido2(options =>
+ {
+ // 你的配置:RelyingParty 配置,必须填写
+ options.ServerDomain = builder.CloudBuilderOptions.WebAuthn.RpId; // 例如 localhost 或者正式域名
+ options.ServerName = builder.CloudBuilderOptions.WebAuthn.RpName;
+ // 可选:限制origin
+ options.Origins = new HashSet { builder.CloudBuilderOptions.WebAuthn.Origin };
+ });
var baseType = typeof(IService);
var allAssemblies = CloudBuilderBuilder.GetAssemblies();
diff --git a/CloudBuilder/CloudBuilder.Core/Extensions/ServiceExtensions.cs b/CloudBuilder/CloudBuilder.Core/Extensions/ServiceExtensions.cs
index d5585a5..a953b86 100644
--- a/CloudBuilder/CloudBuilder.Core/Extensions/ServiceExtensions.cs
+++ b/CloudBuilder/CloudBuilder.Core/Extensions/ServiceExtensions.cs
@@ -1,4 +1,5 @@
using CloudBuilder.Core.Application.Builders;
+using CloudBuilder.Core.Application.Options;
using CloudBuilder.Core.DatabaseAccessor;
using CloudBuilder.Core.DependencyInjection.Form;
using CloudBuilder.Core.Extensions;
@@ -22,7 +23,7 @@ namespace Microsoft.Extensions.DependencyInjection
{
IConfiguration configuration = GetConfiguration();
services.AddSingleton(configuration);
-
+ services.Configure(configuration.GetSection("CloudBuilder:WebAuthn"));
CloudBuilderBuilder builder = new CloudBuilderBuilder(services, configuration);
builder.AddPack();
diff --git a/CloudBuilder/CloudBuilder.Security.Service/AssertionOptionsModel.cs b/CloudBuilder/CloudBuilder.Security.Service/AssertionOptionsModel.cs
new file mode 100644
index 0000000..11214b6
--- /dev/null
+++ b/CloudBuilder/CloudBuilder.Security.Service/AssertionOptionsModel.cs
@@ -0,0 +1,73 @@
+using Fido2NetLib;
+using Fido2NetLib.Objects;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace CloudBuilder.Security.Service
+{
+ ///
+ /// 登录阶段,下发给前端的断言选项模型,对应 CredentialGetOptions
+ ///
+ public sealed class AssertionOptionsModel
+ {
+ [JsonPropertyName("challenge")]
+ public string Challenge { get; init; }
+
+ ///
+ /// 依赖方ID,域名,例如 localhost / example.com
+ ///
+ [JsonPropertyName("rpId")]
+ public string RpId { get; init; }
+
+ ///
+ /// 允许使用的凭证列表,告诉浏览器该用户有哪些已绑定凭证
+ ///
+ [JsonPropertyName("allowCredentials")]
+ public PublicKeyCredentialDescriptor[] AllowCredentials { get; init; }
+
+ ///
+ /// 用户验证策略:preferred|required|discouraged
+ ///
+ [JsonPropertyName("userVerification")]
+ public UserVerificationRequirement UserVerification { get; init; }
+
+ ///
+ /// 超时毫秒
+ ///
+ [JsonPropertyName("timeout")]
+ public ulong Timeout { get; init; }
+
+ // 可选扩展字段,如果业务用到可以加上
+ //[JsonPropertyName("extensions")]
+ //public AuthenticationExtensionsClientInputs? Extensions { get; init; }
+
+
+ ///
+ /// 和Fido2NetLib原生风格对齐,从JSON字符串反序列化对象
+ ///
+ /// 后端输出给前端的原始json
+ ///
+ public static AssertionOptionsModel FromJson(string json)
+ {
+ var opt = new JsonSerializerOptions
+ {
+ Converters = { new Base64UrlConverter() },
+ PropertyNameCaseInsensitive = false
+ };
+ return JsonSerializer.Deserialize(json, opt)!;
+ }
+
+ ///
+ /// 序列化为给前端消费的JSON(challenge/id自动Base64Url编码)
+ ///
+ public string ToJson()
+ {
+ var opt = new JsonSerializerOptions
+ {
+ Converters = { new Base64UrlConverter() },
+ WriteIndented = false
+ };
+ return JsonSerializer.Serialize(this, opt);
+ }
+ }
+}
\ No newline at end of file
diff --git a/CloudBuilder/CloudBuilder.Security.Service/AuthService.cs b/CloudBuilder/CloudBuilder.Security.Service/AuthService.cs
index b2068f8..38fb024 100644
--- a/CloudBuilder/CloudBuilder.Security.Service/AuthService.cs
+++ b/CloudBuilder/CloudBuilder.Security.Service/AuthService.cs
@@ -12,7 +12,10 @@ using CloudBuilder.Security.Publisher;
using CloudBuilder.Security.Service.Authorization;
using CloudBuilder.Security.Service.Publisher;
using DocumentFormat.OpenXml.Spreadsheet;
+using EnvDTE;
using Fido2NetLib;
+using Fido2NetLib.Objects;
+using Fido2NetLib.Serialization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
@@ -21,6 +24,9 @@ using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
using Newtonsoft.Json;
+using Newtonsoft.Json.Converters;
+using Newtonsoft.Json.Serialization;
+using System.Buffers.Text;
using System.ComponentModel.DataAnnotations;
using System.IdentityModel.Tokens.Jwt;
using System.Runtime;
@@ -627,7 +633,8 @@ namespace CloudBuilder.Security.Service
// case-sensitive by default and looks for `Response` (PascalCase) in
// the JSON, while the browser sends `response` (lowercase).
// Newtonsoft.Json's default ContractResolver is case-insensitive.
- raw = JsonConvert.DeserializeObject(dto.AttestationJson);
+ AuthenticatorAttestationRawResponseModel oldModel = JsonConvert.DeserializeObject(dto.AttestationJson);
+ raw = ToNewAuthenticatorAttestationRawResponse(oldModel);
}
catch (Exception ex)
{
@@ -668,8 +675,8 @@ namespace CloudBuilder.Security.Service
// BiometricRegistrationResultDto.CredentialIdBase64Url) and
// the public key can be decoded back to byte[] for the
// assertion verification path.
- user.BiometricCredentialId = Fido2NetLib.Base64Url.Encode(stored.CredentialId);
- user.BiometricPublicKey = Fido2NetLib.Base64Url.Encode(stored.PublicKey);
+ user.BiometricCredentialId = MyBase64Url.Encode(stored.CredentialId);
+ user.BiometricPublicKey = MyBase64Url.Encode(stored.PublicKey);
user.BiometricCounter = 0;
user.BiometricDeviceName = dto.AttestationJson.Length > 0 ? "(set on the device)" : null;
user.BiometricEnrolledAt = DateTime.Now;
@@ -679,7 +686,7 @@ namespace CloudBuilder.Security.Service
return new BiometricRegistrationResultDto
{
- CredentialIdBase64Url = Fido2NetLib.Base64Url.Encode(stored.CredentialId)
+ CredentialIdBase64Url = MyBase64Url.Encode(stored.CredentialId)
};
}
@@ -703,7 +710,7 @@ namespace CloudBuilder.Security.Service
// PascalCase serializer. See WebAuthnService.BuildRequestOptionsJson
// for why the wire shape has to be hand-built.
var optionsJson = webAuthnService.BuildRequestOptionsJson(
- Fido2NetLib.Base64Url.Decode(user.BiometricCredentialId));
+ MyBase64Url.Decode(user.BiometricCredentialId));
return new BiometricLoginChallengeDto
{
RequestOptionsJson = optionsJson,
@@ -748,7 +755,8 @@ namespace CloudBuilder.Security.Service
{
token = Newtonsoft.Json.Linq.JToken.Parse((string)v.Value!);
}
- originalOptions = AssertionOptions.FromJson(token.ToString());
+ AssertionOptionsModel assertionOptionsModel = AssertionOptionsModel.FromJson(token.ToString());
+ originalOptions = ConvertToLibAssertionOptions(assertionOptionsModel);
}
catch (Exception ex)
{
@@ -773,7 +781,13 @@ namespace CloudBuilder.Security.Service
// string (1.0.0-alpha shape) the reflection write will fail at
// runtime with a clear ArgumentException — see the catch below
// for the workaround.
- raw = JsonConvert.DeserializeObject(dto.AssertionJson);
+
+ AuthenticatorAssertionRawResponseModel model =
+ JsonConvert.DeserializeObject(dto.AssertionJson);
+
+ raw = AssertionModelConverter.ToLibAuthenticatorAssertionRawResponse(model);
+
+ //raw = JsonConvert.DeserializeObject(dto.AssertionJson);
WebAuthnAssertionPatcher.PatchResponseToByteArrays(raw);
// DIAG: log the actual runtime property type so we can confirm
@@ -804,7 +818,7 @@ namespace CloudBuilder.Security.Service
// user.BiometricPublicKey is stored as base64url; decode to
// byte[] for Fido2NetLib's MakeAssertionAsync signature.
var newCounter = webAuthnService
- .FinishAssertionAsync(raw, originalOptions, Fido2NetLib.Base64Url.Decode(user.BiometricPublicKey), (uint)user.BiometricCounter)
+ .FinishAssertionAsync(raw, originalOptions, MyBase64Url.Decode(user.BiometricPublicKey), (uint)user.BiometricCounter)
.GetAwaiter().GetResult();
// Persist counter + last-used timestamp.
@@ -822,6 +836,51 @@ namespace CloudBuilder.Security.Service
throw;
}
}
+ public static AuthenticatorAttestationRawResponse ToNewAuthenticatorAttestationRawResponse(AuthenticatorAttestationRawResponseModel oldModel)
+ {
+ if (oldModel == null)
+ throw new ArgumentNullException(nameof(oldModel));
+ if (oldModel.Response == null)
+ throw new ArgumentNullException(nameof(oldModel.Response), "oldModel.Response 不能为null");
+
+ var raw = new AuthenticatorAttestationRawResponse
+ {
+ // v4 Id字段是base64url字符串,由RawId字节编码而来
+ Id = Base64Url.EncodeToString(oldModel.RawId),
+ RawId = oldModel.RawId,
+ Type = PublicKeyCredentialType.PublicKey,
+ ClientExtensionResults = new AuthenticationExtensionsClientOutputs(),
+ Response = new AuthenticatorAttestationRawResponse.AttestationResponse
+ {
+ AttestationObject = oldModel.Response.AttestationObject,
+ ClientDataJson = oldModel.Response.ClientDataJson,
+ Transports = Array.Empty()
+ }
+ };
+
+ return raw;
+ }
+
+ public static AssertionOptions ConvertToLibAssertionOptions(AssertionOptionsModel model)
+ {
+ if (model == null)
+ throw new ArgumentNullException(nameof(model));
+
+ var assertionOptions = new AssertionOptions
+ {
+ Challenge = MyBase64Url.Decode(model.Challenge)!,
+ RpId = model.RpId,
+ Timeout = model.Timeout,
+ //数组转IReadOnlyList,直接赋值
+ AllowCredentials = model.AllowCredentials ?? Array.Empty(),
+ UserVerification = model.UserVerification,
+ //Model不包含的库字段,填充默认空值
+ Hints = Array.Empty(),
+ Extensions = null
+ };
+
+ return assertionOptions;
+ }
// ===== Epayslip biometric verification (step-up auth, not a new login) =====
//
@@ -872,7 +931,7 @@ namespace CloudBuilder.Security.Service
return new BiometricLoginChallengeDto
{
RequestOptionsJson = webAuthnService.BuildRequestOptionsJson(
- Fido2NetLib.Base64Url.Decode(user.BiometricCredentialId))
+ MyBase64Url.Decode(user.BiometricCredentialId))
};
}
@@ -886,86 +945,6 @@ namespace CloudBuilder.Security.Service
// why the options string needs unwrapping); only the persistence
// step is different -- we do not mint a new session.
- public bool FinishEpayslipBiometricAssertion(string assertionJson, string requestOptionsJson)
- {
- if (string.IsNullOrWhiteSpace(assertionJson) || string.IsNullOrWhiteSpace(requestOptionsJson))
- throw new ValidationException("assertionJson and requestOptionsJson are required");
-
- var username = service.UserId;
- if (string.IsNullOrWhiteSpace(username))
- throw new ValidationException("User not found.");
-
- var user = repository.DetachedEntities.FirstOrDefault(x => x.UserName == username);
- if (user == null)
- throw new ValidationException("User not found.");
-
- if (string.IsNullOrEmpty(user.BiometricCredentialId) || string.IsNullOrEmpty(user.BiometricPublicKey))
- throw new ValidationException("Biometric not enrolled for this user");
-
- AssertionOptions originalOptions;
- try
- {
- // Same unwrap as FinishBiometricLogin: the frontend
- // double-JSON-encodes the options string, so the top-level
- // JToken is a JSON string of a JSON object. Unwrap one layer
- // before handing to Fido2NetLib's FromJson.
- var token = Newtonsoft.Json.Linq.JToken.Parse(requestOptionsJson);
- if (token is Newtonsoft.Json.Linq.JValue v && v.Type == Newtonsoft.Json.Linq.JTokenType.String)
- {
- token = Newtonsoft.Json.Linq.JToken.Parse((string)v.Value!);
- }
- originalOptions = AssertionOptions.FromJson(token.ToString());
- }
- catch (Exception ex)
- {
- //logger.LogError("FinishEpayslipBiometricAssertion_ERROR_1: " + ex.ToString());
- throw new ValidationException("Invalid requestOptionsJson: " + ex.Message);
- }
-
- AuthenticatorAssertionRawResponse raw;
- try
- {
- // See WebAuthnAssertionPatcher for why this is needed.
- raw = JsonConvert.DeserializeObject(assertionJson);
- WebAuthnAssertionPatcher.PatchResponseToByteArrays(raw);
- }
- catch (Exception ex)
- {
- //logger.LogError("FinishEpayslipBiometricAssertion_ERROR_2: " + ex.ToString());
- throw new ValidationException("Invalid assertionJson: " + ex.Message);
- }
- if (raw == null)
- throw new ValidationException("Invalid assertionJson");
- if (raw.Response == null)
- throw new ValidationException("assertionJson is missing the response field");
-
- try
- {
- // user.BiometricPublicKey is stored as base64url; decode to
- // byte[] for Fido2NetLib's MakeAssertionAsync signature.
- var newCounter = webAuthnService
- .FinishAssertionAsync(raw, originalOptions, Fido2NetLib.Base64Url.Decode(user.BiometricPublicKey), (uint)user.BiometricCounter)
- .GetAwaiter().GetResult();
-
- user.BiometricCounter = (int)newCounter;
- user.BiometricLastUsedAt = DateTime.Now;
-
- repository.UpdateNow(user);
-
-
- // Stamp the unlock. Default duration is 5 minutes -- see
- // EpayslipBiometricUnlockStore.DefaultDuration.
- //epayslipUnlockStore.Unlock(username);
- //logger.LogInformation("FinishEpayslipBiometricAssertion: unlocked for username={username}", username);
- return true;
- }
- catch (Exception ex)
- {
- //logger.LogError("FinishEpayslipBiometricAssertion_ERROR_3: " + ex.ToString());
- throw;
- }
- }
-
[PermissionOperate(name: SecurityPermissionPolicy.SecurityUser, operate: Operate.Find)]
public void RemoveBiometric()
{
@@ -987,4 +966,172 @@ namespace CloudBuilder.Security.Service
repository.UpdateNow(user);
}
}
+
+
+ public class AuthenticatorAttestationRawResponseModel
+ {
+ public class ResponseData
+ {
+ [JsonConverter(typeof(Base64UrlConverterModel))]
+ public byte[] AttestationObject { get; set; }
+
+ [JsonConverter(typeof(Base64UrlConverterModel))]
+ public byte[] ClientDataJson { get; set; }
+ }
+
+ [JsonConverter(typeof(Base64UrlConverterModel))]
+ public byte[] Id { get; set; }
+
+ [JsonConverter(typeof(Base64UrlConverterModel))]
+ public byte[] RawId { get; set; }
+
+ public string Type { get; set; }
+
+ public ResponseData Response { get; set; }
+ }
+
+
+ public class Base64UrlConverterModel : JsonConverter
+ {
+ private readonly Required requirement = Required.DisallowNull;
+
+ public Base64UrlConverterModel()
+ {
+ }
+
+ public Base64UrlConverterModel(Required required = Required.DisallowNull)
+ {
+ requirement = required;
+ }
+
+ public override void WriteJson(JsonWriter writer, byte[] value, JsonSerializer serializer)
+ {
+ writer.WriteValue(Base64Url.EncodeToString(value));
+ }
+
+ public override byte[] ReadJson(JsonReader reader, Type objectType, byte[] existingValue, bool hasExistingValue, JsonSerializer serializer)
+ {
+ byte[] result = null;
+ if (reader.Value == null && requirement == Required.AllowNull)
+ {
+ return result;
+ }
+
+ if (reader.Value == null)
+ {
+ throw new Fido2VerificationException("json value must not be null");
+ }
+
+ if (Type.GetType("System.String") != reader.ValueType)
+ {
+ throw new Fido2VerificationException("json valuetype must be string");
+ }
+
+ try
+ {
+ return Base64Url.DecodeFromChars((string)reader.Value);
+ }
+ catch (FormatException)
+ {
+ throw new Fido2VerificationException("json value must be valid base64 encoded string");
+ }
+ }
+ }
+
+ public class AuthenticatorAssertionRawResponseModel
+ {
+ public string Id { get; set; } = string.Empty;
+
+ public string RawId { get; set; } = string.Empty;
+
+ public AssertionResponseModel Response { get; set; } = new();
+
+ public PublicKeyCredentialType Type { get; set; }
+
+ public AuthenticationExtensionsClientOutputs ClientExtensionResults { get; set; } = new();
+
+ public sealed class AssertionResponseModel
+ {
+ public string AuthenticatorData { get; set; } = string.Empty;
+ public string Signature { get; set; } = string.Empty;
+ public string ClientDataJSON { get; set; } = string.Empty;
+ public string? UserHandle { get; set; }
+ }
+ }
+
+ public static class AssertionModelConverter
+ {
+ ///
+ /// 把simplewebauthn过渡Model,转为Fido2NetLib v4.1原生 AuthenticatorAssertionRawResponse
+ ///
+ /// 过渡模型
+ ///
+ /// Fido2NetLib原生 AuthenticatorAssertionRawResponse
+ public static AuthenticatorAssertionRawResponse ToLibAuthenticatorAssertionRawResponse(
+ AuthenticatorAssertionRawResponseModel model)
+ {
+ if (model == null) throw new ArgumentNullException(nameof(model));
+ if (model.Response == null) throw new ArgumentNullException(nameof(model.Response));
+
+ return new AuthenticatorAssertionRawResponse
+ {
+ Id = model.Id,
+ RawId = Base64Url.DecodeFromChars(model.RawId),
+ Type = model.Type,
+ ClientExtensionResults = model.ClientExtensionResults,
+ Response = new AuthenticatorAssertionRawResponse.AssertionResponse
+ {
+ AuthenticatorData = Base64Url.DecodeFromChars(model.Response.AuthenticatorData),
+ Signature = Base64Url.DecodeFromChars(model.Response.Signature),
+ ClientDataJson = Base64Url.DecodeFromChars(model.Response.ClientDataJSON),
+ UserHandle = string.IsNullOrEmpty(model.Response.UserHandle)
+ ? null
+ : Base64Url.DecodeFromChars(model.Response.UserHandle)
+ }
+ };
+ }
+ }
+ /*
+ public class AssertionOptionsModel
+ {
+ [JsonProperty("status")]
+ public string Status { get; set; } = "ok";
+
+
+ [JsonProperty("errorMessage")]
+ public string ErrorMessage { get; set; } = string.Empty;
+
+
+ [JsonConverter(typeof(Base64UrlConverterModel))]
+ public byte[] Challenge { get; set; }
+
+ public uint Timeout { get; set; }
+
+ public string RpId { get; set; }
+
+ public IEnumerable AllowCredentials { get; set; }
+
+ public UserVerificationRequirement UserVerification { get; set; }
+
+ internal static AssertionOptionsModel Create(byte[] challenge, IEnumerable allowedCredentials, UserVerificationRequirement userVerification)
+ {
+ return new AssertionOptionsModel
+ {
+ Challenge = challenge,
+ AllowCredentials = (allowedCredentials ?? new List()),
+ UserVerification = userVerification
+ };
+ }
+
+ public string ToJson()
+ {
+ return JsonConvert.SerializeObject(this);
+ }
+
+ public static AssertionOptionsModel FromJson(string json)
+ {
+ return JsonConvert.DeserializeObject(json);
+ }
+ }
+ */
}
diff --git a/CloudBuilder/CloudBuilder.Security.Service/CloudBuilder.Security.Service.csproj b/CloudBuilder/CloudBuilder.Security.Service/CloudBuilder.Security.Service.csproj
index b8ea72c..ab59d26 100644
--- a/CloudBuilder/CloudBuilder.Security.Service/CloudBuilder.Security.Service.csproj
+++ b/CloudBuilder/CloudBuilder.Security.Service/CloudBuilder.Security.Service.csproj
@@ -13,6 +13,8 @@
+
+
@@ -21,13 +23,14 @@
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
diff --git a/CloudBuilder/CloudBuilder.Security.Service/WebAuthnAssertionPatcher.cs b/CloudBuilder/CloudBuilder.Security.Service/WebAuthnAssertionPatcher.cs
index 34644cf..7a64680 100644
--- a/CloudBuilder/CloudBuilder.Security.Service/WebAuthnAssertionPatcher.cs
+++ b/CloudBuilder/CloudBuilder.Security.Service/WebAuthnAssertionPatcher.cs
@@ -67,7 +67,7 @@ namespace CloudBuilder.Security.Service
if (current is byte[]) return; // already decoded by the converter
if (current is string s && !string.IsNullOrEmpty(s))
{
- try { prop.SetValue(target, Fido2NetLib.Base64Url.Decode(s)); }
+ try { prop.SetValue(target, MyBase64Url.Decode(s)); }
catch { /* leave original value; Fido2NetLib will surface a clearer error */ }
}
return;
@@ -78,7 +78,7 @@ namespace CloudBuilder.Security.Service
{
if (current is string s && !string.IsNullOrEmpty(s))
{
- try { prop.SetValue(target, Fido2NetLib.Base64Url.Decode(s)); } catch { }
+ try { prop.SetValue(target, MyBase64Url.Decode(s)); } catch { }
}
return;
}
@@ -89,4 +89,34 @@ namespace CloudBuilder.Security.Service
// InvalidCastException that we can diagnose from the stack trace.
}
}
+
+ ///
+ /// 跨平台 Base64Url,行为等价旧Fido2NetLib.Base64Url,Windows+Linux完全兼容
+ ///
+ public static class MyBase64Url
+ {
+ public static byte[] Decode(string input)
+ {
+ if (string.IsNullOrEmpty(input))
+ return null;
+
+ // base64url → 标准base64
+ input = input.Replace('-', '+').Replace('_', '/');
+ switch (input.Length % 4)
+ {
+ case 2: input += "=="; break;
+ case 3: input += "="; break;
+ }
+ return Convert.FromBase64String(input);
+ }
+
+ public static string Encode(byte[] data)
+ {
+ if (data == null) return null;
+ return Convert.ToBase64String(data)
+ .Replace('+', '-')
+ .Replace('/', '_')
+ .TrimEnd('=');
+ }
+ }
}
diff --git a/CloudBuilder/CloudBuilder.Security.Service/WebAuthnService.cs b/CloudBuilder/CloudBuilder.Security.Service/WebAuthnService.cs
index 0b0e163..c0145c3 100644
--- a/CloudBuilder/CloudBuilder.Security.Service/WebAuthnService.cs
+++ b/CloudBuilder/CloudBuilder.Security.Service/WebAuthnService.cs
@@ -1,4 +1,5 @@
-using CloudBuilder.Security.Data;
+using CloudBuilder.Core.Application.Options;
+using CloudBuilder.Security.Data;
using Fido2NetLib;
using Fido2NetLib.Objects;
using Microsoft.Extensions.Options;
@@ -6,39 +7,26 @@ using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Linq;
-using System.Text;
using System.Threading.Tasks;
namespace CloudBuilder.Security.Service
{
-
///
- /// Wraps the Fido2NetLib ceremony primitives (challenge generation,
+ /// Wraps the Fido2 ceremony primitives (challenge generation,
/// attestation verification, assertion verification) so the process
/// layer can stay focused on the workflow. The frontend drives both
/// registration and login through this service via webmethods on
/// UserProfileProcess.
///
- public class WebAuthnService: IWebAuthnService
+ public class WebAuthnService : IWebAuthnService
{
- private readonly Fido2 _fido2;
+ private readonly IFido2 _fido2;
private readonly WebAuthnOptions _options;
- public WebAuthnService(IOptions options)
+ public WebAuthnService(IOptions options, IFido2 fido2)
{
_options = options.Value;
-
- var config = new Fido2.Configuration
- {
- ServerDomain = _options.RpId,
- ServerName = _options.RpName,
- Origin = _options.Origin,
- // Fido2NetLib expresses the challenge length in BYTES here.
- // 32 bytes = 256 bits, the WebAuthn-recommended minimum.
- ChallengeSize = 32,
- Timeout = (uint)_options.TimeoutMs
- };
- _fido2 = new Fido2(config);
+ _fido2 = fido2;
}
public string Origin => _options.Origin;
@@ -48,12 +36,9 @@ namespace CloudBuilder.Security.Service
/// Build the PublicKeyCredentialCreationOptions that the browser
/// will hand to navigator.credentials.create().
///
- /// HR+ username (becomes the WebAuthn user.name + user.displayName).
- /// Stable per-user byte array. We use the username's UTF-8 bytes — fine for v1 because usernames are unique.
- /// If the user is re-enrolling on a new device, the existing credential ID is passed via excludeCredentials so the authenticator refuses to re-enroll the same one. For v1 we don't track this (one-device-only); pass an empty list.
public CredentialCreateOptions BuildCreationOptions(string username, byte[] userId, IReadOnlyList existingCredentialIds)
{
- var user = new User
+ var user = new Fido2User
{
Name = username,
Id = userId,
@@ -61,20 +46,23 @@ namespace CloudBuilder.Security.Service
};
var exclude = existingCredentialIds
- .Select(id => new PublicKeyCredentialDescriptor { Id = id })
+ .Select(id => new PublicKeyCredentialDescriptor(id))
.ToList();
- return _fido2.RequestNewCredential(
- user,
- exclude,
- new AuthenticatorSelection
- {
- // Prefer the platform authenticator (Touch ID / Windows Hello /
- // fingerprint) over roaming keys.
- AuthenticatorAttachment = AuthenticatorAttachment.Platform,
- UserVerification = UserVerificationRequirement.Preferred
- },
- AttestationConveyancePreference.None);
+ var authSel = new AuthenticatorSelection
+ {
+ // Prefer the platform authenticator (Touch ID / Windows Hello / fingerprint)
+ AuthenticatorAttachment = AuthenticatorAttachment.Platform,
+ UserVerification = UserVerificationRequirement.Preferred
+ };
+
+ return _fido2.RequestNewCredential(new RequestNewCredentialParams
+ {
+ User = user,
+ ExcludeCredentials = exclude,
+ AuthenticatorSelection = authSel,
+ AttestationPreference = AttestationConveyancePreference.None
+ });
}
///
@@ -86,29 +74,24 @@ namespace CloudBuilder.Security.Service
CredentialCreateOptions originalOptions,
Func> isCredentialIdUnique)
{
- // Fido2NetLib's MakeNewCredentialAsync takes a delegate that, given
- // the new credential id, decides whether the credential is unique
- // for the user. In our schema the credential id is stored on
- // SecurityUser.biometric_credential_id, so uniqueness is automatic
- // — the same credential id can never be assigned to two users.
- // We always return true.
- var makeResult = await _fido2.MakeNewCredentialAsync(
- attestationResponse,
- originalOptions,
- (args) => Task.FromResult(true),
- requestTokenBindingId: null);
-
- if (makeResult?.Result is not AttestationVerificationSuccess success)
+ var makeResult = await _fido2.MakeNewCredentialAsync(new MakeNewCredentialParams
{
- throw new InvalidOperationException(
- "Fido2 attestation verification failed: " +
- (makeResult?.ErrorMessage ?? "unknown error"));
+ AttestationResponse = attestationResponse,
+ OriginalOptions = originalOptions,
+ IsCredentialIdUniqueToUserCallback = (args, ct) => Task.FromResult(true)
+ });
+
+
+ var isUnique = await isCredentialIdUnique(makeResult.Id);
+ if (!isUnique)
+ {
+ throw new InvalidOperationException("Credential id duplicate");
}
return new StoredBiometricCredential
{
- CredentialId = success.CredentialId,
- PublicKey = success.PublicKey
+ CredentialId = makeResult.Id,
+ PublicKey = makeResult.PublicKey
};
}
@@ -120,36 +103,28 @@ namespace CloudBuilder.Security.Service
public AssertionOptions BuildRequestOptions(byte[] storedCredentialId)
{
var allowed = new List
- {
- new() { Id = storedCredentialId }
- };
- return _fido2.GetAssertionOptions(allowed, UserVerificationRequirement.Preferred);
+ {
+ new PublicKeyCredentialDescriptor(storedCredentialId)
+ };
+
+ return _fido2.GetAssertionOptions(new GetAssertionOptionsParams
+ {
+ AllowedCredentials = allowed,
+ UserVerification = UserVerificationRequirement.Preferred
+ });
}
///
/// Same as but returns a
/// browser-ready JSON string directly.
- ///
- /// Fido2NetLib 1.0.0-alpha's AssertionOptions.ToJson() serializes
- /// AllowCredentials[].Id as PascalCase (the C# property is
- /// Id with no [JsonProperty("id")] attribute — the
- /// attribute was added in v1.1.0+). The browser's
- /// @simplewebauthn/browser toPublicKeyCredentialDescriptor
- /// does const { id } = descriptor, gets undefined, then
- /// passes it to base64URLStringToBuffer(id) which does
- /// id.replace(...) and throws
- /// "Cannot read properties of undefined (reading 'replace')".
- ///
- /// We bypass Fido2NetLib's serializer entirely and emit the JSON
- /// shape the WebAuthn spec + the browser expect directly: camelCase
- /// keys, allowCredentials[].id as base64url, type: "public-key",
- /// userVerification: "preferred".
///
public string BuildRequestOptionsJson(byte[] storedCredentialId)
{
- var options = _fido2.GetAssertionOptions(
- new List { new() { Id = storedCredentialId } },
- UserVerificationRequirement.Preferred);
+ var options = _fido2.GetAssertionOptions(new GetAssertionOptionsParams
+ {
+ AllowedCredentials = new List { new PublicKeyCredentialDescriptor(storedCredentialId) },
+ UserVerification = UserVerificationRequirement.Preferred
+ });
var result = new
{
@@ -157,12 +132,12 @@ namespace CloudBuilder.Security.Service
rpId = options.RpId,
allowCredentials = new[]
{
- new
- {
- id = Fido2NetLib.Base64Url.Encode(storedCredentialId),
- type = "public-key",
+ new
+ {
+ id = MyBase64Url.Encode(storedCredentialId),
+ type = "public-key",
+ }
},
- },
userVerification = options.UserVerification?.ToString().ToLower() ?? "preferred",
timeout = options.Timeout,
};
@@ -180,35 +155,17 @@ namespace CloudBuilder.Security.Service
byte[] storedPublicKey,
uint storedCounter)
{
- var result = await _fido2.MakeAssertionAsync(
- assertionResponse,
- originalOptions,
- storedPublicKey,
- storedCounter,
- (args) => Task.FromResult(true), // userHandle ownership always true for our single-user case
- requestTokenBindingId: null);
-
- if (result is null)
+ var result = await _fido2.MakeAssertionAsync(new MakeAssertionParams
{
- throw new InvalidOperationException("Fido2 assertion verification returned null");
- }
+ AssertionResponse = assertionResponse,
+ OriginalOptions = originalOptions,
+ StoredPublicKey = storedPublicKey,
+ StoredSignatureCounter = storedCounter,
+ IsUserHandleOwnerOfCredentialIdCallback = (args, ct) => Task.FromResult(true)
+ });
- // MakeAssertionAsync returns AssertionVerificationSuccess, but in
- // some Fido2NetLib versions it may surface a status/error wrapper.
- // Use the typed object via dynamic to stay version-agnostic.
- uint newCounter;
- try
- {
- newCounter = (uint)result.Counter;
- }
- catch
- {
- throw new InvalidOperationException("Fido2 assertion verification did not return a counter");
- }
+ uint newCounter = result.SignCount;
- // The counter MUST be strictly greater than the stored value
- // (replay protection). Fido2NetLib already enforces this server-side
- // and would have thrown; the second check is a belt-and-suspenders.
if (newCounter <= storedCounter)
{
throw new InvalidOperationException(
@@ -218,4 +175,4 @@ namespace CloudBuilder.Security.Service
return newCounter;
}
}
-}
+}
\ No newline at end of file
diff --git a/CloudBuilder/CloudBuilder.Security/CloudBuilder.Security.csproj b/CloudBuilder/CloudBuilder.Security/CloudBuilder.Security.csproj
index 243ac8e..3431cde 100644
--- a/CloudBuilder/CloudBuilder.Security/CloudBuilder.Security.csproj
+++ b/CloudBuilder/CloudBuilder.Security/CloudBuilder.Security.csproj
@@ -7,23 +7,24 @@
+
+
-
+
-
-
-
-
-
-
-
+
+
+
+
+
+
diff --git a/CloudBuilder/CloudBuilder.Security/Service/IWebAuthnService.cs b/CloudBuilder/CloudBuilder.Security/Service/IWebAuthnService.cs
index fe8d513..1342be0 100644
--- a/CloudBuilder/CloudBuilder.Security/Service/IWebAuthnService.cs
+++ b/CloudBuilder/CloudBuilder.Security/Service/IWebAuthnService.cs
@@ -7,6 +7,7 @@ using System.Linq;
using System.Text;
using System.Threading.Tasks;
using CloudBuilder.Security.Data;
+using CloudBuilder.Core.Application.Options;
namespace CloudBuilder.Security.Service
{
diff --git a/CloudBuilder/CloudBuilder.Web.Entry/CloudBuilder.Web.Entry.csproj b/CloudBuilder/CloudBuilder.Web.Entry/CloudBuilder.Web.Entry.csproj
index 5b1a530..b700177 100644
--- a/CloudBuilder/CloudBuilder.Web.Entry/CloudBuilder.Web.Entry.csproj
+++ b/CloudBuilder/CloudBuilder.Web.Entry/CloudBuilder.Web.Entry.csproj
@@ -10,26 +10,29 @@
+
+
-
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
+
diff --git a/CloudBuilder/CloudBuilder.Web.Entry/Program.cs b/CloudBuilder/CloudBuilder.Web.Entry/Program.cs
index 71159e8..851ac00 100644
--- a/CloudBuilder/CloudBuilder.Web.Entry/Program.cs
+++ b/CloudBuilder/CloudBuilder.Web.Entry/Program.cs
@@ -14,9 +14,7 @@ using System.Text.Json.Serialization;
var builder = WebApplication.CreateBuilder(args);
-
builder.Services.Initialize();
-builder.Services.Configure(builder.Configuration.GetSection("WebAuthn"));
// Add services to the container.
builder.Services.AddHttpClient().AddControllers()
diff --git a/CloudBuilder/CloudBuilder.Web.Entry/Properties/PublishProfiles/FolderProfile3.pubxml.user b/CloudBuilder/CloudBuilder.Web.Entry/Properties/PublishProfiles/FolderProfile3.pubxml.user
index c9d2656..8daa7db 100644
--- a/CloudBuilder/CloudBuilder.Web.Entry/Properties/PublishProfiles/FolderProfile3.pubxml.user
+++ b/CloudBuilder/CloudBuilder.Web.Entry/Properties/PublishProfiles/FolderProfile3.pubxml.user
@@ -3,7 +3,7 @@
<_PublishTargetUrl>C:\inetpub\wwwroot\dockwebapi2
- True|2026-08-19T05:39:59.9363522Z||;True|2026-08-19T13:36:53.0379313+08:00||;True|2026-08-19T13:31:36.0841398+08:00||;True|2026-08-19T11:44:37.5319626+08:00||;True|2026-08-19T09:36:45.6000047+08:00||;True|2026-08-19T09:34:36.8468631+08:00||;True|2026-08-19T08:35:41.2202300+08:00||;True|2026-08-18T16:20:59.0170916+08:00||;
+ True|2026-08-20T05:10:46.4725468Z||;True|2026-08-20T13:10:28.3142576+08:00||;True|2026-08-19T16:10:27.3362887+08:00||;True|2026-08-19T16:01:12.6234971+08:00||;True|2026-08-19T15:42:46.6576265+08:00||;True|2026-08-19T13:39:59.9363522+08:00||;True|2026-08-19T13:36:53.0379313+08:00||;True|2026-08-19T13:31:36.0841398+08:00||;True|2026-08-19T11:44:37.5319626+08:00||;True|2026-08-19T09:36:45.6000047+08:00||;True|2026-08-19T09:34:36.8468631+08:00||;True|2026-08-19T08:35:41.2202300+08:00||;True|2026-08-18T16:20:59.0170916+08:00||;
\ No newline at end of file
diff --git a/CloudBuilder/CloudBuilder.Web.Entry/appsettings.json b/CloudBuilder/CloudBuilder.Web.Entry/appsettings.json
index 396c7ef..f2d5042 100644
--- a/CloudBuilder/CloudBuilder.Web.Entry/appsettings.json
+++ b/CloudBuilder/CloudBuilder.Web.Entry/appsettings.json
@@ -45,6 +45,13 @@
"Redis": {
"Configuration": "localhost",
"InstanceName": "CloudBuilder:"
+ },
+ "WebAuthn": {
+ "//": "RpId is the registrable domain suffix of Origin's host. It is a domain (or IP), NOT a full URL — never include the port. Origin is the full URL (scheme + host + port + path) the browser is loading the page from. For IP-based origins the rpId is just the IP (e.g. '172.16.253.243'); the port goes in Origin only. Examples: dev → RpId 'localhost' / Origin 'https://localhost:8000'; UAT → RpId '172.16.253.243' / Origin 'https://172.16.253.243:8002'; prod → RpId 'www.youjiala.com' / Origin 'https://www.youjiala.com'.",
+ "RpId": "localhost",
+ "RpName": "Cloud Builder",
+ "Origin": "http://localhost:8088",
+ "TimeoutMs": 60000
}
},
"FileServiceSettings": {
@@ -102,12 +109,6 @@
]
},
- "WebAuthn": {
- "//": "RpId is the registrable domain suffix of Origin's host. It is a domain (or IP), NOT a full URL — never include the port. Origin is the full URL (scheme + host + port + path) the browser is loading the page from. For IP-based origins the rpId is just the IP (e.g. '172.16.253.243'); the port goes in Origin only. Examples: dev → RpId 'localhost' / Origin 'https://localhost:8000'; UAT → RpId '172.16.253.243' / Origin 'https://172.16.253.243:8002'; prod → RpId 'www.youjiala.com' / Origin 'https://www.youjiala.com'.",
- "RpId": "uat.youjiala.com",
- "RpName": "Cloud Builder",
- "Origin": "https://uat.youjiala.com",
- "TimeoutMs": 60000
- },
+
"AllowedHosts": "*"
}