This commit is contained in:
owenchen committed 2026-08-06 13:15:56 +08:00
1 parent 7eb017d8ee
commit 873f65dbbe
48 files changed
+1391 -1236

No files matched your search

+516 -13
View File
@@ -13,16 +13,22 @@ using CloudBuilder.Security.Publisher;
using CloudBuilder.Security.Service.Authorization;
using CloudBuilder.Security.Service.Publisher;
using DocumentFormat.OpenXml.Spreadsheet;
using Fido2NetLib;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
using Newtonsoft.Json;
using System.ComponentModel.DataAnnotations;
using System.IdentityModel.Tokens.Jwt;
using System.Runtime;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using RSA = System.RSA;
namespace CloudBuilder.Security.Service
{
@@ -32,13 +38,15 @@ namespace CloudBuilder.Security.Service
private readonly IHttpContextAccessor httpContextAccessor;
private readonly ILocaleMessageManager localeMessageManager;
private readonly IContext context;
private readonly IWebAuthnService webAuthnService;
private IRepository<SecurityUserEntity> repository;
public AuthService(IApplicationService service, IHttpContextAccessor httpContextAccessor, ILocaleMessageManager localeMessageManager, IContext context)
public AuthService(IApplicationService service, IHttpContextAccessor httpContextAccessor, ILocaleMessageManager localeMessageManager, IContext context, IWebAuthnService webAuthnService)
{
this.service = service;
this.httpContextAccessor = httpContextAccessor;
this.localeMessageManager = localeMessageManager;
this.context = context;
this.webAuthnService = webAuthnService;
repository = service.GetRepository<IRepository<SecurityUserEntity>>();
}
@@ -60,7 +68,7 @@ namespace CloudBuilder.Security.Service
var descKey = service.Configuration[SecuritySettingsPolicy.SecuritySettings_DescKey];
var privateKey = service.Configuration[SecuritySettingsPolicy.SecuritySettings_RsaPrivateKey];
var rsa = new RSA(privateKey, true);
var rsa = new System.RSA(privateKey, true);
var psd = rsa.DecodeOrNull(data.RsaUserPassword);
if (string.IsNullOrEmpty(psd)) throw new ValidatedException(certification);
@@ -96,6 +104,19 @@ namespace CloudBuilder.Security.Service
throw new ValidatedException(certification);
}
securityUserEntity.ErrorLoginCount = 0;
securityUserEntity.IsAccountsLockedIndc = YesNoPolicy.NO;
securityUserEntity.AccountsLockedDatetime = null;
//更新最后登录日期
securityUserEntity.LastLoginDatetime = DateTime.Now;
repository.UpdateNow(securityUserEntity);
return PackLoginProfile(securityUserEntity, data.Culture);
}
private LoginProfile PackLoginProfile(SecurityUserEntity securityUserEntity, string culture)
{
JwtGenerate jwtGenerate = service.ServiceProvider.GetService<JwtGenerate>()!;
string clainmName = Guid.NewGuid().ToString();
@@ -103,7 +124,7 @@ namespace CloudBuilder.Security.Service
claims.Add(ClaimPolicy.CLAINM_NAME, clainmName);
claims.Add(ClaimPolicy.CLAINM_ACCOUNT, securityUserEntity.UserMobile!);
claims.Add(ClaimPolicy.CLAINM_USERID, securityUserEntity.UserName);
claims.Add(ClaimPolicy.CLAINM_LOCALE, data.Culture);
claims.Add(ClaimPolicy.CLAINM_LOCALE, culture);
var accessToken = jwtGenerate.Generate(claims);
if (httpContextAccessor.HttpContext.Request.Headers.ContainsKey("Authorization"))
@@ -112,18 +133,10 @@ namespace CloudBuilder.Security.Service
if (httpContextAccessor.HttpContext.Request.Headers.ContainsKey(ClaimPolicy.HEADER_LOCALE))
httpContextAccessor.HttpContext.Request.Headers.Remove(ClaimPolicy.HEADER_LOCALE);
httpContextAccessor.HttpContext.Request.Headers.Add(ClaimPolicy.HEADER_LOCALE, data.Culture);
httpContextAccessor.HttpContext.Request.Headers.Add(ClaimPolicy.HEADER_LOCALE, culture);
service.UserId = securityUserEntity.UserName;
securityUserEntity.ErrorLoginCount = 0;
securityUserEntity.IsAccountsLockedIndc = YesNoPolicy.NO;
securityUserEntity.AccountsLockedDatetime = null;
//更新最后登录日期
securityUserEntity.LastLoginDatetime = DateTime.Now;
repository.UpdateNow(securityUserEntity);
IRepository<SecurityUserWebLoginEntity> repositoryLog = service.GetRepository<IRepository<SecurityUserWebLoginEntity>>();
SecurityUserWebLoginEntity webLogin = new SecurityUserWebLoginEntity
@@ -445,7 +458,7 @@ namespace CloudBuilder.Security.Service
claims.Add(ClaimPolicy.CLAINM_NAME, clainmName);
claims.Add(ClaimPolicy.CLAINM_ACCOUNT, userMobile);
claims.Add(ClaimPolicy.CLAINM_USERID, username);
claims.Add(ClaimPolicy.CLAINM_LOCALE, locale??CulturePolicy.ZH_CN);
claims.Add(ClaimPolicy.CLAINM_LOCALE, locale ?? CulturePolicy.ZH_CN);
var accessToken = jwtGenerate.Generate(claims);
@@ -484,5 +497,495 @@ namespace CloudBuilder.Security.Service
repositoryLog.UpdateNow(log);
}
// ===== Biometric / WebAuthn login (fingerprint, face, device PIN) =====
// Two-phase registration + two-phase assertion. The browser drives
// navigator.credentials.create() / navigator.credentials.get(); we
// generate challenges server-side, verify the response, and persist
// the credential on the user. Login re-uses LoginProfileFactory so
// the JWT + SecurityUserWebLogin flow is identical to the password
// login in AuthProcess.
// Verifies the password of the currently-authenticated user.
// Used as a step-up authentication gate before sensitive
// operations like enabling biometric login on a new device.
//
// The username is taken from `loginCredential` (the JWT/cookie
// identity), not from the request body — the request only carries
// the password. This means:
// 1. A valid JWT is required to even hit this method.
// 2. The user can only verify their OWN password, not someone
// else's (even if they craft a request with a different
// username, that field is ignored).
// 3. No new session is minted — the existing one is preserved.
//
// Returns true if the password matches, throws ValidationException
// otherwise. The frontend treats the exception as "wrong password".
[PermissionOperate(name: SecurityPermissionPolicy.SecurityUser, operate: Operate.Find)]
public bool ConfirmPassword(string password)
{
if (string.IsNullOrWhiteSpace(password))
throw new ValidationException("password is 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.");
var descKey = service.Configuration[SecuritySettingsPolicy.SecuritySettings_DescKey];
var privateKey = service.Configuration[SecuritySettingsPolicy.SecuritySettings_RsaPrivateKey];
var rsa = new System.RSA(privateKey, true);
var psd = rsa.DecodeOrNull(password);
if (string.IsNullOrEmpty(psd)) return false;
return user.UserPassword == psd.Substring(36, psd.Length - 36);
}
[PermissionOperate(name: SecurityPermissionPolicy.SecurityUser, operate: Operate.Find)]
public string BeginBiometricRegistration(string deviceName)
{
try
{
if (string.IsNullOrWhiteSpace(deviceName))
deviceName = "Unnamed device";
var username = service.UserId;// loginCredential.GetUsername();
if (string.IsNullOrWhiteSpace(username))
throw new ValidationException("User not found.");
var userIdBytes = Encoding.UTF8.GetBytes(username);
// v1: one credential per user — exclude list is empty.
var options = webAuthnService.BuildCreationOptions(username, userIdBytes, Array.Empty<byte[]>());
return options.ToJson();
}
catch (Exception ex)
{
//logger.LogError("BeginBiometricRegistration ERROR: " + ex.ToString());
throw;
}
}
[PermissionOperate(name: SecurityPermissionPolicy.SecurityUser, operate: Operate.Find)]
public BiometricRegistrationResultDto FinishBiometricRegistration(FinishBiometricRegistrationData dto)
{
if (dto == null || string.IsNullOrWhiteSpace(dto.AttestationJson) || string.IsNullOrWhiteSpace(dto.CreationOptionsJson))
throw new ValidationException("attestationJson and creationOptionsJson are required");
var username = service.UserId;// loginCredential.GetUsername();
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.");
// Diagnostic: log the raw JSON we received so we can diagnose
// Fido2NetLib "Expected rawResponse, got null" failures. The
// Fido2NetLib 1.0.0-alpha VerifyAsync checks `Raw == null` at
// the top, before any field-level checks — if it fires, the
// wrapper was constructed with a null raw, which means either
// (a) CredentialCreateOptions.FromJson returned null and the
// constructor blew up, or (b) the C# deserializer produced a
// shell object whose required properties are all null.
//logger.LogInformation(
// "FinishBiometricRegistration_DIAG attestationJson[0..400]={att} creationOptionsJson[0..200]={opts}",
// dto.AttestationJson.Substring(0, Math.Min(400, dto.AttestationJson.Length)),
// dto.CreationOptionsJson.Substring(0, Math.Min(200, dto.CreationOptionsJson.Length)));
CredentialCreateOptions originalOptions;
try
{
originalOptions = CredentialCreateOptions.FromJson(dto.CreationOptionsJson);
}
catch (Exception ex)
{
throw new ValidationException("Invalid creationOptionsJson: " + ex.Message);
}
if (originalOptions == null)
throw new ValidationException("CredentialCreateOptions.FromJson returned null (creationOptionsJson parsed as the JSON literal 'null' or empty)");
AuthenticatorAttestationRawResponse raw;
try
{
//logger.LogInformation("DTO: " + dto.AttestationJson);
// MUST use Newtonsoft.Json here, not System.Text.Json. Fido2NetLib's
// AuthenticatorAttestationRawResponse relies on Newtonsoft.Json-specific
// attributes that System.Text.Json does not honor:
// * [JsonConverter(typeof(Base64UrlConverter))] on Id/RawId/AttestationObject/ClientDataJson
// (converts base64url strings to byte[]; System.Text.Json would leave them as strings)
// * [JsonConverter(typeof(StringEnumConverter))] on PublicKeyCredentialType.Type
// with [EnumMember(Value = "public-key")] on the PublicKey member
//
// And Fido2NetLib's AuthenticatorAttestationResponse.Parse throws
// "Expected rawResponse, got null" when rawResponse.Response is null —
// which is exactly what System.Text.Json produces, because it is
// 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);
}
catch (Exception ex)
{
throw new ValidationException("Invalid attestationJson: " + ex.Message);
}
if (raw == null) throw new ValidationException("Invalid attestationJson");
if (raw.Response == null) throw new ValidationException("raw.Response is null after deserialization — the browser's JSON didn't bind to Fido2NetLib's Response property");
// Diagnostic: log what Fido2NetLib will see. If any of these
// are null, Fido2NetLib's VerifyAsync will throw the cryptic
// "Expected rawResponse, got null" or "Id is missing" / "RawId
// is missing" / "clientDataJson cannot be null" etc.
//logger.LogInformation(
// "FinishBiometricRegistration_DIAG raw.Id={id} raw.RawId={rawId} raw.Type={type} raw.Response.AttestationObject={ao} raw.Response.ClientDataJson={cdj}",
// raw.Id,
// raw.RawId,
// raw.Type,
// raw.Response?.AttestationObject == null ? "<null>" : $"<{raw.Response.AttestationObject.Length} chars>",
// raw.Response?.ClientDataJson == null ? "<null>" : $"<{raw.Response.ClientDataJson.Length} chars>");
// Fido2NetLib verifies signature, certificate chain, challenge,
// origin, and rpId. We only need to await it.
var stored = webAuthnService
.FinishRegistrationAsync(raw, originalOptions, _ => Task.FromResult(true))
.GetAwaiter().GetResult();
// Persist on the user. v1 = one credential per user: just
// overwrite. (The excludeCredentials list passed to
// BuildCreationOptions is empty, so the authenticator will
// refuse to re-enroll the same key on the same device — but
// re-enrolling on a new device overwrites. That's intentional.)
//
// SecurityUser.BiometricCredentialId / BiometricPublicKey are
// `string` columns (per the request to keep the schema as-is).
// Fido2NetLib gives us `byte[]`; we encode with base64url so
// the stored value is directly usable as the credential id
// (matches the format returned to the browser in
// 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.BiometricCounter = 0;
user.BiometricDeviceName = dto.AttestationJson.Length > 0 ? "(set on the device)" : null;
user.BiometricEnrolledAt = DateTime.Now;
user.BiometricLastUsedAt = null;
repository.UpdateNow(user);
return new BiometricRegistrationResultDto
{
CredentialIdBase64Url = Fido2NetLib.Base64Url.Encode(stored.CredentialId)
};
}
[AllowAnonymous]
public BiometricLoginChallengeDto BeginBiometricLogin(string username)
{
if (string.IsNullOrWhiteSpace(username))
throw new ValidationException("username is required");
var user = repository.DetachedEntities.FirstOrDefault(x => x.UserName == username);
if (user == null)
throw new ValidationException("User not found.");
if (user.BiometricCredentialId == null || user.BiometricCredentialId.Length == 0)
throw new ValidationException("Biometric not enrolled for this user");
// user.BiometricCredentialId is stored as base64url (see
// FinishBiometricRegistration). Decode back to byte[] for
// Fido2NetLib's PublicKeyCredentialDescriptor.Id.
// BuildRequestOptionsJson bypasses Fido2NetLib 1.0.0-alpha's
// PascalCase serializer. See WebAuthnService.BuildRequestOptionsJson
// for why the wire shape has to be hand-built.
var optionsJson = webAuthnService.BuildRequestOptionsJson(
Fido2NetLib.Base64Url.Decode(user.BiometricCredentialId));
return new BiometricLoginChallengeDto
{
RequestOptionsJson = optionsJson,
ChallengeId = Guid.NewGuid().ToString()
};
}
[AllowAnonymous]
public LoginProfile FinishBiometricLogin(FinishBiometricLoginData dto)
{
if (dto == null || string.IsNullOrWhiteSpace(dto.Username) || string.IsNullOrWhiteSpace(dto.AssertionJson) || string.IsNullOrWhiteSpace(dto.RequestOptionsJson))
throw new ValidationException("username, assertionJson, and requestOptionsJson are required");
var user = repository.DetachedEntities.FirstOrDefault(x => x.UserMobile == dto.Username || x.UserName == dto.Username);
if (user == null)
throw new ValidationException("User not found.");
if (user.BiometricCredentialId == null || user.BiometricPublicKey == null)
throw new ValidationException("Biometric not enrolled for this user");
AssertionOptions originalOptions;
try
{
// dto.RequestOptionsJson arrives double-JSON-encoded: the
// backend returns the options as a JSON string (e.g.
// '{"challenge":"...","rpId":"..."}'
// ), and the frontend then does JSON.stringify() on that
// string, wrapping it in another layer of quotes:
// '"{\"challenge\":\"...\",\"rpId\":\"..."}"'
// Fido2NetLib's AssertionOptions.FromJson then tries to
// deserialize that as an AssertionOptions object and throws
// "Could not cast or convert from System.String to
// Fido2NetLib.AssertionOptions" because it sees a string
// token at the top level.
//
// Unwrap one layer with JToken.Parse: if the result is a
// JValue (the JSON-encoded string case), parse its string
// value to get the inner JSON. If it's already a JObject
// (no double-encoding), just use ToString().
var token = Newtonsoft.Json.Linq.JToken.Parse(dto.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("FinishBiometricLogin_ERROR_1: " + ex.ToString());
throw new ValidationException("Invalid requestOptionsJson: " + ex.Message);
}
AuthenticatorAssertionRawResponse raw;
try
{
// Fido2NetLib 1.0.0-alpha declares Id/RawId/Response.* as `string`
// (no [JsonConverter] — that was added in v1.1.0+), and internally
// does (byte[])raw.Id to look up the credential. The (byte[]) cast
// throws "Unable to cast object of type 'System.String' to type
// 'System.Byte[]'" on the assertion flow (the registration flow
// doesn't hit it because its Parse() path only touches
// Response.AttestationObject, never Id/RawId).
//
// Fix: deserialize normally with Newtonsoft.Json, then patch the
// base64url string fields to byte[] via reflection. If the C#
// property is byte[] (1.1.0+ shape) this is a no-op. If it's
// 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);
WebAuthnAssertionPatcher.PatchResponseToByteArrays(raw);
// DIAG: log the actual runtime property type so we can confirm
// the shape of the Fido2NetLib 1.0.0-alpha class and choose the
// right patching strategy.
//logger.LogInformation(
// "FinishBiometricLogin_DIAG Id={idType} RawId={rawIdType} Response.Id={respIdType} Response.AuthenticatorData={adType} Response.Signature={sigType} Response.ClientDataJson={cdjType}",
// typeof(AuthenticatorAssertionRawResponse).GetProperty("Id")?.PropertyType.Name,
// typeof(AuthenticatorAssertionRawResponse).GetProperty("RawId")?.PropertyType.Name,
// typeof(AuthenticatorAssertionRawResponse.AssertionResponse).GetProperty("Id")?.PropertyType.Name,
// typeof(AuthenticatorAssertionRawResponse.AssertionResponse).GetProperty("AuthenticatorData")?.PropertyType.Name,
// typeof(AuthenticatorAssertionRawResponse.AssertionResponse).GetProperty("Signature")?.PropertyType.Name,
// typeof(AuthenticatorAssertionRawResponse.AssertionResponse).GetProperty("ClientDataJson")?.PropertyType.Name);
}
catch (Exception ex)
{
//logger.LogError("FinishBiometricLogin_ERROR_2: " + ex.ToString());
throw new ValidationException("Invalid assertionJson: " + ex.Message);
}
if (raw == null)
{
//logger.LogError("FinishBiometricLogin_ERROR_3:");
throw new ValidationException("Invalid assertionJson");
}
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();
// Persist counter + last-used timestamp.
user.BiometricCounter = (int)newCounter;
user.BiometricLastUsedAt = DateTime.Now;
repository.UpdateNow(user);
// Mint the session the same way AuthProcess.Login does.
return PackLoginProfile(user, CulturePolicy.ZH_CN);
}
catch (Exception ex)
{
//logger.LogError("FinishBiometricLogin_ERROR_4: " + ex.ToString());
throw;
}
}
// ===== Epayslip biometric verification (step-up auth, not a new login) =====
//
// BeginBiometricLogin / FinishBiometricLogin mint a new JWT session
// (they are the password-less login flow). The two webmethods below
// reuse the same WebAuthn ceremony against the CURRENTLY-authenticated
// user's stored credential, but instead of issuing a fresh profile
// they just stamp a 5-minute unlock in EpayslipBiometricUnlockStore
// so SalaryTaxWebProcess.SalaryTax can accept biometricVerified=true
// as a substitute for the epayslip password.
//
// Both webmethods require the user to be logged in (the auto-generated
// UserProfileController has [Authorize] at the class level) AND
// require biometric_for_epayslip=1 -- we re-check the server flag
// here even though the frontend gates on it, so a user cannot
// accidentally hit Begin from a stale tab after disabling the flag.
// Returns the WebAuthn request options JSON for the current user's
// stored credential. The frontend parses the string and passes it to
// @simplewebauthn/browser's startAuthentication.
//
// Wrapped in EpayslipBiometricAssertionChallengeDto (not returned
// as a raw string) so the auto-generated TS service stub's
// resp.requestOptionsJson mirrors the BiometricLoginChallengeDto
// contract used by LoginView. A raw-string return would serialize
// as a JSON-encoded quoted string and confuse the frontend's
// JSON.parse step.
public BiometricLoginChallengeDto BeginEpayslipBiometricAssertion()
{
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.");
// Defense in depth: refuse to issue a challenge if the user
// has not enrolled biometric login or has not opted in to
// biometric-for-epayslip.
if (string.IsNullOrEmpty(user.BiometricCredentialId))
throw new ValidationException("Biometric login must be enabled first");
// user.BiometricCredentialId is stored as base64url (see
// FinishBiometricRegistration). Decode back to byte[] for
// Fido2NetLib's PublicKeyCredentialDescriptor.Id.
return new BiometricLoginChallengeDto
{
RequestOptionsJson = webAuthnService.BuildRequestOptionsJson(
Fido2NetLib.Base64Url.Decode(user.BiometricCredentialId))
};
}
// Verifies the assertion produced by the browser from
// BeginEpayslipBiometricAssertion's challenge. On success stamps
// the unlock store for 5 minutes -- SalaryTax can then accept
// biometricVerified=true instead of the epayslip password hash.
//
// Same Newtonsoft + double-JSON-decode dance as FinishBiometricLogin
// (see that method's comments for why Newtonsoft specifically and
// 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()
{
var username = service.UserId;// loginCredential.GetUsername();
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.");
user.BiometricCredentialId = null;
user.BiometricPublicKey = null;
user.BiometricCounter = 0;
user.BiometricDeviceName = null;
user.BiometricEnrolledAt = null;
user.BiometricLastUsedAt = null;
repository.UpdateNow(user);
}
}
}
@@ -27,6 +27,7 @@
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyModel" Version="8.0.0" />
<PackageReference Include="Fido2NetLib" Version="1.0.0-alpha" />
</ItemGroup>
<ItemGroup>
@@ -0,0 +1,92 @@
using Fido2NetLib;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace CloudBuilder.Security.Service
{
/// <summary>
/// Re-encodes the base64url string fields on a freshly-deserialized
/// <see cref="AuthenticatorAssertionRawResponse"/> as <c>byte[]</c>.
///
/// Fido2NetLib 1.0.0-alpha has these properties typed as <c>string</c>
/// (no [JsonConverter] — that was added in v1.1.0+), but its
/// <c>MakeAssertionAsync</c> internally does
/// <c>(byte[])raw.Id</c> and
/// <c>(byte[])raw.Response.ClientDataJson</c> to look up the credential
/// and verify the signature. The hard cast throws
/// "Unable to cast object of type 'System.String' to type 'System.Byte[]'".
///
/// This helper was originally a private static method on
/// <c>UserProfileProcess</c>; it was extracted so
/// <c>FinishEpayslipBiometricAssertion</c> (the new epayslip-unlock
/// flow) can share the same Fido2NetLib 1.0.0-alpha workaround without
/// duplicating the reflection-patching logic.
///
/// Behaviour:
/// 1. Checks the runtime property type.
/// 2. If it's already <c>byte[]</c> (v1.1.0+), base64url-decodes the
/// incoming string and stores the bytes.
/// 3. If it's <c>string</c> (v1.0.0-alpha), we can't store <c>byte[]</c>
/// in a <c>string</c> field — CLR enforces type safety. Fido2NetLib's
/// internal (byte[]) cast will then throw on the next call with a
/// clear InvalidCastException we can diagnose from the stack trace.
/// </summary>
public static class WebAuthnAssertionPatcher
{
public static void PatchResponseToByteArrays(AuthenticatorAssertionRawResponse raw)
{
if (raw == null) return;
PatchStringPropertyToByteArray(raw, "Id");
PatchStringPropertyToByteArray(raw, "RawId");
if (raw.Response != null)
{
PatchStringPropertyToByteArray(raw.Response, "Id");
PatchStringPropertyToByteArray(raw.Response, "AuthenticatorData");
PatchStringPropertyToByteArray(raw.Response, "Signature");
PatchStringPropertyToByteArray(raw.Response, "ClientDataJson");
PatchStringPropertyToByteArray(raw.Response, "UserHandle");
}
}
private static void PatchStringPropertyToByteArray(object target, string propertyName)
{
if (target == null) return;
var prop = target.GetType().GetProperty(propertyName);
if (prop == null) return;
if (!prop.CanWrite) return;
var current = prop.GetValue(target);
// If Fido2NetLib 1.1.0+ shape — property is already byte[].
if (prop.PropertyType == typeof(byte[]))
{
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)); }
catch { /* leave original value; Fido2NetLib will surface a clearer error */ }
}
return;
}
// If the property is `object` (1.0.0-alpha may store the raw
// deserialized value as object), we can safely put a byte[] in it.
if (prop.PropertyType == typeof(object))
{
if (current is string s && !string.IsNullOrEmpty(s))
{
try { prop.SetValue(target, Fido2NetLib.Base64Url.Decode(s)); } catch { }
}
return;
}
// If the property is `string` (1.0.0-alpha default), we cannot
// store byte[] in a string-typed field — CLR enforces type safety
// even via reflection. Fido2NetLib's internal (byte[]) cast will
// throw on the next call. The caller will see a clear
// InvalidCastException that we can diagnose from the stack trace.
}
}
}
@@ -0,0 +1,221 @@
using CloudBuilder.Security.Data;
using Fido2NetLib;
using Fido2NetLib.Objects;
using Microsoft.Extensions.Options;
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace CloudBuilder.Security.Service
{
/// <summary>
/// Wraps the Fido2NetLib 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.
/// </summary>
public class WebAuthnService: IWebAuthnService
{
private readonly Fido2 _fido2;
private readonly WebAuthnOptions _options;
public WebAuthnService(IOptions<WebAuthnOptions> options)
{
_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);
}
public string Origin => _options.Origin;
public string RpId => _options.RpId;
/// <summary>
/// Build the PublicKeyCredentialCreationOptions that the browser
/// will hand to <c>navigator.credentials.create()</c>.
/// </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)
{
var user = new User
{
Name = username,
Id = userId,
DisplayName = username
};
var exclude = existingCredentialIds
.Select(id => new PublicKeyCredentialDescriptor { Id = 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);
}
/// <summary>
/// Verify the attestation returned by the browser and return the
/// stored credential fields. Throws on validation failure.
/// </summary>
public async Task<StoredBiometricCredential> FinishRegistrationAsync(
AuthenticatorAttestationRawResponse attestationResponse,
CredentialCreateOptions originalOptions,
Func<byte[], Task<bool>> 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)
{
throw new InvalidOperationException(
"Fido2 attestation verification failed: " +
(makeResult?.ErrorMessage ?? "unknown error"));
}
return new StoredBiometricCredential
{
CredentialId = success.CredentialId,
PublicKey = success.PublicKey
};
}
/// <summary>
/// Build the PublicKeyCredentialRequestOptions that the browser will
/// hand to <c>navigator.credentials.get()</c>. Only the stored
/// credential for this user is allowed.
/// </summary>
public AssertionOptions BuildRequestOptions(byte[] storedCredentialId)
{
var allowed = new List<PublicKeyCredentialDescriptor>
{
new() { Id = storedCredentialId }
};
return _fido2.GetAssertionOptions(allowed, UserVerificationRequirement.Preferred);
}
/// <summary>
/// Same as <see cref="BuildRequestOptions"/> but returns a
/// 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>
public string BuildRequestOptionsJson(byte[] storedCredentialId)
{
var options = _fido2.GetAssertionOptions(
new List<PublicKeyCredentialDescriptor> { new() { Id = storedCredentialId } },
UserVerificationRequirement.Preferred);
var result = new
{
challenge = options.Challenge,
rpId = options.RpId,
allowCredentials = new[]
{
new
{
id = Fido2NetLib.Base64Url.Encode(storedCredentialId),
type = "public-key",
},
},
userVerification = options.UserVerification?.ToString().ToLower() ?? "preferred",
timeout = options.Timeout,
};
return JsonConvert.SerializeObject(result);
}
/// <summary>
/// Verify the assertion returned by the browser. Returns the new
/// signature counter to persist. Throws on validation failure.
/// </summary>
public async Task<uint> FinishAssertionAsync(
AuthenticatorAssertionRawResponse assertionResponse,
AssertionOptions originalOptions,
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)
{
throw new InvalidOperationException("Fido2 assertion verification returned null");
}
// 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");
}
// 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(
$"WebAuthn counter regression: stored={storedCounter}, new={newCounter}");
}
return newCounter;
}
}
}