From ddfbd7d0f224b922034712915fd0a00981b8cef4 Mon Sep 17 00:00:00 2001 From: owenchen Date: Thu, 20 Aug 2026 13:23:26 +0800 Subject: [PATCH] ow --- .../Options/CloudBuilderOptions.cs | 2 + .../Options/CloudBuilderOptionsSetup.cs | 8 + .../Application/Options/WebAuthnOptions.cs | 47 +++ .../CloudBuilder.Core.csproj | 17 +- .../Service/ServicePack.cs | 9 + .../Extensions/ServiceExtensions.cs | 3 +- .../AssertionOptionsModel.cs | 73 ++++ .../AuthService.cs | 325 +++++++++++++----- .../CloudBuilder.Security.Service.csproj | 17 +- .../WebAuthnAssertionPatcher.cs | 34 +- .../WebAuthnService.cs | 171 ++++----- .../CloudBuilder.Security.csproj | 17 +- .../Service/IWebAuthnService.cs | 1 + .../CloudBuilder.Web.Entry.csproj | 21 +- .../CloudBuilder.Web.Entry/Program.cs | 2 - .../FolderProfile3.pubxml.user | 2 +- .../CloudBuilder.Web.Entry/appsettings.json | 15 +- 17 files changed, 523 insertions(+), 241 deletions(-) create mode 100644 CloudBuilder/CloudBuilder.Core/Application/Options/WebAuthnOptions.cs create mode 100644 CloudBuilder/CloudBuilder.Security.Service/AssertionOptionsModel.cs 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": "*" }