718 lines
35 KiB
C#
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);
|
|
}
|
|
}
|