ow
This commit is contained in:
1 parent
3461a2e921
commit
ddfbd7d0f2
17 files changed
+514
-232
No files matched your search
@@ -49,6 +49,8 @@ namespace CloudBuilder.Core.Application.Options
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public RedisOptions Redis { get; set; }
|
public RedisOptions Redis { get; set; }
|
||||||
|
|
||||||
|
public WebAuthnOptions WebAuthn { get; set; }
|
||||||
|
|
||||||
///// <summary>
|
///// <summary>
|
||||||
///// 获取或设置 Swagger选项
|
///// 获取或设置 Swagger选项
|
||||||
///// </summary>
|
///// </summary>
|
||||||
|
|||||||
@@ -67,6 +67,14 @@ namespace CloudBuilder.Core.Application.Options
|
|||||||
options.Redis = redis;
|
options.Redis = redis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WebAuthnOptions
|
||||||
|
section = _configuration.GetSection("CloudBuilder:WebAuthn");
|
||||||
|
WebAuthnOptions webAuthn = section.Get<WebAuthnOptions>();
|
||||||
|
if (webAuthn != null)
|
||||||
|
{
|
||||||
|
options.WebAuthn = webAuthn;
|
||||||
|
}
|
||||||
|
|
||||||
//// SwaggerOptions
|
//// SwaggerOptions
|
||||||
//section = _configuration.GetSection("CloudBuilder:Swagger");
|
//section = _configuration.GetSection("CloudBuilder:Swagger");
|
||||||
//SwaggerOptions swagger = section.Get<SwaggerOptions>();
|
//SwaggerOptions swagger = section.Get<SwaggerOptions>();
|
||||||
|
|||||||
@@ -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
|
||||||
|
{
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Plain-old-data wrapper for the credential fields we persist on
|
||||||
|
/// SecurityUser. The frontend never sees this type.
|
||||||
|
/// </summary>
|
||||||
|
public class StoredBiometricCredential
|
||||||
|
{
|
||||||
|
public byte[] CredentialId { get; set; } = Array.Empty<byte>();
|
||||||
|
public byte[] PublicKey { get; set; } = Array.Empty<byte>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class WebAuthnOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Relying Party ID — the registrable domain suffix of the
|
||||||
|
/// origin's host. For dev: "localhost". For production:
|
||||||
|
/// </summary>
|
||||||
|
public string RpId { get; set; } = "localhost";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Human-readable relying-party name. Shown in the OS biometric
|
||||||
|
/// prompt ("Use Cloud Builder to sign in to localhost").
|
||||||
|
/// </summary>
|
||||||
|
public string RpName { get; set; } = "Cloud Builder";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
public string Origin { get; set; } = "http://localhost:8080";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// WebAuthn ceremony timeout in milliseconds. Default 60s.
|
||||||
|
/// </summary>
|
||||||
|
public int TimeoutMs { get; set; } = 60000;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,19 +16,20 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Fido2" Version="4.0.1" />
|
||||||
|
<PackageReference Include="Fido2.AspNet" Version="4.0.1" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.0" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authorization" Version="8.0.0" />
|
<PackageReference Include="Microsoft.AspNetCore.Authorization" Version="8.0.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="8.0.0" />
|
<PackageReference Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="8.0.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.0" />
|
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.0" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.0" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.0" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyModel" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.DependencyModel" Version="9.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.0" />
|
||||||
<PackageReference Include="ClosedXML" Version="0.102.1" />
|
<PackageReference Include="ClosedXML" Version="0.102.1" />
|
||||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||||
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.0" />
|
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.0" />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using CloudBuilder.Core.Application.Builders;
|
using CloudBuilder.Core.Application.Builders;
|
||||||
|
using CloudBuilder.Core.Application.Options;
|
||||||
using CloudBuilder.Core.Application.Packs;
|
using CloudBuilder.Core.Application.Packs;
|
||||||
using CloudBuilder.Core.Extensions;
|
using CloudBuilder.Core.Extensions;
|
||||||
using CloudBuilder.Core.Service;
|
using CloudBuilder.Core.Service;
|
||||||
@@ -28,6 +29,14 @@ namespace CloudBuilder.Core.DependencyInjection.Service
|
|||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
public override IServiceCollection AddServices(IServiceCollection services, CloudBuilderBuilder builder)
|
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<string> { builder.CloudBuilderOptions.WebAuthn.Origin };
|
||||||
|
});
|
||||||
var baseType = typeof(IService);
|
var baseType = typeof(IService);
|
||||||
|
|
||||||
var allAssemblies = CloudBuilderBuilder.GetAssemblies();
|
var allAssemblies = CloudBuilderBuilder.GetAssemblies();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using CloudBuilder.Core.Application.Builders;
|
using CloudBuilder.Core.Application.Builders;
|
||||||
|
using CloudBuilder.Core.Application.Options;
|
||||||
using CloudBuilder.Core.DatabaseAccessor;
|
using CloudBuilder.Core.DatabaseAccessor;
|
||||||
using CloudBuilder.Core.DependencyInjection.Form;
|
using CloudBuilder.Core.DependencyInjection.Form;
|
||||||
using CloudBuilder.Core.Extensions;
|
using CloudBuilder.Core.Extensions;
|
||||||
@@ -22,7 +23,7 @@ namespace Microsoft.Extensions.DependencyInjection
|
|||||||
{
|
{
|
||||||
IConfiguration configuration = GetConfiguration();
|
IConfiguration configuration = GetConfiguration();
|
||||||
services.AddSingleton(configuration);
|
services.AddSingleton(configuration);
|
||||||
|
services.Configure<WebAuthnOptions>(configuration.GetSection("CloudBuilder:WebAuthn"));
|
||||||
CloudBuilderBuilder builder = new CloudBuilderBuilder(services, configuration);
|
CloudBuilderBuilder builder = new CloudBuilderBuilder(services, configuration);
|
||||||
builder.AddPack();
|
builder.AddPack();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
using Fido2NetLib;
|
||||||
|
using Fido2NetLib.Objects;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace CloudBuilder.Security.Service
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 登录阶段,下发给前端的断言选项模型,对应 CredentialGetOptions
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AssertionOptionsModel
|
||||||
|
{
|
||||||
|
[JsonPropertyName("challenge")]
|
||||||
|
public string Challenge { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 依赖方ID,域名,例如 localhost / example.com
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("rpId")]
|
||||||
|
public string RpId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 允许使用的凭证列表,告诉浏览器该用户有哪些已绑定凭证
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("allowCredentials")]
|
||||||
|
public PublicKeyCredentialDescriptor[] AllowCredentials { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 用户验证策略:preferred|required|discouraged
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("userVerification")]
|
||||||
|
public UserVerificationRequirement UserVerification { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 超时毫秒
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("timeout")]
|
||||||
|
public ulong Timeout { get; init; }
|
||||||
|
|
||||||
|
// 可选扩展字段,如果业务用到可以加上
|
||||||
|
//[JsonPropertyName("extensions")]
|
||||||
|
//public AuthenticationExtensionsClientInputs? Extensions { get; init; }
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 和Fido2NetLib原生风格对齐,从JSON字符串反序列化对象
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="json">后端输出给前端的原始json</param>
|
||||||
|
/// <returns></returns>
|
||||||
|
public static AssertionOptionsModel FromJson(string json)
|
||||||
|
{
|
||||||
|
var opt = new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
Converters = { new Base64UrlConverter() },
|
||||||
|
PropertyNameCaseInsensitive = false
|
||||||
|
};
|
||||||
|
return JsonSerializer.Deserialize<AssertionOptionsModel>(json, opt)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 序列化为给前端消费的JSON(challenge/id自动Base64Url编码)
|
||||||
|
/// </summary>
|
||||||
|
public string ToJson()
|
||||||
|
{
|
||||||
|
var opt = new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
Converters = { new Base64UrlConverter() },
|
||||||
|
WriteIndented = false
|
||||||
|
};
|
||||||
|
return JsonSerializer.Serialize(this, opt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,10 @@ using CloudBuilder.Security.Publisher;
|
|||||||
using CloudBuilder.Security.Service.Authorization;
|
using CloudBuilder.Security.Service.Authorization;
|
||||||
using CloudBuilder.Security.Service.Publisher;
|
using CloudBuilder.Security.Service.Publisher;
|
||||||
using DocumentFormat.OpenXml.Spreadsheet;
|
using DocumentFormat.OpenXml.Spreadsheet;
|
||||||
|
using EnvDTE;
|
||||||
using Fido2NetLib;
|
using Fido2NetLib;
|
||||||
|
using Fido2NetLib.Objects;
|
||||||
|
using Fido2NetLib.Serialization;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -21,6 +24,9 @@ using Microsoft.Extensions.Logging;
|
|||||||
using Microsoft.IdentityModel.JsonWebTokens;
|
using Microsoft.IdentityModel.JsonWebTokens;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
|
using Newtonsoft.Json.Converters;
|
||||||
|
using Newtonsoft.Json.Serialization;
|
||||||
|
using System.Buffers.Text;
|
||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.IdentityModel.Tokens.Jwt;
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
using System.Runtime;
|
using System.Runtime;
|
||||||
@@ -627,7 +633,8 @@ namespace CloudBuilder.Security.Service
|
|||||||
// case-sensitive by default and looks for `Response` (PascalCase) in
|
// case-sensitive by default and looks for `Response` (PascalCase) in
|
||||||
// the JSON, while the browser sends `response` (lowercase).
|
// the JSON, while the browser sends `response` (lowercase).
|
||||||
// Newtonsoft.Json's default ContractResolver is case-insensitive.
|
// Newtonsoft.Json's default ContractResolver is case-insensitive.
|
||||||
raw = JsonConvert.DeserializeObject<AuthenticatorAttestationRawResponse>(dto.AttestationJson);
|
AuthenticatorAttestationRawResponseModel oldModel = JsonConvert.DeserializeObject<AuthenticatorAttestationRawResponseModel>(dto.AttestationJson);
|
||||||
|
raw = ToNewAuthenticatorAttestationRawResponse(oldModel);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -668,8 +675,8 @@ namespace CloudBuilder.Security.Service
|
|||||||
// BiometricRegistrationResultDto.CredentialIdBase64Url) and
|
// BiometricRegistrationResultDto.CredentialIdBase64Url) and
|
||||||
// the public key can be decoded back to byte[] for the
|
// the public key can be decoded back to byte[] for the
|
||||||
// assertion verification path.
|
// assertion verification path.
|
||||||
user.BiometricCredentialId = Fido2NetLib.Base64Url.Encode(stored.CredentialId);
|
user.BiometricCredentialId = MyBase64Url.Encode(stored.CredentialId);
|
||||||
user.BiometricPublicKey = Fido2NetLib.Base64Url.Encode(stored.PublicKey);
|
user.BiometricPublicKey = MyBase64Url.Encode(stored.PublicKey);
|
||||||
user.BiometricCounter = 0;
|
user.BiometricCounter = 0;
|
||||||
user.BiometricDeviceName = dto.AttestationJson.Length > 0 ? "(set on the device)" : null;
|
user.BiometricDeviceName = dto.AttestationJson.Length > 0 ? "(set on the device)" : null;
|
||||||
user.BiometricEnrolledAt = DateTime.Now;
|
user.BiometricEnrolledAt = DateTime.Now;
|
||||||
@@ -679,7 +686,7 @@ namespace CloudBuilder.Security.Service
|
|||||||
|
|
||||||
return new BiometricRegistrationResultDto
|
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
|
// PascalCase serializer. See WebAuthnService.BuildRequestOptionsJson
|
||||||
// for why the wire shape has to be hand-built.
|
// for why the wire shape has to be hand-built.
|
||||||
var optionsJson = webAuthnService.BuildRequestOptionsJson(
|
var optionsJson = webAuthnService.BuildRequestOptionsJson(
|
||||||
Fido2NetLib.Base64Url.Decode(user.BiometricCredentialId));
|
MyBase64Url.Decode(user.BiometricCredentialId));
|
||||||
return new BiometricLoginChallengeDto
|
return new BiometricLoginChallengeDto
|
||||||
{
|
{
|
||||||
RequestOptionsJson = optionsJson,
|
RequestOptionsJson = optionsJson,
|
||||||
@@ -748,7 +755,8 @@ namespace CloudBuilder.Security.Service
|
|||||||
{
|
{
|
||||||
token = Newtonsoft.Json.Linq.JToken.Parse((string)v.Value!);
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -773,7 +781,13 @@ namespace CloudBuilder.Security.Service
|
|||||||
// string (1.0.0-alpha shape) the reflection write will fail at
|
// string (1.0.0-alpha shape) the reflection write will fail at
|
||||||
// runtime with a clear ArgumentException — see the catch below
|
// runtime with a clear ArgumentException — see the catch below
|
||||||
// for the workaround.
|
// for the workaround.
|
||||||
raw = JsonConvert.DeserializeObject<AuthenticatorAssertionRawResponse>(dto.AssertionJson);
|
|
||||||
|
AuthenticatorAssertionRawResponseModel model =
|
||||||
|
JsonConvert.DeserializeObject<AuthenticatorAssertionRawResponseModel>(dto.AssertionJson);
|
||||||
|
|
||||||
|
raw = AssertionModelConverter.ToLibAuthenticatorAssertionRawResponse(model);
|
||||||
|
|
||||||
|
//raw = JsonConvert.DeserializeObject<AuthenticatorAssertionRawResponse>(dto.AssertionJson);
|
||||||
WebAuthnAssertionPatcher.PatchResponseToByteArrays(raw);
|
WebAuthnAssertionPatcher.PatchResponseToByteArrays(raw);
|
||||||
|
|
||||||
// DIAG: log the actual runtime property type so we can confirm
|
// 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
|
// user.BiometricPublicKey is stored as base64url; decode to
|
||||||
// byte[] for Fido2NetLib's MakeAssertionAsync signature.
|
// byte[] for Fido2NetLib's MakeAssertionAsync signature.
|
||||||
var newCounter = webAuthnService
|
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();
|
.GetAwaiter().GetResult();
|
||||||
|
|
||||||
// Persist counter + last-used timestamp.
|
// Persist counter + last-used timestamp.
|
||||||
@@ -822,6 +836,51 @@ namespace CloudBuilder.Security.Service
|
|||||||
throw;
|
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<AuthenticatorTransport>()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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<PublicKeyCredentialDescriptor>(),
|
||||||
|
UserVerification = model.UserVerification,
|
||||||
|
//Model不包含的库字段,填充默认空值
|
||||||
|
Hints = Array.Empty<PublicKeyCredentialHint>(),
|
||||||
|
Extensions = null
|
||||||
|
};
|
||||||
|
|
||||||
|
return assertionOptions;
|
||||||
|
}
|
||||||
|
|
||||||
// ===== Epayslip biometric verification (step-up auth, not a new login) =====
|
// ===== Epayslip biometric verification (step-up auth, not a new login) =====
|
||||||
//
|
//
|
||||||
@@ -872,7 +931,7 @@ namespace CloudBuilder.Security.Service
|
|||||||
return new BiometricLoginChallengeDto
|
return new BiometricLoginChallengeDto
|
||||||
{
|
{
|
||||||
RequestOptionsJson = webAuthnService.BuildRequestOptionsJson(
|
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
|
// why the options string needs unwrapping); only the persistence
|
||||||
// step is different -- we do not mint a new session.
|
// 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<AuthenticatorAssertionRawResponse>(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)]
|
[PermissionOperate(name: SecurityPermissionPolicy.SecurityUser, operate: Operate.Find)]
|
||||||
public void RemoveBiometric()
|
public void RemoveBiometric()
|
||||||
{
|
{
|
||||||
@@ -987,4 +966,172 @@ namespace CloudBuilder.Security.Service
|
|||||||
repository.UpdateNow(user);
|
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<byte[]>
|
||||||
|
{
|
||||||
|
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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 把simplewebauthn过渡Model,转为Fido2NetLib v4.1原生 AuthenticatorAssertionRawResponse
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="model">过渡模型</param>
|
||||||
|
/// <exception cref="ArgumentNullException"></exception>
|
||||||
|
/// <returns>Fido2NetLib原生 AuthenticatorAssertionRawResponse</returns>
|
||||||
|
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<PublicKeyCredentialDescriptor> AllowCredentials { get; set; }
|
||||||
|
|
||||||
|
public UserVerificationRequirement UserVerification { get; set; }
|
||||||
|
|
||||||
|
internal static AssertionOptionsModel Create(byte[] challenge, IEnumerable<PublicKeyCredentialDescriptor> allowedCredentials, UserVerificationRequirement userVerification)
|
||||||
|
{
|
||||||
|
return new AssertionOptionsModel
|
||||||
|
{
|
||||||
|
Challenge = challenge,
|
||||||
|
AllowCredentials = (allowedCredentials ?? new List<PublicKeyCredentialDescriptor>()),
|
||||||
|
UserVerification = userVerification
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public string ToJson()
|
||||||
|
{
|
||||||
|
return JsonConvert.SerializeObject(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static AssertionOptionsModel FromJson(string json)
|
||||||
|
{
|
||||||
|
return JsonConvert.DeserializeObject<AssertionOptionsModel>(json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
}
|
}
|
||||||
@@ -13,6 +13,8 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Fido2" Version="4.0.1" />
|
||||||
|
<PackageReference Include="Fido2.AspNet" Version="4.0.1" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.0" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authorization" Version="8.0.0" />
|
<PackageReference Include="Microsoft.AspNetCore.Authorization" Version="8.0.0" />
|
||||||
<PackageReference Include="ClosedXML" Version="0.102.1" />
|
<PackageReference Include="ClosedXML" Version="0.102.1" />
|
||||||
@@ -21,13 +23,14 @@
|
|||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.0" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
|
||||||
<PackageReference Include="Microsoft.VisualStudio.OLE.Interop" Version="17.8.37221" />
|
<PackageReference Include="Microsoft.VisualStudio.OLE.Interop" Version="17.8.37221" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyModel" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.DependencyModel" Version="9.0.0" />
|
||||||
<PackageReference Include="Fido2NetLib" Version="1.0.0-alpha" />
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||||
|
|
||||||
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.0" />
|
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ namespace CloudBuilder.Security.Service
|
|||||||
if (current is byte[]) return; // already decoded by the converter
|
if (current is byte[]) return; // already decoded by the converter
|
||||||
if (current is string s && !string.IsNullOrEmpty(s))
|
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 */ }
|
catch { /* leave original value; Fido2NetLib will surface a clearer error */ }
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -78,7 +78,7 @@ namespace CloudBuilder.Security.Service
|
|||||||
{
|
{
|
||||||
if (current is string s && !string.IsNullOrEmpty(s))
|
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;
|
return;
|
||||||
}
|
}
|
||||||
@@ -89,4 +89,34 @@ namespace CloudBuilder.Security.Service
|
|||||||
// InvalidCastException that we can diagnose from the stack trace.
|
// InvalidCastException that we can diagnose from the stack trace.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 跨平台 Base64Url,行为等价旧Fido2NetLib.Base64Url,Windows+Linux完全兼容
|
||||||
|
/// </summary>
|
||||||
|
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('=');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using CloudBuilder.Security.Data;
|
using CloudBuilder.Core.Application.Options;
|
||||||
|
using CloudBuilder.Security.Data;
|
||||||
using Fido2NetLib;
|
using Fido2NetLib;
|
||||||
using Fido2NetLib.Objects;
|
using Fido2NetLib.Objects;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
@@ -6,39 +7,26 @@ using Newtonsoft.Json;
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
namespace CloudBuilder.Security.Service
|
namespace CloudBuilder.Security.Service
|
||||||
{
|
{
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Wraps the Fido2NetLib ceremony primitives (challenge generation,
|
/// Wraps the Fido2 ceremony primitives (challenge generation,
|
||||||
/// attestation verification, assertion verification) so the process
|
/// attestation verification, assertion verification) so the process
|
||||||
/// layer can stay focused on the workflow. The frontend drives both
|
/// layer can stay focused on the workflow. The frontend drives both
|
||||||
/// registration and login through this service via webmethods on
|
/// registration and login through this service via webmethods on
|
||||||
/// UserProfileProcess.
|
/// UserProfileProcess.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class WebAuthnService: IWebAuthnService
|
public class WebAuthnService : IWebAuthnService
|
||||||
{
|
{
|
||||||
private readonly Fido2 _fido2;
|
private readonly IFido2 _fido2;
|
||||||
private readonly WebAuthnOptions _options;
|
private readonly WebAuthnOptions _options;
|
||||||
|
|
||||||
public WebAuthnService(IOptions<WebAuthnOptions> options)
|
public WebAuthnService(IOptions<WebAuthnOptions> options, IFido2 fido2)
|
||||||
{
|
{
|
||||||
_options = options.Value;
|
_options = options.Value;
|
||||||
|
_fido2 = fido2;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public string Origin => _options.Origin;
|
public string Origin => _options.Origin;
|
||||||
@@ -48,12 +36,9 @@ namespace CloudBuilder.Security.Service
|
|||||||
/// Build the PublicKeyCredentialCreationOptions that the browser
|
/// Build the PublicKeyCredentialCreationOptions that the browser
|
||||||
/// will hand to <c>navigator.credentials.create()</c>.
|
/// will hand to <c>navigator.credentials.create()</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="username">HR+ username (becomes the WebAuthn user.name + user.displayName).</param>
|
|
||||||
/// <param name="userId">Stable per-user byte array. We use the username's UTF-8 bytes — fine for v1 because usernames are unique.</param>
|
|
||||||
/// <param name="existingCredentialIds">If the user is re-enrolling on a new device, the existing credential ID is passed via <c>excludeCredentials</c> so the authenticator refuses to re-enroll the same one. For v1 we don't track this (one-device-only); pass an empty list.</param>
|
|
||||||
public CredentialCreateOptions BuildCreationOptions(string username, byte[] userId, IReadOnlyList<byte[]> existingCredentialIds)
|
public CredentialCreateOptions BuildCreationOptions(string username, byte[] userId, IReadOnlyList<byte[]> existingCredentialIds)
|
||||||
{
|
{
|
||||||
var user = new User
|
var user = new Fido2User
|
||||||
{
|
{
|
||||||
Name = username,
|
Name = username,
|
||||||
Id = userId,
|
Id = userId,
|
||||||
@@ -61,20 +46,23 @@ namespace CloudBuilder.Security.Service
|
|||||||
};
|
};
|
||||||
|
|
||||||
var exclude = existingCredentialIds
|
var exclude = existingCredentialIds
|
||||||
.Select(id => new PublicKeyCredentialDescriptor { Id = id })
|
.Select(id => new PublicKeyCredentialDescriptor(id))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
return _fido2.RequestNewCredential(
|
var authSel = new AuthenticatorSelection
|
||||||
user,
|
|
||||||
exclude,
|
|
||||||
new AuthenticatorSelection
|
|
||||||
{
|
{
|
||||||
// Prefer the platform authenticator (Touch ID / Windows Hello /
|
// Prefer the platform authenticator (Touch ID / Windows Hello / fingerprint)
|
||||||
// fingerprint) over roaming keys.
|
|
||||||
AuthenticatorAttachment = AuthenticatorAttachment.Platform,
|
AuthenticatorAttachment = AuthenticatorAttachment.Platform,
|
||||||
UserVerification = UserVerificationRequirement.Preferred
|
UserVerification = UserVerificationRequirement.Preferred
|
||||||
},
|
};
|
||||||
AttestationConveyancePreference.None);
|
|
||||||
|
return _fido2.RequestNewCredential(new RequestNewCredentialParams
|
||||||
|
{
|
||||||
|
User = user,
|
||||||
|
ExcludeCredentials = exclude,
|
||||||
|
AuthenticatorSelection = authSel,
|
||||||
|
AttestationPreference = AttestationConveyancePreference.None
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -86,29 +74,24 @@ namespace CloudBuilder.Security.Service
|
|||||||
CredentialCreateOptions originalOptions,
|
CredentialCreateOptions originalOptions,
|
||||||
Func<byte[], Task<bool>> isCredentialIdUnique)
|
Func<byte[], Task<bool>> isCredentialIdUnique)
|
||||||
{
|
{
|
||||||
// Fido2NetLib's MakeNewCredentialAsync takes a delegate that, given
|
var makeResult = await _fido2.MakeNewCredentialAsync(new MakeNewCredentialParams
|
||||||
// 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)
|
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(
|
AttestationResponse = attestationResponse,
|
||||||
"Fido2 attestation verification failed: " +
|
OriginalOptions = originalOptions,
|
||||||
(makeResult?.ErrorMessage ?? "unknown error"));
|
IsCredentialIdUniqueToUserCallback = (args, ct) => Task.FromResult(true)
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
var isUnique = await isCredentialIdUnique(makeResult.Id);
|
||||||
|
if (!isUnique)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Credential id duplicate");
|
||||||
}
|
}
|
||||||
|
|
||||||
return new StoredBiometricCredential
|
return new StoredBiometricCredential
|
||||||
{
|
{
|
||||||
CredentialId = success.CredentialId,
|
CredentialId = makeResult.Id,
|
||||||
PublicKey = success.PublicKey
|
PublicKey = makeResult.PublicKey
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,35 +104,27 @@ namespace CloudBuilder.Security.Service
|
|||||||
{
|
{
|
||||||
var allowed = new List<PublicKeyCredentialDescriptor>
|
var allowed = new List<PublicKeyCredentialDescriptor>
|
||||||
{
|
{
|
||||||
new() { Id = storedCredentialId }
|
new PublicKeyCredentialDescriptor(storedCredentialId)
|
||||||
};
|
};
|
||||||
return _fido2.GetAssertionOptions(allowed, UserVerificationRequirement.Preferred);
|
|
||||||
|
return _fido2.GetAssertionOptions(new GetAssertionOptionsParams
|
||||||
|
{
|
||||||
|
AllowedCredentials = allowed,
|
||||||
|
UserVerification = UserVerificationRequirement.Preferred
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Same as <see cref="BuildRequestOptions"/> but returns a
|
/// Same as <see cref="BuildRequestOptions"/> but returns a
|
||||||
/// browser-ready JSON string directly.
|
/// browser-ready JSON string directly.
|
||||||
///
|
|
||||||
/// Fido2NetLib 1.0.0-alpha's <c>AssertionOptions.ToJson()</c> serializes
|
|
||||||
/// <c>AllowCredentials[].Id</c> as PascalCase (the C# property is
|
|
||||||
/// <c>Id</c> with no <c>[JsonProperty("id")]</c> attribute — the
|
|
||||||
/// attribute was added in v1.1.0+). The browser's
|
|
||||||
/// @simplewebauthn/browser <c>toPublicKeyCredentialDescriptor</c>
|
|
||||||
/// does <c>const { id } = descriptor</c>, gets <c>undefined</c>, then
|
|
||||||
/// passes it to <c>base64URLStringToBuffer(id)</c> which does
|
|
||||||
/// <c>id.replace(...)</c> 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, <c>allowCredentials[].id</c> as base64url, <c>type: "public-key"</c>,
|
|
||||||
/// <c>userVerification: "preferred"</c>.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string BuildRequestOptionsJson(byte[] storedCredentialId)
|
public string BuildRequestOptionsJson(byte[] storedCredentialId)
|
||||||
{
|
{
|
||||||
var options = _fido2.GetAssertionOptions(
|
var options = _fido2.GetAssertionOptions(new GetAssertionOptionsParams
|
||||||
new List<PublicKeyCredentialDescriptor> { new() { Id = storedCredentialId } },
|
{
|
||||||
UserVerificationRequirement.Preferred);
|
AllowedCredentials = new List<PublicKeyCredentialDescriptor> { new PublicKeyCredentialDescriptor(storedCredentialId) },
|
||||||
|
UserVerification = UserVerificationRequirement.Preferred
|
||||||
|
});
|
||||||
|
|
||||||
var result = new
|
var result = new
|
||||||
{
|
{
|
||||||
@@ -159,9 +134,9 @@ namespace CloudBuilder.Security.Service
|
|||||||
{
|
{
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
id = Fido2NetLib.Base64Url.Encode(storedCredentialId),
|
id = MyBase64Url.Encode(storedCredentialId),
|
||||||
type = "public-key",
|
type = "public-key",
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
userVerification = options.UserVerification?.ToString().ToLower() ?? "preferred",
|
userVerification = options.UserVerification?.ToString().ToLower() ?? "preferred",
|
||||||
timeout = options.Timeout,
|
timeout = options.Timeout,
|
||||||
@@ -180,35 +155,17 @@ namespace CloudBuilder.Security.Service
|
|||||||
byte[] storedPublicKey,
|
byte[] storedPublicKey,
|
||||||
uint storedCounter)
|
uint storedCounter)
|
||||||
{
|
{
|
||||||
var result = await _fido2.MakeAssertionAsync(
|
var result = await _fido2.MakeAssertionAsync(new MakeAssertionParams
|
||||||
assertionResponse,
|
|
||||||
originalOptions,
|
|
||||||
storedPublicKey,
|
|
||||||
storedCounter,
|
|
||||||
(args) => Task.FromResult(true), // userHandle ownership always true for our single-user case
|
|
||||||
requestTokenBindingId: null);
|
|
||||||
|
|
||||||
if (result is null)
|
|
||||||
{
|
{
|
||||||
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
|
uint newCounter = result.SignCount;
|
||||||
// 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");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
if (newCounter <= storedCounter)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
|
|||||||
@@ -7,23 +7,24 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Fido2" Version="4.0.1" />
|
||||||
|
<PackageReference Include="Fido2.AspNet" Version="4.0.1" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.0" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authorization" Version="8.0.0" />
|
<PackageReference Include="Microsoft.AspNetCore.Authorization" Version="8.0.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="8.0.0" />
|
<PackageReference Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="8.0.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.0" />
|
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.0" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.0" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.0" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.0" />
|
||||||
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||||
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.0" />
|
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
|
||||||
<PackageReference Include="Microsoft.VisualStudio.OLE.Interop" Version="17.8.37221" />
|
<PackageReference Include="Microsoft.VisualStudio.OLE.Interop" Version="17.8.37221" />
|
||||||
<PackageReference Include="RazorEngineCore" Version="2023.11.2" />
|
<PackageReference Include="RazorEngineCore" Version="2023.11.2" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyModel" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.DependencyModel" Version="9.0.0" />
|
||||||
<PackageReference Include="Fido2NetLib" Version="1.0.0-alpha" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using System.Linq;
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using CloudBuilder.Security.Data;
|
using CloudBuilder.Security.Data;
|
||||||
|
using CloudBuilder.Core.Application.Options;
|
||||||
|
|
||||||
namespace CloudBuilder.Security.Service
|
namespace CloudBuilder.Security.Service
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,26 +10,29 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="CliWrap" Version="3.6.6" />
|
<PackageReference Include="CliWrap" Version="3.6.6" />
|
||||||
|
<PackageReference Include="Fido2" Version="4.0.1" />
|
||||||
|
<PackageReference Include="Fido2.AspNet" Version="4.0.1" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.0" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="8.0.0" />
|
<PackageReference Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="8.0.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authorization" Version="8.0.0" />
|
<PackageReference Include="Microsoft.AspNetCore.Authorization" Version="8.0.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.0" />
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.0" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.0" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.0" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.0" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.0" />
|
||||||
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.22.1" />
|
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.22.1" />
|
||||||
<PackageReference Include="Microsoft.VisualStudio.OLE.Interop" Version="17.8.37221" />
|
<PackageReference Include="Microsoft.VisualStudio.OLE.Interop" Version="17.8.37221" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyModel" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.DependencyModel" Version="9.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.0" />
|
||||||
<PackageReference Include="ClosedXML" Version="0.102.1" />
|
<PackageReference Include="ClosedXML" Version="0.102.1" />
|
||||||
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||||
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.0" />
|
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.0" />
|
||||||
<PackageReference Include="System.Drawing.Common" Version="8.0.2" />
|
<PackageReference Include="System.Drawing.Common" Version="8.0.2" />
|
||||||
<PackageReference Include="Fido2NetLib" Version="1.0.0-alpha" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -14,9 +14,7 @@ using System.Text.Json.Serialization;
|
|||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
|
||||||
builder.Services.Initialize();
|
builder.Services.Initialize();
|
||||||
builder.Services.Configure<WebAuthnOptions>(builder.Configuration.GetSection("WebAuthn"));
|
|
||||||
|
|
||||||
// Add services to the container.
|
// Add services to the container.
|
||||||
builder.Services.AddHttpClient().AddControllers()
|
builder.Services.AddHttpClient().AddControllers()
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<_PublishTargetUrl>C:\inetpub\wwwroot\dockwebapi2</_PublishTargetUrl>
|
<_PublishTargetUrl>C:\inetpub\wwwroot\dockwebapi2</_PublishTargetUrl>
|
||||||
<History>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||;</History>
|
<History>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||;</History>
|
||||||
<LastFailureDetails />
|
<LastFailureDetails />
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
</Project>
|
</Project>
|
||||||
@@ -45,6 +45,13 @@
|
|||||||
"Redis": {
|
"Redis": {
|
||||||
"Configuration": "localhost",
|
"Configuration": "localhost",
|
||||||
"InstanceName": "CloudBuilder:"
|
"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": {
|
"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": "*"
|
"AllowedHosts": "*"
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user