diff --git a/UserProfileProcess.cs b/UserProfileProcess.cs new file mode 100644 index 0000000..aadfac6 --- /dev/null +++ b/UserProfileProcess.cs @@ -0,0 +1,717 @@ +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); + } +}