Tools/UserProfileProcess.cs
itcsx d62f3050e3 上传文件至 /
指纹验证proc
2026-08-03 09:04:01 +08:00

718 lines
35 KiB
C#

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<WebMessages> 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<WebMessages> stringLocalizer,
ILoginCredential loginCredential,
XConfiguration configuration,
WebAuthnService webAuthnService,
LoginProfileFactory loginProfileFactory,
EpayslipBiometricUnlockStore epayslipUnlockStore,
XServiceProvider serviceProvider,
ILogger<UserProfileProcess> 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<EpaySlipProcess>(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<byte[]>());
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<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;
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<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;
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<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;
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);
}
}