上传文件至 /
指纹验证proc
This commit is contained in:
1 parent
6ff9249dc1
commit
d62f3050e3
1 file changed
+717
@@ -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<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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user