ow
This commit is contained in:
1 parent
3461a2e921
commit
ddfbd7d0f2
17 files changed
+523
-241
No files matched your search
@@ -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<AuthenticatorAttestationRawResponse>(dto.AttestationJson);
|
||||
AuthenticatorAttestationRawResponseModel oldModel = JsonConvert.DeserializeObject<AuthenticatorAttestationRawResponseModel>(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<AuthenticatorAssertionRawResponse>(dto.AssertionJson);
|
||||
|
||||
AuthenticatorAssertionRawResponseModel model =
|
||||
JsonConvert.DeserializeObject<AuthenticatorAssertionRawResponseModel>(dto.AssertionJson);
|
||||
|
||||
raw = AssertionModelConverter.ToLibAuthenticatorAssertionRawResponse(model);
|
||||
|
||||
//raw = JsonConvert.DeserializeObject<AuthenticatorAssertionRawResponse>(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<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) =====
|
||||
//
|
||||
@@ -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<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)]
|
||||
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<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);
|
||||
}
|
||||
}
|
||||
*/
|
||||
}
|
||||
Reference in new issue
Block a user