using Azure.Core; using CloudBuilder.Core.Authorization; using CloudBuilder.Core.DatabaseAccessor; using CloudBuilder.Core.DatabaseAccessor.Entity; using CloudBuilder.Core.DependencyInjection.EventBuses; using CloudBuilder.Core.DependencyInjection.Request; using CloudBuilder.Core.Policy; using CloudBuilder.Core.Service; using CloudBuilder.Security.Data; using CloudBuilder.Security.Entity; using CloudBuilder.Security.Policy; using CloudBuilder.Security.Publisher; using CloudBuilder.Security.Service.Authorization; using CloudBuilder.Security.Service.Publisher; using DocumentFormat.OpenXml.Spreadsheet; using Fido2NetLib; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; using Newtonsoft.Json; using System.ComponentModel.DataAnnotations; using System.IdentityModel.Tokens.Jwt; using System.Runtime; using System.Security.Cryptography; using System.Text; using System.Text.RegularExpressions; using RSA = System.RSA; namespace CloudBuilder.Security.Service { public class AuthService : IAuthService { private readonly IApplicationService service; private readonly IHttpContextAccessor httpContextAccessor; private readonly ILocaleMessageManager localeMessageManager; private readonly IContext context; private readonly IWebAuthnService webAuthnService; private IRepository repository; public AuthService(IApplicationService service, IHttpContextAccessor httpContextAccessor, ILocaleMessageManager localeMessageManager, IContext context, IWebAuthnService webAuthnService) { this.service = service; this.httpContextAccessor = httpContextAccessor; this.localeMessageManager = localeMessageManager; this.context = context; this.webAuthnService = webAuthnService; repository = service.GetRepository>(); } [AllowAnonymous] [UnitOfWork] public LoginProfile Login(LoginData data) { context.SetCulture(data.Culture); string certification = localeMessageManager.ComposeMessage(typeof(AuthService).Name, "certification"); SecurityUserEntity securityUserEntity = repository.DetachedEntities.Where(x => (x.UserName == data.UserMobile || x.UserMobile == data.UserMobile) && x.UserIsValidIndc == YesNoPolicy.YES).FirstOrDefault()!; if (securityUserEntity == null) { throw new ValidatedException(certification); } var descKey = service.Configuration[SecuritySettingsPolicy.SecuritySettings_DescKey]; var privateKey = service.Configuration[SecuritySettingsPolicy.SecuritySettings_RsaPrivateKey]; var rsa = new System.RSA(privateKey, true); var psd = rsa.DecodeOrNull(data.RsaUserPassword); if (string.IsNullOrEmpty(psd)) throw new ValidatedException(certification); //052636a8-b146-9efd-f5dc-0034a3c5bb34+password var guid = psd.Substring(0, 36); if (guid != data.Guid) { throw new ValidatedException(certification); } if (securityUserEntity.IsAccountsLockedIndc == YesNoPolicy.YES && securityUserEntity.AccountsLockedDatetime.HasValue && securityUserEntity.AccountsLockedDatetime.Value.Subtract(DateTime.Now).TotalMinutes > 0) { throw new ValidatedException(localeMessageManager.ComposeMessage("AuthService", "locked")); } data.UserPassword = psd.Substring(36, psd.Length - 36); if (securityUserEntity.UserPassword != data.UserPassword) { securityUserEntity.ErrorLoginCount += 1; //输错多少次后,账号锁定 if (securityUserEntity.ErrorLoginCount > SecurityUserPolicy.ERROR_LOGIN_COUNT) { securityUserEntity.IsAccountsLockedIndc = YesNoPolicy.YES; securityUserEntity.AccountsLockedDatetime = DateTime.Now.AddDays(1); } repository.UpdateNow(securityUserEntity); throw new ValidatedException(certification); } securityUserEntity.ErrorLoginCount = 0; securityUserEntity.IsAccountsLockedIndc = YesNoPolicy.NO; securityUserEntity.AccountsLockedDatetime = null; //更新最后登录日期 securityUserEntity.LastLoginDatetime = DateTime.Now; repository.UpdateNow(securityUserEntity); return PackLoginProfile(securityUserEntity, data.Culture); } private LoginProfile PackLoginProfile(SecurityUserEntity securityUserEntity, string culture) { JwtGenerate jwtGenerate = service.ServiceProvider.GetService()!; string clainmName = Guid.NewGuid().ToString(); Dictionary claims = new Dictionary(); claims.Add(ClaimPolicy.CLAINM_NAME, clainmName); claims.Add(ClaimPolicy.CLAINM_ACCOUNT, securityUserEntity.UserMobile!); claims.Add(ClaimPolicy.CLAINM_USERID, securityUserEntity.UserName); claims.Add(ClaimPolicy.CLAINM_LOCALE, culture); var accessToken = jwtGenerate.Generate(claims); if (httpContextAccessor.HttpContext.Request.Headers.ContainsKey("Authorization")) httpContextAccessor.HttpContext.Request.Headers.Remove("Authorization"); httpContextAccessor.HttpContext.Request.Headers.Add("Authorization", "Bearer " + accessToken); if (httpContextAccessor.HttpContext.Request.Headers.ContainsKey(ClaimPolicy.HEADER_LOCALE)) httpContextAccessor.HttpContext.Request.Headers.Remove(ClaimPolicy.HEADER_LOCALE); httpContextAccessor.HttpContext.Request.Headers.Add(ClaimPolicy.HEADER_LOCALE, culture); service.UserId = securityUserEntity.UserName; IRepository repositoryLog = service.GetRepository>(); SecurityUserWebLoginEntity webLogin = new SecurityUserWebLoginEntity { Username = securityUserEntity.UserName, SessionId = clainmName, LoginTime = DateTime.Now, HostIp = httpContextAccessor.HttpContext.Connection.RemoteIpAddress?.MapToIPv4()?.ToString(), HostUserAgent = this.httpContextAccessor?.HttpContext?.Request.Headers.UserAgent.ToString(), RefreshToken = jwtGenerate.GenerateRefreshToken(), RefreshTokenRefreshedTime = DateTime.Now, }; repositoryLog.InsertNow(webLogin); PermissionContext permissionContext = new PermissionContext(service); return new LoginProfile { Token = new JwtInfo() { AccessToken = accessToken, RefreshToken = webLogin.RefreshToken, CreatedTime = webLogin.RefreshTokenRefreshedTime }, Username = securityUserEntity.UserName, UserType = securityUserEntity.UserType, UserLocalName = securityUserEntity.UserLocalName, UserEngName = securityUserEntity.UserEngName, UserMobile = securityUserEntity.UserMobile!, PermissionInfo = permissionContext.GetUserPermissionInfo() }; } [PermissionOperate(name: SecurityPermissionPolicy.SecurityUser, operate: "LoginAgent")] [UnitOfWork] public LoginProfile LoginAgent(LoginData data) { LoginProfile loginProfile = Login(data); SecurityUserEntity agent = repository.DetachedEntities.Where(x => (x.UserMobile == data.AgentUserName && x.UserIsValidIndc == YesNoPolicy.YES)).FirstOrDefault()!; if (agent == null) { throw new ValidatedException("代理用户不存在."); } JwtGenerate jwtGenerate = service.ServiceProvider.GetService()!; Dictionary claims = new Dictionary(); claims.Add(ClaimPolicy.CLAINM_NAME, agent.UserName); claims.Add(ClaimPolicy.CLAINM_ACCOUNT, agent.UserMobile!); claims.Add(ClaimPolicy.CLAINM_USERID, agent.UserName); claims.Add(ClaimPolicy.CLAINM_LOCALE, data.Culture); var accessToken = jwtGenerate.Generate(claims); if (httpContextAccessor.HttpContext.Request.Headers.ContainsKey("Authorization")) httpContextAccessor.HttpContext.Request.Headers.Remove("Authorization"); httpContextAccessor.HttpContext.Request.Headers.Add("Authorization", "Bearer " + accessToken); if (httpContextAccessor.HttpContext.Request.Headers.ContainsKey(ClaimPolicy.HEADER_LOCALE)) httpContextAccessor.HttpContext.Request.Headers.Remove(ClaimPolicy.HEADER_LOCALE); httpContextAccessor.HttpContext.Request.Headers.Add(ClaimPolicy.HEADER_LOCALE, data.Culture); SecurityUserWebLoginEntity webLogin = new SecurityUserWebLoginEntity { Username = agent.UserName, SessionId = Guid.NewGuid().ToString(), LoginTime = DateTime.Now, HostIp = loginProfile.Username, HostUserAgent = loginProfile.UserMobile!, RefreshToken = jwtGenerate.GenerateRefreshToken(), RefreshTokenRefreshedTime = DateTime.Now, }; IRepository repositoryLog = service.GetRepository>(); repositoryLog.InsertNow(webLogin); service.UserId = agent.UserName; PermissionContext permissionContext = new PermissionContext(service); return new LoginProfile { Token = new JwtInfo() { AccessToken = accessToken, RefreshToken = webLogin.RefreshToken, CreatedTime = webLogin.RefreshTokenRefreshedTime }, Username = agent.UserName, UserType = agent.UserType, UserLocalName = agent.UserLocalName, UserEngName = agent.UserEngName, UserMobile = agent.UserMobile!, PermissionInfo = permissionContext.GetUserPermissionInfo() }; } [AllowAnonymous] [UnitOfWork] public string Register(RegisterData data) { SecurityUserEntity securityUser = repository.DetachedEntities.Where(x => x.UserMobile == data.UserMobile).FirstOrDefault()!; if (securityUser != null) { throw new ValidatedException("手机号已注册."); } securityUser = new SecurityUserEntity(); if (string.IsNullOrEmpty(data.UserMobile) || string.IsNullOrEmpty(data.RsaUserPassword)) return null; ISecurityGeneratorNumberService securityGeneratorNumberService = service.ServiceProvider.GetService()!; string userName = securityGeneratorNumberService.GetNo(SecurityPrefixPolicy.SECURITY_USER, SecurityPrefixPolicy.SECURITY_USER_USER_NAME, null, YesNoPolicy.NO, SecurityPrefixPolicy.SECURITY_USER_FORMAT); var privateKey = service.Configuration[SecuritySettingsPolicy.SecuritySettings_RsaPrivateKey]; var rsa = new System.RSA(privateKey, true); securityUser.UserPassword = rsa.DecodeOrNull(data.RsaUserPassword); //052636a8-b146-9efd-f5dc-0034a3c5bb34+password(MD5) string guid = securityUser.UserPassword.Substring(0, 36); securityUser.UserPassword = securityUser.UserPassword.Substring(36, securityUser.UserPassword.Length - 36); securityUser.UserName = userName; securityUser.UserMobile = data.UserMobile; securityUser.UserLocalName = "用户" + securityUser.UserName; securityUser.UserIsValidIndc = YesNoPolicy.YES; JwtGenerate jwtGenerate = service.ServiceProvider.GetService()!; Dictionary claims = new Dictionary(); claims.Add(ClaimPolicy.CLAINM_NAME, securityUser.UserName); claims.Add(ClaimPolicy.CLAINM_ACCOUNT, securityUser.UserName); claims.Add(ClaimPolicy.CLAINM_USERID, securityUser.UserName); claims.Add(ClaimPolicy.CLAINM_LOCALE, CulturePolicy.ZH_CN); service.UserId = securityUser.UserName; var accessToken = jwtGenerate.Generate(claims); if (httpContextAccessor.HttpContext.Request.Headers.ContainsKey("Authorization")) httpContextAccessor.HttpContext.Request.Headers.Remove("Authorization"); httpContextAccessor.HttpContext.Request.Headers.Add("Authorization", "Bearer " + accessToken); repository.InsertNow(securityUser); //发布注册的事务 EventData eventData = new EventData(); ISecurityUserRegisterPublisher publisher = service.ServiceProvider.GetService()!; eventData.EventSource = securityUser; eventData.EventId = typeof(SecurityUserRegisterPublisher).Name; publisher.Publish(eventData); return securityUser.UserMobile; } [PermissionOperate(name: SecurityPermissionPolicy.SecurityUser, operate: Operate.Update)] [UnitOfWork] public LoginProfile AmendPassword(LoginData data) { SecurityUserEntity securityUserEntity = repository.DetachedEntities.Where(x => x.UserName == service.UserId && x.UserIsValidIndc == YesNoPolicy.YES).FirstOrDefault()!; if (securityUserEntity == null) { throw new ValidatedException("认证不通过."); } var descKey = service.Configuration[SecuritySettingsPolicy.SecuritySettings_DescKey]; var privateKey = service.Configuration[SecuritySettingsPolicy.SecuritySettings_RsaPrivateKey]; var rsa = new RSA(privateKey, true); var psd = rsa.DecodeOrNull(data.RsaUserPassword); if (string.IsNullOrEmpty(psd)) throw new ValidatedException("认证不通过."); //052636a8-b146-9efd-f5dc-0034a3c5bb34+password var guid = psd.Substring(0, 36); if (guid != data.UserName) { throw new ValidatedException("认证不通过."); } if (securityUserEntity.IsAccountsLockedIndc == YesNoPolicy.YES && securityUserEntity.AccountsLockedDatetime.HasValue && securityUserEntity.AccountsLockedDatetime.Value.Subtract(DateTime.Now).TotalMinutes > 0) { throw new ValidatedException("用户账号已锁定."); } if (securityUserEntity.UserPassword != psd.Substring(36, psd.Length - 36)) { securityUserEntity.ErrorLoginCount += 1; //输错多少次后,账号锁定 if (securityUserEntity.ErrorLoginCount > SecurityUserPolicy.ERROR_LOGIN_COUNT) { securityUserEntity.IsAccountsLockedIndc = YesNoPolicy.YES; securityUserEntity.AccountsLockedDatetime = DateTime.Now.AddDays(1); } repository.UpdateNow(securityUserEntity); throw new ValidatedException("认证不通过."); } JwtGenerate jwtGenerate = service.ServiceProvider.GetService()!; string clainmName = Guid.NewGuid().ToString(); Dictionary claims = new Dictionary(); claims.Add(ClaimPolicy.CLAINM_NAME, clainmName); claims.Add(ClaimPolicy.CLAINM_ACCOUNT, securityUserEntity.UserMobile!); claims.Add(ClaimPolicy.CLAINM_USERID, securityUserEntity.UserName); claims.Add(ClaimPolicy.CLAINM_LOCALE, data.Culture ?? CulturePolicy.ZH_CN); var accessToken = jwtGenerate.Generate(claims); if (httpContextAccessor.HttpContext.Request.Headers.ContainsKey("Authorization")) httpContextAccessor.HttpContext.Request.Headers.Remove("Authorization"); httpContextAccessor.HttpContext.Request.Headers.Add("Authorization", "Bearer " + accessToken); securityUserEntity.ErrorLoginCount = 0; securityUserEntity.IsAccountsLockedIndc = YesNoPolicy.NO; securityUserEntity.AccountsLockedDatetime = null; //更新最后登录日期 securityUserEntity.LastLoginDatetime = DateTime.Now; psd = rsa.DecodeOrNull(data.UserPassword); if (string.IsNullOrEmpty(psd)) throw new ValidatedException("认证不通过."); securityUserEntity.UserPassword = psd.Substring(36, psd.Length - 36); repository.UpdateNow(securityUserEntity); IRepository repositoryLog = service.GetRepository>(); SecurityUserWebLoginEntity webLogin = new SecurityUserWebLoginEntity { Username = securityUserEntity.UserName, SessionId = clainmName, LoginTime = DateTime.Now, HostIp = httpContextAccessor.HttpContext.Connection.RemoteIpAddress?.MapToIPv4()?.ToString(), HostUserAgent = this.httpContextAccessor?.HttpContext?.Request.Headers.UserAgent.ToString(), RefreshToken = jwtGenerate.GenerateRefreshToken(), RefreshTokenRefreshedTime = DateTime.Now, }; repositoryLog.InsertNow(webLogin); PermissionContext permissionContext = new PermissionContext(service); return new LoginProfile { Token = new JwtInfo() { AccessToken = accessToken, RefreshToken = webLogin.RefreshToken, CreatedTime = webLogin.RefreshTokenRefreshedTime }, Username = securityUserEntity.UserName, UserType = securityUserEntity.UserType, UserLocalName = securityUserEntity.UserLocalName, UserEngName = securityUserEntity.UserEngName, UserMobile = securityUserEntity.UserMobile!, PermissionInfo = permissionContext.GetUserPermissionInfo() }; } [AllowAnonymous] [HttpGet] public string GetRsaPublicKey() { if (string.IsNullOrEmpty(service.Configuration["SecuritySettings:RsaPublicKey"])) throw new ValidatedException("[SecuritySettings:RsaPublicKey]"); return service.Configuration["SecuritySettings:RsaPublicKey"]!; } [AllowAnonymous] public JwtInfo RefreshToken(JwtInfo jwtInfo) { if (jwtInfo == null || string.IsNullOrEmpty(jwtInfo.AccessToken) || string.IsNullOrEmpty(jwtInfo.RefreshToken)) throw new ServiceErrorException("无效的令牌,请重新登录."); TokenValidationParameters parameters = new TokenValidationParameters { ValidateIssuerSigningKey = true, //获取或设置要使用的Microsoft.IdentityModel.Tokens.SecurityKey用于签名验证。 IssuerSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(service.CloudBuilder.CloudBuilderOptions.Jwt.Secret)), //获取或设置一个System.String,它表示将使用的有效发行者检查代币的发行者。 ValidIssuer = service.CloudBuilder.CloudBuilderOptions.Jwt.Issuer, //获取或设置一个字符串,该字符串表示将用于检查的有效受众反对令牌的观众。 ValidAudience = service.CloudBuilder.CloudBuilderOptions.Jwt.Audience, ValidateIssuer = true, ValidateAudience = true, ValidateLifetime = false,//不验证过期时间 }; // 验证 Token var tokenHandler = new JwtSecurityTokenHandler(); SecurityToken vTocken; string token = jwtInfo.AccessToken.Replace("Bearer ", ""); try { var tokenValidationResult = tokenHandler.ValidateToken(token, parameters, out vTocken); if (tokenValidationResult == null || !tokenValidationResult.Identities.Any() || !tokenValidationResult.Identities.First().IsAuthenticated) throw new ServiceErrorException("无效的令牌,请重新登录."); } catch (Exception) { throw new ServiceErrorException("无效的令牌,请重新登录."); } string clainmName = ClaimPolicy.GetClaimValue(token, ClaimPolicy.CLAINM_NAME); string username = ClaimPolicy.GetClaimValue(token, ClaimPolicy.CLAINM_USERID); string userMobile = ClaimPolicy.GetClaimValue(token, ClaimPolicy.CLAINM_ACCOUNT); string locale = ClaimPolicy.GetClaimValue(token, ClaimPolicy.CLAINM_LOCALE); if (string.IsNullOrEmpty(clainmName) || string.IsNullOrEmpty(username)) throw new ServiceErrorException("无效的令牌,请重新登录."); IRepository repositorySecurityUser = service.GetRepository>(); SecurityUserEntity user = repositorySecurityUser.DetachedEntities.Where(x => x.UserName == username && x.UserIsValidIndc == YesNoPolicy.YES).FirstOrDefault(); if (user == null) throw new ServiceErrorException("无效的令牌,请重新登录."); IRepository repositoryLog = service.GetRepository>(); SecurityUserWebLoginEntity log = repositoryLog.DetachedEntities.Where(x => x.Username == username && x.SessionId == clainmName).FirstOrDefault()!; //不是最后登录的令牌失效 DateTime? LoginTime = repositoryLog.DetachedEntities.Where(x => x.Username == username).Max(x => x.LoginTime); if (LoginTime.HasValue && log.LoginTime < LoginTime.Value) throw new ServiceErrorException("无效的令牌,请重新登录."); if (log == null || log.RefreshToken != jwtInfo.RefreshToken || log.LogoutTime.HasValue || log.RefreshToken != jwtInfo.RefreshToken || log.RefreshTokenRefreshedTime.AddMinutes(Convert.ToInt32(service.CloudBuilder.CloudBuilderOptions.Jwt.RefreshExpireMins)) < DateTime.Now) throw new ServiceErrorException("无效的令牌,请重新登录."); JwtGenerate jwtGenerate = service.ServiceProvider.GetService()!; Dictionary claims = new Dictionary(); claims.Add(ClaimPolicy.CLAINM_NAME, clainmName); claims.Add(ClaimPolicy.CLAINM_ACCOUNT, userMobile); claims.Add(ClaimPolicy.CLAINM_USERID, username); claims.Add(ClaimPolicy.CLAINM_LOCALE, locale ?? CulturePolicy.ZH_CN); var accessToken = jwtGenerate.Generate(claims); log.RefreshToken = jwtGenerate.GenerateRefreshToken(); log.RefreshTokenRefreshedTime = DateTime.Now; if (httpContextAccessor.HttpContext.Request.Headers.ContainsKey("Authorization")) httpContextAccessor.HttpContext.Request.Headers.Remove("Authorization"); httpContextAccessor.HttpContext.Request.Headers.Add("Authorization", "Bearer " + accessToken); repositoryLog.UpdateNow(log); jwtInfo.AccessToken = accessToken; jwtInfo.RefreshToken = log.RefreshToken; jwtInfo.CreatedTime = log.RefreshTokenRefreshedTime; return jwtInfo; } [AllowAnonymous] public void Logout() { string username = ClaimPolicy.GetClaimValue(httpContextAccessor, ClaimPolicy.CLAINM_USERID); string clainmName = ClaimPolicy.GetClaimValue(httpContextAccessor, ClaimPolicy.CLAINM_NAME); if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(clainmName)) { return; } IRepository repositoryLog = service.GetRepository>(); SecurityUserWebLoginEntity log = repositoryLog.DetachedEntities.Where(x => x.Username == username && x.SessionId == clainmName).FirstOrDefault()!; if (log == null || log.LogoutTime.HasValue) { return; } log.LogoutTime = DateTime.Now; repositoryLog.UpdateNow(log); } // ===== 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". [PermissionOperate(name: SecurityPermissionPolicy.SecurityUser, operate: Operate.Find)] public bool ConfirmPassword(string password) { if (string.IsNullOrWhiteSpace(password)) throw new ValidationException("password is required"); var username = service.UserId; if (string.IsNullOrWhiteSpace(username)) throw new ValidationException("User not found."); var user = repository.DetachedEntities.FirstOrDefault(x => x.UserName == username); if (user == null) throw new ValidationException("User not found."); var descKey = service.Configuration[SecuritySettingsPolicy.SecuritySettings_DescKey]; var privateKey = service.Configuration[SecuritySettingsPolicy.SecuritySettings_RsaPrivateKey]; var rsa = new System.RSA(privateKey, true); var psd = rsa.DecodeOrNull(password); if (string.IsNullOrEmpty(psd)) return false; return user.UserPassword == psd.Substring(36, psd.Length - 36); } [PermissionOperate(name: SecurityPermissionPolicy.SecurityUser, operate: Operate.Find)] public string BeginBiometricRegistration(string deviceName) { try { if (string.IsNullOrWhiteSpace(deviceName)) deviceName = "Unnamed device"; var username = service.UserId;// loginCredential.GetUsername(); if (string.IsNullOrWhiteSpace(username)) throw new ValidationException("User not found."); var userIdBytes = Encoding.UTF8.GetBytes(username); // v1: one credential per user — exclude list is empty. var options = webAuthnService.BuildCreationOptions(username, userIdBytes, Array.Empty()); return options.ToJson(); } catch (Exception ex) { //logger.LogError("BeginBiometricRegistration ERROR: " + ex.ToString()); throw; } } [PermissionOperate(name: SecurityPermissionPolicy.SecurityUser, operate: Operate.Find)] 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 = service.UserId;// loginCredential.GetUsername(); if (string.IsNullOrWhiteSpace(username)) throw new ValidationException("User not found."); var user = repository.DetachedEntities.FirstOrDefault(x => x.UserName == username); if (user == null) throw new ValidationException("User not found."); // Diagnostic: log the raw JSON we received so we can diagnose // Fido2NetLib "Expected rawResponse, got null" failures. The // Fido2NetLib 1.0.0-alpha VerifyAsync checks `Raw == null` at // the top, before any field-level checks — if it fires, the // wrapper was constructed with a null raw, which means either // (a) CredentialCreateOptions.FromJson returned null and the // constructor blew up, or (b) the C# deserializer produced a // shell object whose required properties are all null. //logger.LogInformation( // "FinishBiometricRegistration_DIAG attestationJson[0..400]={att} creationOptionsJson[0..200]={opts}", // dto.AttestationJson.Substring(0, Math.Min(400, dto.AttestationJson.Length)), // dto.CreationOptionsJson.Substring(0, Math.Min(200, dto.CreationOptionsJson.Length))); CredentialCreateOptions originalOptions; try { originalOptions = CredentialCreateOptions.FromJson(dto.CreationOptionsJson); } catch (Exception ex) { throw new ValidationException("Invalid creationOptionsJson: " + ex.Message); } if (originalOptions == null) throw new ValidationException("CredentialCreateOptions.FromJson returned null (creationOptionsJson parsed as the JSON literal 'null' or empty)"); AuthenticatorAttestationRawResponse raw; try { //logger.LogInformation("DTO: " + dto.AttestationJson); // MUST use Newtonsoft.Json here, not System.Text.Json. Fido2NetLib's // AuthenticatorAttestationRawResponse relies on Newtonsoft.Json-specific // attributes that System.Text.Json does not honor: // * [JsonConverter(typeof(Base64UrlConverter))] on Id/RawId/AttestationObject/ClientDataJson // (converts base64url strings to byte[]; System.Text.Json would leave them as strings) // * [JsonConverter(typeof(StringEnumConverter))] on PublicKeyCredentialType.Type // with [EnumMember(Value = "public-key")] on the PublicKey member // // And Fido2NetLib's AuthenticatorAttestationResponse.Parse throws // "Expected rawResponse, got null" when rawResponse.Response is null — // which is exactly what System.Text.Json produces, because it is // case-sensitive by default and looks for `Response` (PascalCase) in // the JSON, while the browser sends `response` (lowercase). // Newtonsoft.Json's default ContractResolver is case-insensitive. raw = JsonConvert.DeserializeObject(dto.AttestationJson); } catch (Exception ex) { throw new ValidationException("Invalid attestationJson: " + ex.Message); } if (raw == null) throw new ValidationException("Invalid attestationJson"); if (raw.Response == null) throw new ValidationException("raw.Response is null after deserialization — the browser's JSON didn't bind to Fido2NetLib's Response property"); // Diagnostic: log what Fido2NetLib will see. If any of these // are null, Fido2NetLib's VerifyAsync will throw the cryptic // "Expected rawResponse, got null" or "Id is missing" / "RawId // is missing" / "clientDataJson cannot be null" etc. //logger.LogInformation( // "FinishBiometricRegistration_DIAG raw.Id={id} raw.RawId={rawId} raw.Type={type} raw.Response.AttestationObject={ao} raw.Response.ClientDataJson={cdj}", // raw.Id, // raw.RawId, // raw.Type, // raw.Response?.AttestationObject == null ? "" : $"<{raw.Response.AttestationObject.Length} chars>", // raw.Response?.ClientDataJson == null ? "" : $"<{raw.Response.ClientDataJson.Length} chars>"); // Fido2NetLib verifies signature, certificate chain, challenge, // origin, and rpId. We only need to await it. var stored = webAuthnService .FinishRegistrationAsync(raw, originalOptions, _ => Task.FromResult(true)) .GetAwaiter().GetResult(); // Persist on the user. v1 = one credential per user: just // overwrite. (The excludeCredentials list passed to // BuildCreationOptions is empty, so the authenticator will // refuse to re-enroll the same key on the same device — but // re-enrolling on a new device overwrites. That's intentional.) // // SecurityUser.BiometricCredentialId / BiometricPublicKey are // `string` columns (per the request to keep the schema as-is). // Fido2NetLib gives us `byte[]`; we encode with base64url so // the stored value is directly usable as the credential id // (matches the format returned to the browser in // BiometricRegistrationResultDto.CredentialIdBase64Url) and // the public key can be decoded back to byte[] for the // assertion verification path. user.BiometricCredentialId = Fido2NetLib.Base64Url.Encode(stored.CredentialId); user.BiometricPublicKey = Fido2NetLib.Base64Url.Encode(stored.PublicKey); user.BiometricCounter = 0; user.BiometricDeviceName = dto.AttestationJson.Length > 0 ? "(set on the device)" : null; user.BiometricEnrolledAt = DateTime.Now; user.BiometricLastUsedAt = null; repository.UpdateNow(user); return new BiometricRegistrationResultDto { CredentialIdBase64Url = Fido2NetLib.Base64Url.Encode(stored.CredentialId) }; } [AllowAnonymous] public BiometricLoginChallengeDto BeginBiometricLogin(string username) { if (string.IsNullOrWhiteSpace(username)) throw new ValidationException("username is required"); var user = repository.DetachedEntities.FirstOrDefault(x => x.UserName == username); if (user == null) throw new ValidationException("User not found."); if (user.BiometricCredentialId == null || user.BiometricCredentialId.Length == 0) throw new ValidationException("Biometric not enrolled for this user"); // 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() }; } [AllowAnonymous] 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 = repository.DetachedEntities.FirstOrDefault(x => x.UserMobile == dto.Username || x.UserName == dto.Username); if (user == null) throw new ValidationException("User not found."); if (user.BiometricCredentialId == null || user.BiometricPublicKey == null) throw new ValidationException("Biometric not enrolled for this user"); AssertionOptions originalOptions; try { // dto.RequestOptionsJson arrives double-JSON-encoded: the // backend returns the options as a JSON string (e.g. // '{"challenge":"...","rpId":"..."}' // ), and the frontend then does JSON.stringify() on that // string, wrapping it in another layer of quotes: // '"{\"challenge\":\"...\",\"rpId\":\"..."}"' // Fido2NetLib's AssertionOptions.FromJson then tries to // deserialize that as an AssertionOptions object and throws // "Could not cast or convert from System.String to // Fido2NetLib.AssertionOptions" because it sees a string // token at the top level. // // Unwrap one layer with JToken.Parse: if the result is a // JValue (the JSON-encoded string case), parse its string // value to get the inner JSON. If it's already a JObject // (no double-encoding), just use ToString(). var token = Newtonsoft.Json.Linq.JToken.Parse(dto.RequestOptionsJson); if (token is Newtonsoft.Json.Linq.JValue v && v.Type == Newtonsoft.Json.Linq.JTokenType.String) { token = Newtonsoft.Json.Linq.JToken.Parse((string)v.Value!); } originalOptions = AssertionOptions.FromJson(token.ToString()); } catch (Exception ex) { //logger.LogError("FinishBiometricLogin_ERROR_1: " + ex.ToString()); throw new ValidationException("Invalid requestOptionsJson: " + ex.Message); } AuthenticatorAssertionRawResponse raw; try { // Fido2NetLib 1.0.0-alpha declares Id/RawId/Response.* as `string` // (no [JsonConverter] — that was added in v1.1.0+), and internally // does (byte[])raw.Id to look up the credential. The (byte[]) cast // throws "Unable to cast object of type 'System.String' to type // 'System.Byte[]'" on the assertion flow (the registration flow // doesn't hit it because its Parse() path only touches // Response.AttestationObject, never Id/RawId). // // Fix: deserialize normally with Newtonsoft.Json, then patch the // base64url string fields to byte[] via reflection. If the C# // property is byte[] (1.1.0+ shape) this is a no-op. If it's // string (1.0.0-alpha shape) the reflection write will fail at // runtime with a clear ArgumentException — see the catch below // for the workaround. raw = JsonConvert.DeserializeObject(dto.AssertionJson); WebAuthnAssertionPatcher.PatchResponseToByteArrays(raw); // DIAG: log the actual runtime property type so we can confirm // the shape of the Fido2NetLib 1.0.0-alpha class and choose the // right patching strategy. //logger.LogInformation( // "FinishBiometricLogin_DIAG Id={idType} RawId={rawIdType} Response.Id={respIdType} Response.AuthenticatorData={adType} Response.Signature={sigType} Response.ClientDataJson={cdjType}", // typeof(AuthenticatorAssertionRawResponse).GetProperty("Id")?.PropertyType.Name, // typeof(AuthenticatorAssertionRawResponse).GetProperty("RawId")?.PropertyType.Name, // typeof(AuthenticatorAssertionRawResponse.AssertionResponse).GetProperty("Id")?.PropertyType.Name, // typeof(AuthenticatorAssertionRawResponse.AssertionResponse).GetProperty("AuthenticatorData")?.PropertyType.Name, // typeof(AuthenticatorAssertionRawResponse.AssertionResponse).GetProperty("Signature")?.PropertyType.Name, // typeof(AuthenticatorAssertionRawResponse.AssertionResponse).GetProperty("ClientDataJson")?.PropertyType.Name); } catch (Exception ex) { //logger.LogError("FinishBiometricLogin_ERROR_2: " + ex.ToString()); throw new ValidationException("Invalid assertionJson: " + ex.Message); } if (raw == null) { //logger.LogError("FinishBiometricLogin_ERROR_3:"); throw new ValidationException("Invalid assertionJson"); } try { // user.BiometricPublicKey is stored as base64url; decode to // byte[] for Fido2NetLib's MakeAssertionAsync signature. var newCounter = webAuthnService .FinishAssertionAsync(raw, originalOptions, Fido2NetLib.Base64Url.Decode(user.BiometricPublicKey), (uint)user.BiometricCounter) .GetAwaiter().GetResult(); // Persist counter + last-used timestamp. user.BiometricCounter = (int)newCounter; user.BiometricLastUsedAt = DateTime.Now; repository.UpdateNow(user); // Mint the session the same way AuthProcess.Login does. return PackLoginProfile(user, CulturePolicy.ZH_CN); } 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. public BiometricLoginChallengeDto BeginEpayslipBiometricAssertion() { var username = service.UserId; if (string.IsNullOrWhiteSpace(username)) throw new ValidationException("User not found."); var user = repository.DetachedEntities.FirstOrDefault(x => x.UserName == username); if (user == null) throw new ValidationException("User not found."); // 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"); // user.BiometricCredentialId is stored as base64url (see // FinishBiometricRegistration). Decode back to byte[] for // Fido2NetLib's PublicKeyCredentialDescriptor.Id. return new BiometricLoginChallengeDto { 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. public bool FinishEpayslipBiometricAssertion(string assertionJson, string requestOptionsJson) { if (string.IsNullOrWhiteSpace(assertionJson) || string.IsNullOrWhiteSpace(requestOptionsJson)) throw new ValidationException("assertionJson and requestOptionsJson are required"); var username = service.UserId; if (string.IsNullOrWhiteSpace(username)) throw new ValidationException("User not found."); var user = repository.DetachedEntities.FirstOrDefault(x => x.UserName == username); if (user == null) throw new ValidationException("User not found."); if (string.IsNullOrEmpty(user.BiometricCredentialId) || string.IsNullOrEmpty(user.BiometricPublicKey)) throw new ValidationException("Biometric not enrolled for this user"); AssertionOptions originalOptions; try { // Same unwrap as FinishBiometricLogin: the frontend // double-JSON-encodes the options string, so the top-level // JToken is a JSON string of a JSON object. Unwrap one layer // before handing to Fido2NetLib's FromJson. var token = Newtonsoft.Json.Linq.JToken.Parse(requestOptionsJson); if (token is Newtonsoft.Json.Linq.JValue v && v.Type == Newtonsoft.Json.Linq.JTokenType.String) { token = Newtonsoft.Json.Linq.JToken.Parse((string)v.Value!); } originalOptions = AssertionOptions.FromJson(token.ToString()); } catch (Exception ex) { //logger.LogError("FinishEpayslipBiometricAssertion_ERROR_1: " + ex.ToString()); throw new ValidationException("Invalid requestOptionsJson: " + ex.Message); } AuthenticatorAssertionRawResponse raw; try { // See WebAuthnAssertionPatcher for why this is needed. raw = JsonConvert.DeserializeObject(assertionJson); WebAuthnAssertionPatcher.PatchResponseToByteArrays(raw); } catch (Exception ex) { //logger.LogError("FinishEpayslipBiometricAssertion_ERROR_2: " + ex.ToString()); throw new ValidationException("Invalid assertionJson: " + ex.Message); } if (raw == null) throw new ValidationException("Invalid assertionJson"); if (raw.Response == null) throw new ValidationException("assertionJson is missing the response field"); try { // user.BiometricPublicKey is stored as base64url; decode to // byte[] for Fido2NetLib's MakeAssertionAsync signature. var newCounter = webAuthnService .FinishAssertionAsync(raw, originalOptions, Fido2NetLib.Base64Url.Decode(user.BiometricPublicKey), (uint)user.BiometricCounter) .GetAwaiter().GetResult(); user.BiometricCounter = (int)newCounter; user.BiometricLastUsedAt = DateTime.Now; repository.UpdateNow(user); // 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; } } [PermissionOperate(name: SecurityPermissionPolicy.SecurityUser, operate: Operate.Find)] public void RemoveBiometric() { var username = service.UserId;// loginCredential.GetUsername(); if (string.IsNullOrWhiteSpace(username)) throw new ValidationException("User not found."); var user = repository.DetachedEntities.FirstOrDefault(x => x.UserName == username); if (user == null) throw new ValidationException("User not found."); user.BiometricCredentialId = null; user.BiometricPublicKey = null; user.BiometricCounter = 0; user.BiometricDeviceName = null; user.BiometricEnrolledAt = null; user.BiometricLastUsedAt = null; repository.UpdateNow(user); } } }