using Fido2NetLib; using Jupiter.Data; using Jupiter.Data.Entities; using Jupiter.Data.Model.eleave.Profile; using Jupiter.Message; using Jupiter.Policy; using Jupiter.Services; using jupiter.maintenance.process.epaySlip; using LRAF.Exceptions; using LRAF.Extension; using LRAF.Security; using LRAF.Util; using LRAF.Xaf.Commander; using Microsoft.Extensions.Localization; using Microsoft.Extensions.Logging; using System.IO; using System.Text; using Newtonsoft.Json; namespace Jupiter.Process.Profile; public class UserProfileProcess { readonly EleaveDataContext dataContext; readonly IStringLocalizer stringLocalizer; private readonly ILoginCredential loginCredential; private readonly XConfiguration configuration; private readonly WebAuthnService webAuthnService; private readonly LoginProfileFactory loginProfileFactory; private readonly EpayslipBiometricUnlockStore epayslipUnlockStore; private readonly XafCommandConnector commandConnector; private readonly ILogger logger; public UserProfileProcess( EleaveDataContext dataContext, IStringLocalizer stringLocalizer, ILoginCredential loginCredential, XConfiguration configuration, WebAuthnService webAuthnService, LoginProfileFactory loginProfileFactory, EpayslipBiometricUnlockStore epayslipUnlockStore, XServiceProvider serviceProvider, ILogger logger ) { this.dataContext = dataContext; this.stringLocalizer = stringLocalizer; this.loginCredential = loginCredential; this.configuration = configuration; this.webAuthnService = webAuthnService; this.loginProfileFactory = loginProfileFactory; this.epayslipUnlockStore = epayslipUnlockStore; this.commandConnector = new XafCommandConnector(serviceProvider); this.logger = logger; } //@webmethod public UserProfileDisplayData FindUserProfileModel(string username) { var user = dataContext.SecurityUsers.SingleOrDefault(x => x.Username == username); if (user == null) throw new ValidationException(this.stringLocalizer.GetString(WebMessages.UserNotFound)); var department = string.IsNullOrWhiteSpace(user.Department) ? null : dataContext.Departments.FirstOrDefault(d => d.DepartmentId == user.Department); var company = department == null ? null : dataContext.Companies.FirstOrDefault(c => c.CompanyId == department.CompanyId); return new UserProfileDisplayData() { Username = user.Username, FullNameEng = user.FullNameEng, FullNameLocal = user.FullNameLocal, Email = user.Email, Tel = user.Tel, CompanyName = company?.NameEng ?? string.Empty, DepartmentName = department?.NameEng ?? string.Empty, AvatarUrl = string.IsNullOrEmpty(user.Avatar) ? null : $"/img/avatar/{user.Username}/{user.Avatar}", BiometricForEpayslip = user.BiometricForEpayslip, }; } //@webmethod public void ChangePassword(UserProfilePasswordInput input) { var user = dataContext.SecurityUsers.FirstOrDefault(x => x.Username == input.Username && x.PasswordHash == MD5.Hash(input.OldPassword)); if (user == null) throw new ValidationException(this.stringLocalizer.GetString(WebMessages.OldPasswordNotMatched)); user.PasswordHash = MD5.Hash(input.NewPassword); dataContext.Update(user); dataContext.SaveChanges(true); } // Moves the avatar file the frontend uploaded to the DMS temp folder // into the user's permanent avatar location on the DMS file server, // then records the resulting filename in security_user.avatar so the // auth flow can wire it onto LoginProfile.parameters on next login. // // Frontend flow: // 1. POST api/file/uploadFile?tempFolder=avatar/{uuid} (file lands in temp) // 2. POST api/userProfile/UploadAvatar { uuid, fileName } // 3. The webmethod moves temp/.../avatar/{uuid}/{fileName} // → dms/avatar/{username}/current{ext} // and writes "current{ext}" into security_user.avatar. // // Static serving URL (already wired in Program.cs): // /img/AVATAR/{username}/current{ext} //@webmethod public string UploadAvatar(string uuid, string fileName) { if (string.IsNullOrWhiteSpace(uuid) || string.IsNullOrWhiteSpace(fileName)) throw new ValidationException("uuid and fileName are required"); var username = loginCredential.GetUsername(); if (string.IsNullOrWhiteSpace(username)) throw new ValidationException(this.stringLocalizer.GetString(WebMessages.UserNotFound)); var user = dataContext.SecurityUsers.FirstOrDefault(x => x.Username == username); if (user == null) throw new ValidationException(this.stringLocalizer.GetString(WebMessages.UserNotFound)); var fileServer = this.configuration.GetFileserver("dms"); var localTempPath = this.configuration.GetTempPath(); // Source: {tempPath}/{currentUsername}/avatar/{uuid}/{fileName} var ext = Path.GetExtension(fileName); string sourcePath = Path.Combine(localTempPath, username, DmsFileCategoryPolicy.AVATAR_FILE_CATEGORY, uuid, fileName); // Destination: stable filename so subsequent uploads overwrite // the same path. Extension is preserved so the browser renders // the right MIME type. string storedFileName = username + ext; string destinationPath = fileServer.GetFullPath( DmsFileCategoryPolicy.AVATAR_FILE_CATEGORY + "\\" + storedFileName ).Replace("/", "\\"); if (!fileServer.FileExists(sourcePath)) throw new ValidationException("Avatar file was not found in the upload temp folder"); // Archive any previous avatar (so a re-upload doesn't lose the // old file before the new one is in place). if (fileServer.FileExists(destinationPath)) { fileServer.DeleteFile(destinationPath); } fileServer.MoveFile(sourcePath, destinationPath); user.Avatar = storedFileName; dataContext.Update(user); dataContext.SaveChanges(true); return storedFileName; } // ===== 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". //@webmethod public bool ConfirmPassword(string password) { if (string.IsNullOrWhiteSpace(password)) throw new ValidationException("password is required"); var username = loginCredential.GetUsername(); if (string.IsNullOrWhiteSpace(username)) throw new ValidationException(this.stringLocalizer.GetString(WebMessages.UserNotFound)); var user = dataContext.SecurityUsers.FirstOrDefault(x => x.Username == username); if (user == null) throw new ValidationException(this.stringLocalizer.GetString(WebMessages.UserNotFound)); // Same MD5-hash check as ChangePassword. If the password has been // migrated to a stronger scheme in the future, swap this for the // password-verification abstraction (PasswordHasher.Verify) used // by AuthProcess.Login. return user.PasswordHash == MD5.Hash(password); } // Enables the "use biometric to view epayslip / taxation" feature. // Requires: // 1. Biometric login is already enrolled (biometric_credential_id // is set on security_user). The frontend only shows the toggle // on MyProfile when this is true, but we re-check here to be // safe. // 2. The user proves they know their epayslip password. We MD5-hash // it (matching the algorithm used by the existing maintenance // EpaySlip dialog) and verify via the EpaySlipProcess commander. // The commander throws on wrong password, which propagates as a // 400 to the frontend. // Returns true on success, throws ValidationException otherwise. //@webmethod public bool EnableBiometricForEpayslip(string epayslipPassword) { if (string.IsNullOrWhiteSpace(epayslipPassword)) throw new ValidationException("epayslip password is required"); var username = loginCredential.GetUsername(); var staffId = loginCredential.GetStaffId(); if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(staffId)) throw new ValidationException(this.stringLocalizer.GetString(WebMessages.UserNotFound)); var user = dataContext.SecurityUsers.FirstOrDefault(x => x.Username == username); if (user == null) throw new ValidationException(this.stringLocalizer.GetString(WebMessages.UserNotFound)); // Biometric login must be enrolled. We refuse to set the flag // without a real credential, so a user can't enable this // accidentally before WebAuthn enrollment completes. if (string.IsNullOrEmpty(user.BiometricCredentialId)) throw new ValidationException("Biometric login must be enabled first"); // Verify the epayslip password via the maintenance module's // EpaySlipProcess.Commander. The commander throws if the // password is wrong (which the frontend surfaces as 400). var hashed = MD5.Hash(epayslipPassword); this.commandConnector.InvokeCmd(x => x.VerifyPayslipPassword(staffId, hashed)); user.BiometricForEpayslip = true; dataContext.Update(user); dataContext.SaveChanges(true); logger.LogInformation("EnableBiometricForEpayslip: enabled for username={username}", username); return true; } // Disables the "use biometric to view epayslip" feature. Does NOT // require the epayslip password (the user is just opting out of a // convenience). The biometric login itself is unaffected — the user // can still sign in with fingerprint/face; they just have to type // the epayslip password again on the epayslip page. //@webmethod public bool DisableBiometricForEpayslip() { var username = loginCredential.GetUsername(); if (string.IsNullOrWhiteSpace(username)) throw new ValidationException(this.stringLocalizer.GetString(WebMessages.UserNotFound)); var user = dataContext.SecurityUsers.FirstOrDefault(x => x.Username == username); if (user == null) throw new ValidationException(this.stringLocalizer.GetString(WebMessages.UserNotFound)); user.BiometricForEpayslip = false; dataContext.Update(user); dataContext.SaveChanges(true); logger.LogInformation("DisableBiometricForEpayslip: disabled for username={username}", username); return true; } //@webmethod public string BeginBiometricRegistration(string deviceName) { try { if (string.IsNullOrWhiteSpace(deviceName)) deviceName = "Unnamed device"; var username = loginCredential.GetUsername(); if (string.IsNullOrWhiteSpace(username)) throw new ValidationException(this.stringLocalizer.GetString(WebMessages.UserNotFound)); var userIdBytes = Encoding.UTF8.GetBytes(username); // v1: one credential per user — exclude list is empty. var options = webAuthnService.BuildCreationOptions(username, userIdBytes, Array.Empty()); return options.ToJson(); } catch (Exception ex) { logger.LogError("BeginBiometricRegistration ERROR: " + ex.ToString()); throw; } } //@webmethod 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 = loginCredential.GetUsername(); if (string.IsNullOrWhiteSpace(username)) throw new ValidationException(this.stringLocalizer.GetString(WebMessages.UserNotFound)); var user = dataContext.SecurityUsers.FirstOrDefault(x => x.Username == username); if (user == null) throw new ValidationException(this.stringLocalizer.GetString(WebMessages.UserNotFound)); // 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(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 ? "" : $"<{raw.Response.AttestationObject.Length} chars>", raw.Response?.ClientDataJson == 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; dataContext.Update(user); dataContext.SaveChanges(true); return new BiometricRegistrationResultDto { CredentialIdBase64Url = Fido2NetLib.Base64Url.Encode(stored.CredentialId) }; } public BiometricLoginChallengeDto BeginBiometricLogin(string username) { if (string.IsNullOrWhiteSpace(username)) throw new ValidationException("username is required"); var user = dataContext.SecurityUsers.FirstOrDefault(x => x.Username == username); if (user == null) throw new ValidationException(this.stringLocalizer.GetString(WebMessages.UserNotFound)); if (user.BiometricCredentialId == null || user.BiometricCredentialId.Length == 0) throw new ValidationException("Biometric not enrolled for this user"); if (user.Status != SecurityUserStatusPolicy.Active) throw new ValidationException(this.stringLocalizer.GetString(WebMessages.UsernameSuspended)); // 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() }; } 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 = dataContext.SecurityUsers.FirstOrDefault(x => x.Username == dto.Username); if (user == null) throw new ValidationException(this.stringLocalizer.GetString(WebMessages.UserNotFound)); if (user.Status != SecurityUserStatusPolicy.Active) throw new ValidationException(this.stringLocalizer.GetString(WebMessages.UsernameSuspended)); 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(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; dataContext.Update(user); dataContext.SaveChanges(true); // Mint the session the same way AuthProcess.Login does. return loginProfileFactory.CreateProfile(user); } 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. //@webmethod public EpayslipBiometricAssertionChallengeDto BeginEpayslipBiometricAssertion() { var username = loginCredential.GetUsername(); if (string.IsNullOrWhiteSpace(username)) throw new ValidationException(this.stringLocalizer.GetString(WebMessages.UserNotFound)); var user = dataContext.SecurityUsers.FirstOrDefault(x => x.Username == username); if (user == null) throw new ValidationException(this.stringLocalizer.GetString(WebMessages.UserNotFound)); // 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"); if (!user.BiometricForEpayslip) throw new ValidationException("Biometric for epayslip is not enabled"); // user.BiometricCredentialId is stored as base64url (see // FinishBiometricRegistration). Decode back to byte[] for // Fido2NetLib's PublicKeyCredentialDescriptor.Id. return new EpayslipBiometricAssertionChallengeDto { 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. //@webmethod public bool FinishEpayslipBiometricAssertion(string assertionJson, string requestOptionsJson) { if (string.IsNullOrWhiteSpace(assertionJson) || string.IsNullOrWhiteSpace(requestOptionsJson)) throw new ValidationException("assertionJson and requestOptionsJson are required"); var username = loginCredential.GetUsername(); if (string.IsNullOrWhiteSpace(username)) throw new ValidationException(this.stringLocalizer.GetString(WebMessages.UserNotFound)); var user = dataContext.SecurityUsers.FirstOrDefault(x => x.Username == username); if (user == null) throw new ValidationException(this.stringLocalizer.GetString(WebMessages.UserNotFound)); if (string.IsNullOrEmpty(user.BiometricCredentialId) || string.IsNullOrEmpty(user.BiometricPublicKey)) throw new ValidationException("Biometric not enrolled for this user"); AssertionOptions originalOptions; try { // Same unwrap as FinishBiometricLogin: the frontend // double-JSON-encodes the options string, so the top-level // JToken is a JSON string of a JSON object. Unwrap one layer // before handing to Fido2NetLib's FromJson. var token = Newtonsoft.Json.Linq.JToken.Parse(requestOptionsJson); if (token is Newtonsoft.Json.Linq.JValue v && v.Type == Newtonsoft.Json.Linq.JTokenType.String) { token = Newtonsoft.Json.Linq.JToken.Parse((string)v.Value!); } originalOptions = AssertionOptions.FromJson(token.ToString()); } catch (Exception ex) { logger.LogError("FinishEpayslipBiometricAssertion_ERROR_1: " + ex.ToString()); throw new ValidationException("Invalid requestOptionsJson: " + ex.Message); } AuthenticatorAssertionRawResponse raw; try { // See WebAuthnAssertionPatcher for why this is needed. raw = JsonConvert.DeserializeObject(assertionJson); WebAuthnAssertionPatcher.PatchResponseToByteArrays(raw); } catch (Exception ex) { logger.LogError("FinishEpayslipBiometricAssertion_ERROR_2: " + ex.ToString()); throw new ValidationException("Invalid assertionJson: " + ex.Message); } if (raw == null) throw new ValidationException("Invalid assertionJson"); if (raw.Response == null) throw new ValidationException("assertionJson is missing the response field"); try { // user.BiometricPublicKey is stored as base64url; decode to // byte[] for Fido2NetLib's MakeAssertionAsync signature. var newCounter = webAuthnService .FinishAssertionAsync(raw, originalOptions, Fido2NetLib.Base64Url.Decode(user.BiometricPublicKey), (uint)user.BiometricCounter) .GetAwaiter().GetResult(); user.BiometricCounter = (int)newCounter; user.BiometricLastUsedAt = DateTime.Now; dataContext.Update(user); dataContext.SaveChanges(true); // 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; } } //@webmethod public void RemoveBiometric() { var username = loginCredential.GetUsername(); if (string.IsNullOrWhiteSpace(username)) throw new ValidationException(this.stringLocalizer.GetString(WebMessages.UserNotFound)); var user = dataContext.SecurityUsers.FirstOrDefault(x => x.Username == username); if (user == null) return; user.BiometricCredentialId = null; user.BiometricPublicKey = null; user.BiometricCounter = 0; user.BiometricDeviceName = null; user.BiometricEnrolledAt = null; user.BiometricLastUsedAt = null; user.BiometricForEpayslip = false; dataContext.Update(user); dataContext.SaveChanges(true); } }