diff --git a/CloudBuilder.Core/DatabaseAccessor/MasterDbContext.cs b/CloudBuilder.Core/DatabaseAccessor/MasterDbContext.cs index c69f064..397f9c9 100644 --- a/CloudBuilder.Core/DatabaseAccessor/MasterDbContext.cs +++ b/CloudBuilder.Core/DatabaseAccessor/MasterDbContext.cs @@ -26,6 +26,7 @@ namespace CloudBuilder.Core.DatabaseAccessor private readonly IServiceProvider _serviceProvider; private readonly IConfiguration configuration; private readonly IHttpContextAccessor httpContextAccessor; + private FileLog fileLog; public MasterDbContext(DbContextOptions options, IEnumerable> entityManagers, @@ -38,6 +39,7 @@ namespace CloudBuilder.Core.DatabaseAccessor _serviceProvider = serviceProvider; this.configuration = configuration; this.httpContextAccessor = httpContextAccessor; + fileLog = new FileLog(configuration); } public override int SaveChanges(bool acceptAllChangesOnSuccess) @@ -78,7 +80,7 @@ namespace CloudBuilder.Core.DatabaseAccessor } foreach (var entity in entities) - { + { if (!(entity.State == EntityState.Added || entity.State == EntityState.Modified)) continue; // 获取所有实体有效属性,排除 [NotMapper] 属性 @@ -132,7 +134,7 @@ namespace CloudBuilder.Core.DatabaseAccessor // 拼接 WHERE 条件:Id=1 AND TenantId=2 var whereClauses = pkProps - .Select(p => $"{p.Metadata.GetColumnName()?? p.Metadata.Name} = '{p.CurrentValue?.ToString()?.Replace("'", "''")}'"); + .Select(p => $"{p.Metadata.GetColumnName() ?? p.Metadata.Name} = '{p.CurrentValue?.ToString()?.Replace("'", "''")}'"); string whereSql = string.Join(" AND ", whereClauses); string sqlLog = ""; @@ -169,42 +171,10 @@ namespace CloudBuilder.Core.DatabaseAccessor sqlLog = $"[{uid}][{now.ToString("yyyy-MM-dd HH:mm:ss")}]DELETE FROM {entityName} WHERE {whereSql}"; } - WriteToFile(sqlLog); + fileLog.WriteToFile(sqlLog); } } - private void WriteToFile(string log) - { - try - { - long MaxFileSizeBytes = (long)(configuration.GetValue("Logging:MaxFileSizeMB") * 1024 * 1024); - - string today = DateTime.Now.ToString("yyyyMMdd"); - string basePath = configuration["Logging:LogFileDirectory"]; - Directory.CreateDirectory(basePath); - - string baseFileName = $"log-{today}"; - string ext = ".log"; - string finalPath = Path.Combine(basePath, $"{baseFileName}{ext}"); - int index = 1; - - // 循环找没超过大小的文件 - while (File.Exists(finalPath) && new FileInfo(finalPath).Length >= MaxFileSizeBytes) - { - finalPath = Path.Combine(basePath, $"{baseFileName}-{index}{ext}"); - index++; - } - - // 写入(线程安全) - lock (this) - { - File.AppendAllText(finalPath, log + Environment.NewLine, Encoding.UTF8); - } - } - catch { } - } - - protected override void OnModelCreating(ModelBuilder builder) { foreach (IMasterRegistEntityType entityManager in _entityManagers) diff --git a/CloudBuilder.Core/Utility/FileLog.cs b/CloudBuilder.Core/Utility/FileLog.cs new file mode 100644 index 0000000..722d6e1 --- /dev/null +++ b/CloudBuilder.Core/Utility/FileLog.cs @@ -0,0 +1,58 @@ +using Microsoft.Extensions.Configuration; +using System; +using System.IO; +using System.Text; + +/// +/// 文件日志写入封装类,自动按日期分文件、超大小自动切割 +/// +public class FileLog +{ + private readonly IConfiguration _configuration; + private readonly object _lockObj = new object(); // 独立锁,不依赖外部this + + public FileLog(IConfiguration configuration) + { + _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); + } + + /// + /// 写入日志内容到文件 + /// + /// 日志文本 + public void WriteToFile(string log) + { + try + { + // 读取配置,转换最大文件字节数 + int maxMb = _configuration.GetValue("Logging:MaxFileSizeMB"); + long maxFileSizeBytes = (long)maxMb * 1024 * 1024; + + string today = DateTime.Now.ToString("yyyyMMdd"); + string basePath = _configuration["Logging:LogFileDirectory"]; + Directory.CreateDirectory(basePath); + + string baseFileName = $"log-{today}"; + string ext = ".log"; + string finalPath = Path.Combine(basePath, $"{baseFileName}{ext}"); + int index = 1; + + // 文件超限则递增后缀切割 + while (File.Exists(finalPath) && new FileInfo(finalPath).Length >= maxFileSizeBytes) + { + finalPath = Path.Combine(basePath, $"{baseFileName}-{index}{ext}"); + index++; + } + + // 线程安全写入 + lock (_lockObj) + { + File.AppendAllText(finalPath, log + Environment.NewLine, Encoding.UTF8); + } + } + catch + { + // 原逻辑吞掉所有异常,如需日志可自行增加内部异常输出 + } + } +} \ No newline at end of file diff --git a/CloudBuilder.Request/Security/AuthRequest.auto.cs b/CloudBuilder.Request/Security/AuthRequest.auto.cs index 8591514..09235c5 100644 --- a/CloudBuilder.Request/Security/AuthRequest.auto.cs +++ b/CloudBuilder.Request/Security/AuthRequest.auto.cs @@ -1,3 +1,4 @@ + using CloudBuilder.Core.DependencyInjection.Request; using CloudBuilder.Core.Request; using CloudBuilder.Core.Service; @@ -9,6 +10,7 @@ using CloudBuilder.Security.Service; using CloudBuilder.Core.DependencyInjection.Request; using CloudBuilder.Security.Data; using CloudBuilder.Core.Authorization; + using System; namespace CloudBuilder.Request.Security { @@ -50,6 +52,30 @@ return Task.Run(() => serviceRequest.PostAsJsonAsync("/Auth/R { Task.Run(() => serviceRequest.PostAsync("/Auth/Logout")).GetAwaiter().GetResult(); } + public bool ConfirmPassword(string password) + { +return Task.Run(() => serviceRequest.PostAsJsonAsync("/Auth/ConfirmPassword?password=" + password.ToString())).GetAwaiter().GetResult(); } + + public string BeginBiometricRegistration(string deviceName) + { +return Task.Run(() => serviceRequest.PostAsJsonAsync("/Auth/BeginBiometricRegistration?deviceName=" + deviceName.ToString())).GetAwaiter().GetResult(); } + + public BiometricRegistrationResultDto FinishBiometricRegistration(FinishBiometricRegistrationData dto) + { +return Task.Run(() => serviceRequest.PostAsJsonAsync("/Auth/FinishBiometricRegistration" , dto)).GetAwaiter().GetResult(); } + + public BiometricLoginChallengeDto BeginBiometricLogin(string username) + { +return Task.Run(() => serviceRequest.PostAsJsonAsync("/Auth/BeginBiometricLogin?username=" + username.ToString())).GetAwaiter().GetResult(); } + + public LoginProfile FinishBiometricLogin(FinishBiometricLoginData dto) + { +return Task.Run(() => serviceRequest.PostAsJsonAsync("/Auth/FinishBiometricLogin" , dto)).GetAwaiter().GetResult(); } + + public void RemoveBiometric() + { + Task.Run(() => serviceRequest.PostAsync("/Auth/RemoveBiometric")).GetAwaiter().GetResult(); } + } } \ No newline at end of file diff --git a/CloudBuilder.Request/Security/LocaleRequest.auto.cs b/CloudBuilder.Request/Security/LocaleRequest.auto.cs index a484ba7..fac21cf 100644 --- a/CloudBuilder.Request/Security/LocaleRequest.auto.cs +++ b/CloudBuilder.Request/Security/LocaleRequest.auto.cs @@ -1,3 +1,4 @@ + using CloudBuilder.Core.DependencyInjection.Request; using CloudBuilder.Core.Request; using CloudBuilder.Core.Service; @@ -9,6 +10,7 @@ using CloudBuilder.Security.Service; using CloudBuilder.Core.DependencyInjection.Request; using CloudBuilder.Security.Data; using CloudBuilder.Core.Authorization; + using System; using CloudBuilder.Security.Entity; using CloudBuilder.Security.SearchCriteria; using CloudBuilder.Security; diff --git a/CloudBuilder.Request/Security/SecurityActorRequest.auto.cs b/CloudBuilder.Request/Security/SecurityActorRequest.auto.cs index c42c30e..1454380 100644 --- a/CloudBuilder.Request/Security/SecurityActorRequest.auto.cs +++ b/CloudBuilder.Request/Security/SecurityActorRequest.auto.cs @@ -1,3 +1,4 @@ + using CloudBuilder.Core.DependencyInjection.Request; using CloudBuilder.Core.Request; using CloudBuilder.Core.Service; @@ -9,10 +10,10 @@ using CloudBuilder.Security.Service; using CloudBuilder.Core.DependencyInjection.Request; using CloudBuilder.Security.Data; using CloudBuilder.Core.Authorization; + using System; using CloudBuilder.Security.Entity; using CloudBuilder.Security.SearchCriteria; using CloudBuilder.Security; - using System; namespace CloudBuilder.Request.Security { diff --git a/CloudBuilder.Request/Security/SecurityFunctionRequest.auto.cs b/CloudBuilder.Request/Security/SecurityFunctionRequest.auto.cs index be59046..f13bdc9 100644 --- a/CloudBuilder.Request/Security/SecurityFunctionRequest.auto.cs +++ b/CloudBuilder.Request/Security/SecurityFunctionRequest.auto.cs @@ -1,3 +1,4 @@ + using CloudBuilder.Core.DependencyInjection.Request; using CloudBuilder.Core.Request; using CloudBuilder.Core.Service; @@ -9,10 +10,10 @@ using CloudBuilder.Security.Service; using CloudBuilder.Core.DependencyInjection.Request; using CloudBuilder.Security.Data; using CloudBuilder.Core.Authorization; + using System; using CloudBuilder.Security.Entity; using CloudBuilder.Security.SearchCriteria; using CloudBuilder.Security; - using System; namespace CloudBuilder.Request.Security { diff --git a/CloudBuilder.Request/Security/SecurityGeneratorNumberRequest.auto.cs b/CloudBuilder.Request/Security/SecurityGeneratorNumberRequest.auto.cs index b4327c5..b28e239 100644 --- a/CloudBuilder.Request/Security/SecurityGeneratorNumberRequest.auto.cs +++ b/CloudBuilder.Request/Security/SecurityGeneratorNumberRequest.auto.cs @@ -1,3 +1,4 @@ + using CloudBuilder.Core.DependencyInjection.Request; using CloudBuilder.Core.Request; using CloudBuilder.Core.Service; @@ -9,10 +10,10 @@ using CloudBuilder.Security.Service; using CloudBuilder.Core.DependencyInjection.Request; using CloudBuilder.Security.Data; using CloudBuilder.Core.Authorization; + using System; using CloudBuilder.Security.Entity; using CloudBuilder.Security.SearchCriteria; using CloudBuilder.Security; - using System; namespace CloudBuilder.Request.Security { diff --git a/CloudBuilder.Request/Security/SecurityGroupRequest.auto.cs b/CloudBuilder.Request/Security/SecurityGroupRequest.auto.cs index db3f6e2..217e1ec 100644 --- a/CloudBuilder.Request/Security/SecurityGroupRequest.auto.cs +++ b/CloudBuilder.Request/Security/SecurityGroupRequest.auto.cs @@ -1,3 +1,4 @@ + using CloudBuilder.Core.DependencyInjection.Request; using CloudBuilder.Core.Request; using CloudBuilder.Core.Service; @@ -9,10 +10,10 @@ using CloudBuilder.Security.Service; using CloudBuilder.Core.DependencyInjection.Request; using CloudBuilder.Security.Data; using CloudBuilder.Core.Authorization; + using System; using CloudBuilder.Security.Entity; using CloudBuilder.Security.SearchCriteria; using CloudBuilder.Security; - using System; namespace CloudBuilder.Request.Security { diff --git a/CloudBuilder.Request/Security/SecurityLockRequest.auto.cs b/CloudBuilder.Request/Security/SecurityLockRequest.auto.cs index c4622d4..3f46fbc 100644 --- a/CloudBuilder.Request/Security/SecurityLockRequest.auto.cs +++ b/CloudBuilder.Request/Security/SecurityLockRequest.auto.cs @@ -1,3 +1,4 @@ + using CloudBuilder.Core.DependencyInjection.Request; using CloudBuilder.Core.Request; using CloudBuilder.Core.Service; @@ -9,10 +10,10 @@ using CloudBuilder.Security.Service; using CloudBuilder.Core.DependencyInjection.Request; using CloudBuilder.Security.Data; using CloudBuilder.Core.Authorization; + using System; using CloudBuilder.Security.Entity; using CloudBuilder.Security.SearchCriteria; using CloudBuilder.Security; - using System; namespace CloudBuilder.Request.Security { diff --git a/CloudBuilder.Request/Security/SecurityMasterRequest.auto.cs b/CloudBuilder.Request/Security/SecurityMasterRequest.auto.cs index 47e8b64..8bf757b 100644 --- a/CloudBuilder.Request/Security/SecurityMasterRequest.auto.cs +++ b/CloudBuilder.Request/Security/SecurityMasterRequest.auto.cs @@ -1,3 +1,4 @@ + using CloudBuilder.Core.DependencyInjection.Request; using CloudBuilder.Core.Request; using CloudBuilder.Core.Service; @@ -9,10 +10,10 @@ using CloudBuilder.Security.Service; using CloudBuilder.Core.DependencyInjection.Request; using CloudBuilder.Security.Data; using CloudBuilder.Core.Authorization; + using System; using CloudBuilder.Security.Entity; using CloudBuilder.Security.SearchCriteria; using CloudBuilder.Security; - using System; namespace CloudBuilder.Request.Security { diff --git a/CloudBuilder.Request/Security/SecurityPermissionRequest.auto.cs b/CloudBuilder.Request/Security/SecurityPermissionRequest.auto.cs index 1133f8b..8ca088e 100644 --- a/CloudBuilder.Request/Security/SecurityPermissionRequest.auto.cs +++ b/CloudBuilder.Request/Security/SecurityPermissionRequest.auto.cs @@ -1,3 +1,4 @@ + using CloudBuilder.Core.DependencyInjection.Request; using CloudBuilder.Core.Request; using CloudBuilder.Core.Service; @@ -9,10 +10,10 @@ using CloudBuilder.Security.Service; using CloudBuilder.Core.DependencyInjection.Request; using CloudBuilder.Security.Data; using CloudBuilder.Core.Authorization; + using System; using CloudBuilder.Security.Entity; using CloudBuilder.Security.SearchCriteria; using CloudBuilder.Security; - using System; namespace CloudBuilder.Request.Security { diff --git a/CloudBuilder.Request/Security/SecurityUserPreferenceRequest.auto.cs b/CloudBuilder.Request/Security/SecurityUserPreferenceRequest.auto.cs index 30e3048..95f02a8 100644 --- a/CloudBuilder.Request/Security/SecurityUserPreferenceRequest.auto.cs +++ b/CloudBuilder.Request/Security/SecurityUserPreferenceRequest.auto.cs @@ -1,3 +1,4 @@ + using CloudBuilder.Core.DependencyInjection.Request; using CloudBuilder.Core.Request; using CloudBuilder.Core.Service; @@ -9,10 +10,10 @@ using CloudBuilder.Security.Service; using CloudBuilder.Core.DependencyInjection.Request; using CloudBuilder.Security.Data; using CloudBuilder.Core.Authorization; + using System; using CloudBuilder.Security.Entity; using CloudBuilder.Security.SearchCriteria; using CloudBuilder.Security; - using System; namespace CloudBuilder.Request.Security { diff --git a/CloudBuilder.Request/Security/SecurityUserRequest.auto.cs b/CloudBuilder.Request/Security/SecurityUserRequest.auto.cs index 3158527..b223254 100644 --- a/CloudBuilder.Request/Security/SecurityUserRequest.auto.cs +++ b/CloudBuilder.Request/Security/SecurityUserRequest.auto.cs @@ -1,3 +1,4 @@ + using CloudBuilder.Core.DependencyInjection.Request; using CloudBuilder.Core.Request; using CloudBuilder.Core.Service; @@ -9,10 +10,10 @@ using CloudBuilder.Security.Service; using CloudBuilder.Core.DependencyInjection.Request; using CloudBuilder.Security.Data; using CloudBuilder.Core.Authorization; + using System; using CloudBuilder.Security.Entity; using CloudBuilder.Security.SearchCriteria; using CloudBuilder.Security; - using System; namespace CloudBuilder.Request.Security { diff --git a/CloudBuilder.Request/Security/YesNoPolicyRequest.auto.cs b/CloudBuilder.Request/Security/YesNoPolicyRequest.auto.cs index b778990..c549498 100644 --- a/CloudBuilder.Request/Security/YesNoPolicyRequest.auto.cs +++ b/CloudBuilder.Request/Security/YesNoPolicyRequest.auto.cs @@ -1,3 +1,4 @@ + using CloudBuilder.Core.DependencyInjection.Request; using CloudBuilder.Core.Request; using CloudBuilder.Core.Service; @@ -9,10 +10,11 @@ using CloudBuilder.Security.Service; using CloudBuilder.Core.DependencyInjection.Request; using CloudBuilder.Security.Data; using CloudBuilder.Core.Authorization; + using System; using CloudBuilder.Security.Entity; using CloudBuilder.Security.SearchCriteria; using CloudBuilder.Security; - using System; + using Microsoft.Extensions.Options; namespace CloudBuilder.Request.Security { diff --git a/CloudBuilder.Security.Service/AuthService.cs b/CloudBuilder.Security.Service/AuthService.cs index 4dbe5ea..ac76248 100644 --- a/CloudBuilder.Security.Service/AuthService.cs +++ b/CloudBuilder.Security.Service/AuthService.cs @@ -13,16 +13,22 @@ 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 { @@ -32,13 +38,15 @@ namespace CloudBuilder.Security.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) + 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>(); } @@ -60,7 +68,7 @@ namespace CloudBuilder.Security.Service var descKey = service.Configuration[SecuritySettingsPolicy.SecuritySettings_DescKey]; var privateKey = service.Configuration[SecuritySettingsPolicy.SecuritySettings_RsaPrivateKey]; - var rsa = new RSA(privateKey, true); + var rsa = new System.RSA(privateKey, true); var psd = rsa.DecodeOrNull(data.RsaUserPassword); if (string.IsNullOrEmpty(psd)) throw new ValidatedException(certification); @@ -96,6 +104,19 @@ namespace CloudBuilder.Security.Service 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(); @@ -103,7 +124,7 @@ namespace CloudBuilder.Security.Service 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); + claims.Add(ClaimPolicy.CLAINM_LOCALE, culture); var accessToken = jwtGenerate.Generate(claims); if (httpContextAccessor.HttpContext.Request.Headers.ContainsKey("Authorization")) @@ -112,18 +133,10 @@ namespace CloudBuilder.Security.Service 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); + httpContextAccessor.HttpContext.Request.Headers.Add(ClaimPolicy.HEADER_LOCALE, culture); service.UserId = securityUserEntity.UserName; - securityUserEntity.ErrorLoginCount = 0; - securityUserEntity.IsAccountsLockedIndc = YesNoPolicy.NO; - securityUserEntity.AccountsLockedDatetime = null; - //更新最后登录日期 - securityUserEntity.LastLoginDatetime = DateTime.Now; - - repository.UpdateNow(securityUserEntity); - IRepository repositoryLog = service.GetRepository>(); SecurityUserWebLoginEntity webLogin = new SecurityUserWebLoginEntity @@ -445,7 +458,7 @@ namespace CloudBuilder.Security.Service 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); + claims.Add(ClaimPolicy.CLAINM_LOCALE, locale ?? CulturePolicy.ZH_CN); var accessToken = jwtGenerate.Generate(claims); @@ -484,5 +497,495 @@ namespace CloudBuilder.Security.Service 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); + } } } diff --git a/CloudBuilder.Security.Service/CloudBuilder.Security.Service.csproj b/CloudBuilder.Security.Service/CloudBuilder.Security.Service.csproj index f43e9ba..8621109 100644 --- a/CloudBuilder.Security.Service/CloudBuilder.Security.Service.csproj +++ b/CloudBuilder.Security.Service/CloudBuilder.Security.Service.csproj @@ -27,6 +27,7 @@ + diff --git a/CloudBuilder.Security.Service/WebAuthnAssertionPatcher.cs b/CloudBuilder.Security.Service/WebAuthnAssertionPatcher.cs new file mode 100644 index 0000000..34644cf --- /dev/null +++ b/CloudBuilder.Security.Service/WebAuthnAssertionPatcher.cs @@ -0,0 +1,92 @@ +using Fido2NetLib; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace CloudBuilder.Security.Service +{ + + /// + /// Re-encodes the base64url string fields on a freshly-deserialized + /// as byte[]. + /// + /// Fido2NetLib 1.0.0-alpha has these properties typed as string + /// (no [JsonConverter] — that was added in v1.1.0+), but its + /// MakeAssertionAsync internally does + /// (byte[])raw.Id and + /// (byte[])raw.Response.ClientDataJson to look up the credential + /// and verify the signature. The hard cast throws + /// "Unable to cast object of type 'System.String' to type 'System.Byte[]'". + /// + /// This helper was originally a private static method on + /// UserProfileProcess; it was extracted so + /// FinishEpayslipBiometricAssertion (the new epayslip-unlock + /// flow) can share the same Fido2NetLib 1.0.0-alpha workaround without + /// duplicating the reflection-patching logic. + /// + /// Behaviour: + /// 1. Checks the runtime property type. + /// 2. If it's already byte[] (v1.1.0+), base64url-decodes the + /// incoming string and stores the bytes. + /// 3. If it's string (v1.0.0-alpha), we can't store byte[] + /// in a string field — CLR enforces type safety. Fido2NetLib's + /// internal (byte[]) cast will then throw on the next call with a + /// clear InvalidCastException we can diagnose from the stack trace. + /// + public static class WebAuthnAssertionPatcher + { + public static void PatchResponseToByteArrays(AuthenticatorAssertionRawResponse raw) + { + if (raw == null) return; + + PatchStringPropertyToByteArray(raw, "Id"); + PatchStringPropertyToByteArray(raw, "RawId"); + + if (raw.Response != null) + { + PatchStringPropertyToByteArray(raw.Response, "Id"); + PatchStringPropertyToByteArray(raw.Response, "AuthenticatorData"); + PatchStringPropertyToByteArray(raw.Response, "Signature"); + PatchStringPropertyToByteArray(raw.Response, "ClientDataJson"); + PatchStringPropertyToByteArray(raw.Response, "UserHandle"); + } + } + + private static void PatchStringPropertyToByteArray(object target, string propertyName) + { + if (target == null) return; + var prop = target.GetType().GetProperty(propertyName); + if (prop == null) return; + if (!prop.CanWrite) return; + var current = prop.GetValue(target); + // If Fido2NetLib 1.1.0+ shape — property is already byte[]. + if (prop.PropertyType == typeof(byte[])) + { + if (current is byte[]) return; // already decoded by the converter + if (current is string s && !string.IsNullOrEmpty(s)) + { + try { prop.SetValue(target, Fido2NetLib.Base64Url.Decode(s)); } + catch { /* leave original value; Fido2NetLib will surface a clearer error */ } + } + return; + } + // If the property is `object` (1.0.0-alpha may store the raw + // deserialized value as object), we can safely put a byte[] in it. + if (prop.PropertyType == typeof(object)) + { + if (current is string s && !string.IsNullOrEmpty(s)) + { + try { prop.SetValue(target, Fido2NetLib.Base64Url.Decode(s)); } catch { } + } + return; + } + // If the property is `string` (1.0.0-alpha default), we cannot + // store byte[] in a string-typed field — CLR enforces type safety + // even via reflection. Fido2NetLib's internal (byte[]) cast will + // throw on the next call. The caller will see a clear + // InvalidCastException that we can diagnose from the stack trace. + } + } +} diff --git a/CloudBuilder.Security.Service/WebAuthnService.cs b/CloudBuilder.Security.Service/WebAuthnService.cs new file mode 100644 index 0000000..0b0e163 --- /dev/null +++ b/CloudBuilder.Security.Service/WebAuthnService.cs @@ -0,0 +1,221 @@ +using CloudBuilder.Security.Data; +using Fido2NetLib; +using Fido2NetLib.Objects; +using Microsoft.Extensions.Options; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace CloudBuilder.Security.Service +{ + + /// + /// Wraps the Fido2NetLib ceremony primitives (challenge generation, + /// attestation verification, assertion verification) so the process + /// layer can stay focused on the workflow. The frontend drives both + /// registration and login through this service via webmethods on + /// UserProfileProcess. + /// + public class WebAuthnService: IWebAuthnService + { + private readonly Fido2 _fido2; + private readonly WebAuthnOptions _options; + + public WebAuthnService(IOptions options) + { + _options = options.Value; + + var config = new Fido2.Configuration + { + ServerDomain = _options.RpId, + ServerName = _options.RpName, + Origin = _options.Origin, + // Fido2NetLib expresses the challenge length in BYTES here. + // 32 bytes = 256 bits, the WebAuthn-recommended minimum. + ChallengeSize = 32, + Timeout = (uint)_options.TimeoutMs + }; + _fido2 = new Fido2(config); + } + + public string Origin => _options.Origin; + public string RpId => _options.RpId; + + /// + /// Build the PublicKeyCredentialCreationOptions that the browser + /// will hand to navigator.credentials.create(). + /// + /// HR+ username (becomes the WebAuthn user.name + user.displayName). + /// Stable per-user byte array. We use the username's UTF-8 bytes — fine for v1 because usernames are unique. + /// If the user is re-enrolling on a new device, the existing credential ID is passed via excludeCredentials so the authenticator refuses to re-enroll the same one. For v1 we don't track this (one-device-only); pass an empty list. + public CredentialCreateOptions BuildCreationOptions(string username, byte[] userId, IReadOnlyList existingCredentialIds) + { + var user = new User + { + Name = username, + Id = userId, + DisplayName = username + }; + + var exclude = existingCredentialIds + .Select(id => new PublicKeyCredentialDescriptor { Id = id }) + .ToList(); + + return _fido2.RequestNewCredential( + user, + exclude, + new AuthenticatorSelection + { + // Prefer the platform authenticator (Touch ID / Windows Hello / + // fingerprint) over roaming keys. + AuthenticatorAttachment = AuthenticatorAttachment.Platform, + UserVerification = UserVerificationRequirement.Preferred + }, + AttestationConveyancePreference.None); + } + + /// + /// Verify the attestation returned by the browser and return the + /// stored credential fields. Throws on validation failure. + /// + public async Task FinishRegistrationAsync( + AuthenticatorAttestationRawResponse attestationResponse, + CredentialCreateOptions originalOptions, + Func> isCredentialIdUnique) + { + // Fido2NetLib's MakeNewCredentialAsync takes a delegate that, given + // the new credential id, decides whether the credential is unique + // for the user. In our schema the credential id is stored on + // SecurityUser.biometric_credential_id, so uniqueness is automatic + // — the same credential id can never be assigned to two users. + // We always return true. + var makeResult = await _fido2.MakeNewCredentialAsync( + attestationResponse, + originalOptions, + (args) => Task.FromResult(true), + requestTokenBindingId: null); + + if (makeResult?.Result is not AttestationVerificationSuccess success) + { + throw new InvalidOperationException( + "Fido2 attestation verification failed: " + + (makeResult?.ErrorMessage ?? "unknown error")); + } + + return new StoredBiometricCredential + { + CredentialId = success.CredentialId, + PublicKey = success.PublicKey + }; + } + + /// + /// Build the PublicKeyCredentialRequestOptions that the browser will + /// hand to navigator.credentials.get(). Only the stored + /// credential for this user is allowed. + /// + public AssertionOptions BuildRequestOptions(byte[] storedCredentialId) + { + var allowed = new List + { + new() { Id = storedCredentialId } + }; + return _fido2.GetAssertionOptions(allowed, UserVerificationRequirement.Preferred); + } + + /// + /// Same as but returns a + /// browser-ready JSON string directly. + /// + /// Fido2NetLib 1.0.0-alpha's AssertionOptions.ToJson() serializes + /// AllowCredentials[].Id as PascalCase (the C# property is + /// Id with no [JsonProperty("id")] attribute — the + /// attribute was added in v1.1.0+). The browser's + /// @simplewebauthn/browser toPublicKeyCredentialDescriptor + /// does const { id } = descriptor, gets undefined, then + /// passes it to base64URLStringToBuffer(id) which does + /// id.replace(...) and throws + /// "Cannot read properties of undefined (reading 'replace')". + /// + /// We bypass Fido2NetLib's serializer entirely and emit the JSON + /// shape the WebAuthn spec + the browser expect directly: camelCase + /// keys, allowCredentials[].id as base64url, type: "public-key", + /// userVerification: "preferred". + /// + public string BuildRequestOptionsJson(byte[] storedCredentialId) + { + var options = _fido2.GetAssertionOptions( + new List { new() { Id = storedCredentialId } }, + UserVerificationRequirement.Preferred); + + var result = new + { + challenge = options.Challenge, + rpId = options.RpId, + allowCredentials = new[] + { + new + { + id = Fido2NetLib.Base64Url.Encode(storedCredentialId), + type = "public-key", + }, + }, + userVerification = options.UserVerification?.ToString().ToLower() ?? "preferred", + timeout = options.Timeout, + }; + + return JsonConvert.SerializeObject(result); + } + + /// + /// Verify the assertion returned by the browser. Returns the new + /// signature counter to persist. Throws on validation failure. + /// + public async Task FinishAssertionAsync( + AuthenticatorAssertionRawResponse assertionResponse, + AssertionOptions originalOptions, + byte[] storedPublicKey, + uint storedCounter) + { + var result = await _fido2.MakeAssertionAsync( + assertionResponse, + originalOptions, + storedPublicKey, + storedCounter, + (args) => Task.FromResult(true), // userHandle ownership always true for our single-user case + requestTokenBindingId: null); + + if (result is null) + { + throw new InvalidOperationException("Fido2 assertion verification returned null"); + } + + // MakeAssertionAsync returns AssertionVerificationSuccess, but in + // some Fido2NetLib versions it may surface a status/error wrapper. + // Use the typed object via dynamic to stay version-agnostic. + uint newCounter; + try + { + newCounter = (uint)result.Counter; + } + catch + { + throw new InvalidOperationException("Fido2 assertion verification did not return a counter"); + } + + // The counter MUST be strictly greater than the stored value + // (replay protection). Fido2NetLib already enforces this server-side + // and would have thrown; the second check is a belt-and-suspenders. + if (newCounter <= storedCounter) + { + throw new InvalidOperationException( + $"WebAuthn counter regression: stored={storedCounter}, new={newCounter}"); + } + + return newCounter; + } + } +} diff --git a/CloudBuilder.Security/CloudBuilder.Security.csproj b/CloudBuilder.Security/CloudBuilder.Security.csproj index e111666..5be5582 100644 --- a/CloudBuilder.Security/CloudBuilder.Security.csproj +++ b/CloudBuilder.Security/CloudBuilder.Security.csproj @@ -22,6 +22,7 @@ + diff --git a/CloudBuilder.Security/Data/BiometricLoginChallengeDto.cs b/CloudBuilder.Security/Data/BiometricLoginChallengeDto.cs new file mode 100644 index 0000000..10b7f88 --- /dev/null +++ b/CloudBuilder.Security/Data/BiometricLoginChallengeDto.cs @@ -0,0 +1,19 @@ +using CloudBuilder.Core.Authorization; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace CloudBuilder.Security.Data +{ + [TypeScript] + public class BiometricLoginChallengeDto + { + /// AssertionOptions JSON to feed to navigator.credentials.get(). + public string RequestOptionsJson { get; set; } = null!; + + /// Opaque id identifying this challenge — we round-trip the requestOptionsJson instead, so this is informational and currently unused. + public string ChallengeId { get; set; } = string.Empty; + } +} diff --git a/CloudBuilder.Security/Data/BiometricRegistrationResultDto.cs b/CloudBuilder.Security/Data/BiometricRegistrationResultDto.cs new file mode 100644 index 0000000..4ddfda8 --- /dev/null +++ b/CloudBuilder.Security/Data/BiometricRegistrationResultDto.cs @@ -0,0 +1,15 @@ +using CloudBuilder.Core.Authorization; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace CloudBuilder.Security.Data +{ + [TypeScript] + public class BiometricRegistrationResultDto + { + public string CredentialIdBase64Url { get; set; } = string.Empty; + } +} diff --git a/CloudBuilder.Security/Data/FinishBiometricLoginData.cs b/CloudBuilder.Security/Data/FinishBiometricLoginData.cs new file mode 100644 index 0000000..0e7b41f --- /dev/null +++ b/CloudBuilder.Security/Data/FinishBiometricLoginData.cs @@ -0,0 +1,20 @@ +using CloudBuilder.Core.Authorization; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace CloudBuilder.Security.Data +{ + [TypeScript] + public class FinishBiometricLoginData + { + public string Username { get; set; } = null!; + public string AssertionJson { get; set; } = null!; + public string RequestOptionsJson { get; set; } = null!; + + /// Opaque id; informational only. We round-trip the requestOptionsJson. + public string ChallengeId { get; set; } = string.Empty; + } +} diff --git a/CloudBuilder.Security/Data/FinishBiometricRegistrationData.cs b/CloudBuilder.Security/Data/FinishBiometricRegistrationData.cs new file mode 100644 index 0000000..08ec9c3 --- /dev/null +++ b/CloudBuilder.Security/Data/FinishBiometricRegistrationData.cs @@ -0,0 +1,19 @@ +using CloudBuilder.Core.Authorization; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace CloudBuilder.Security.Data +{ + [TypeScript] + public class FinishBiometricRegistrationData + { + /// JSON the browser returns from navigator.credentials.create(). + public string AttestationJson { get; set; } = null!; + + /// The CredentialCreateOptions JSON we returned from BeginBiometricRegistration. Round-tripped because Fido2NetLib verifies the challenge against it. + public string CreationOptionsJson { get; set; } = null!; + } +} diff --git a/CloudBuilder.Security/Data/WebAuthnOptions.cs b/CloudBuilder.Security/Data/WebAuthnOptions.cs new file mode 100644 index 0000000..417a25c --- /dev/null +++ b/CloudBuilder.Security/Data/WebAuthnOptions.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace CloudBuilder.Security.Data +{ + + /// + /// Plain-old-data wrapper for the credential fields we persist on + /// SecurityUser. The frontend never sees this type. + /// + public class StoredBiometricCredential + { + public byte[] CredentialId { get; set; } = Array.Empty(); + public byte[] PublicKey { get; set; } = Array.Empty(); + } + + public class WebAuthnOptions + { + /// + /// Relying Party ID — the registrable domain suffix of the + /// origin's host. For dev: "localhost". For production: + /// "eleave.1010printing.com". + /// + public string RpId { get; set; } = "localhost"; + + /// + /// Human-readable relying-party name. Shown in the OS biometric + /// prompt ("Use HR Plus to sign in to localhost"). + /// + public string RpName { get; set; } = "HR Plus"; + + /// + /// The full origin (scheme + host + port) the user is loading + /// the page from. Fido2NetLib verifies the attestation/assertion + /// origin matches this exactly. Must be HTTPS in production + /// (localhost is exempt). + /// + public string Origin { get; set; } = "https://localhost:8000"; + + /// + /// WebAuthn ceremony timeout in milliseconds. Default 60s. + /// + public int TimeoutMs { get; set; } = 60000; + } +} diff --git a/CloudBuilder.Security/Entity/SecurityUserEntity.auto.cs b/CloudBuilder.Security/Entity/SecurityUserEntity.auto.cs index f226eb3..835c981 100644 --- a/CloudBuilder.Security/Entity/SecurityUserEntity.auto.cs +++ b/CloudBuilder.Security/Entity/SecurityUserEntity.auto.cs @@ -26,42 +26,22 @@ namespace CloudBuilder.Security.Entity public string UserName { get; set; } [Column("user_local_name")] public string UserLocalName { get; set; } - [Column("user_eng_name")] - public string? UserEngName { get; set; } - [Column("company_name")] - public string? CompanyName { get; set; } - [Column("industry")] - public string? Industry { get; set; } - [Column("user_password")] - public string? UserPassword { get; set; } - [Column("user_mobile")] - public string? UserMobile { get; set; } - [Column("user_address")] - public string? UserAddress { get; set; } - [Column("postcode")] - public string? Postcode { get; set; } - [Column("community")] - public int Community { get; set; } - [Column("user_grade")] - public int UserGrade { get; set; } - [Column("user_type")] - public string? UserType { get; set; } - [Column("user_wechat_account")] - public string? UserWechatAccount { get; set; } - [Column("user_alipay_account")] - public string? UserAlipayAccount { get; set; } - [Column("user_alipay_account_img_path")] - public string? UserAlipayAccountImgPath { get; set; } - [Column("user_wechat_account_img_path")] - public string? UserWechatAccountImgPath { get; set; } - [Column("user_icon_img_path")] - public string? UserIconImgPath { get; set; } - [Column("user_accounts_expire_datetime")] - public Nullable UserAccountsExpireDatetime { get; set; } - [Column("user_is_valid_indc")] - public string? UserIsValidIndc { get; set; } - [Column("is_forece_change_password_indc")] - public string? IsForeceChangePasswordIndc { get; set; } + [Column("biometric_enrolled_at")] + public Nullable BiometricEnrolledAt { get; set; } + [Column("biometric_last_used_at")] + public Nullable BiometricLastUsedAt { get; set; } + [Column("last_login_datetime")] + public Nullable LastLoginDatetime { get; set; } + [Column("error_login_count")] + public int ErrorLoginCount { get; set; } + [Column("biometric_credential_id")] + public string? BiometricCredentialId { get; set; } + [Column("biometric_public_key")] + public string? BiometricPublicKey { get; set; } + [Column("biometric_counter")] + public int BiometricCounter { get; set; } + [Column("biometric_device_name")] + public string? BiometricDeviceName { get; set; } [Column("is_accounts_locked_indc")] public string? IsAccountsLockedIndc { get; set; } [Column("accounts_locked_datetime")] @@ -74,10 +54,42 @@ namespace CloudBuilder.Security.Entity public Nullable UserTypeEndDatetime { get; set; } [Column("room_name")] public string? RoomName { get; set; } - [Column("last_login_datetime")] - public Nullable LastLoginDatetime { get; set; } - [Column("error_login_count")] - public int ErrorLoginCount { get; set; } + [Column("user_alipay_account_img_path")] + public string? UserAlipayAccountImgPath { get; set; } + [Column("user_wechat_account_img_path")] + public string? UserWechatAccountImgPath { get; set; } + [Column("user_icon_img_path")] + public string? UserIconImgPath { get; set; } + [Column("user_accounts_expire_datetime")] + public Nullable UserAccountsExpireDatetime { get; set; } + [Column("user_is_valid_indc")] + public string? UserIsValidIndc { get; set; } + [Column("is_forece_change_password_indc")] + public string? IsForeceChangePasswordIndc { get; set; } + [Column("postcode")] + public string? Postcode { get; set; } + [Column("community")] + public int Community { get; set; } + [Column("user_grade")] + public int UserGrade { get; set; } + [Column("user_type")] + public string? UserType { get; set; } + [Column("user_wechat_account")] + public string? UserWechatAccount { get; set; } + [Column("user_alipay_account")] + public string? UserAlipayAccount { get; set; } + [Column("user_eng_name")] + public string? UserEngName { get; set; } + [Column("company_name")] + public string? CompanyName { get; set; } + [Column("industry")] + public string? Industry { get; set; } + [Column("user_password")] + public string? UserPassword { get; set; } + [Column("user_mobile")] + public string? UserMobile { get; set; } + [Column("user_address")] + public string? UserAddress { get; set; } public const string CREATED_DATETIME = "CreatedDatetime"; public const string CREATED_BY = "CreatedBy"; @@ -85,35 +97,41 @@ namespace CloudBuilder.Security.Entity public const string UPDATED_BY = "UpdatedBy"; public const string USER_NAME = "UserName"; public const string USER_LOCAL_NAME = "UserLocalName"; - public const string USER_ENG_NAME = "UserEngName"; - public const string COMPANY_NAME = "CompanyName"; - public const string INDUSTRY = "Industry"; - public const string USER_PASSWORD = "UserPassword"; - public const string USER_MOBILE = "UserMobile"; - public const string USER_ADDRESS = "UserAddress"; - public const string POSTCODE = "Postcode"; - public const string COMMUNITY = "Community"; - public const string USER_GRADE = "UserGrade"; - public const string USER_TYPE = "UserType"; - public const string USER_WECHAT_ACCOUNT = "UserWechatAccount"; - public const string USER_ALIPAY_ACCOUNT = "UserAlipayAccount"; - public const string USER_ALIPAY_ACCOUNT_IMG_PATH = "UserAlipayAccountImgPath"; - public const string USER_WECHAT_ACCOUNT_IMG_PATH = "UserWechatAccountImgPath"; - public const string USER_ICON_IMG_PATH = "UserIconImgPath"; - public const string USER_ACCOUNTS_EXPIRE_DATETIME = "UserAccountsExpireDatetime"; - public const string USER_IS_VALID_INDC = "UserIsValidIndc"; - public const string IS_FORECE_CHANGE_PASSWORD_INDC = "IsForeceChangePasswordIndc"; + public const string BIOMETRIC_ENROLLED_AT = "BiometricEnrolledAt"; + public const string BIOMETRIC_LAST_USED_AT = "BiometricLastUsedAt"; + public const string LAST_LOGIN_DATETIME = "LastLoginDatetime"; + public const string ERROR_LOGIN_COUNT = "ErrorLoginCount"; + public const string BIOMETRIC_CREDENTIAL_ID = "BiometricCredentialId"; + public const string BIOMETRIC_PUBLIC_KEY = "BiometricPublicKey"; + public const string BIOMETRIC_COUNTER = "BiometricCounter"; + public const string BIOMETRIC_DEVICE_NAME = "BiometricDeviceName"; public const string IS_ACCOUNTS_LOCKED_INDC = "IsAccountsLockedIndc"; public const string ACCOUNTS_LOCKED_DATETIME = "AccountsLockedDatetime"; public const string USER_REMARK = "UserRemark"; public const string WELCOME_PAGE = "WelcomePage"; public const string USER_TYPE_END_DATETIME = "UserTypeEndDatetime"; public const string ROOM_NAME = "RoomName"; - public const string LAST_LOGIN_DATETIME = "LastLoginDatetime"; - public const string ERROR_LOGIN_COUNT = "ErrorLoginCount"; + public const string USER_ALIPAY_ACCOUNT_IMG_PATH = "UserAlipayAccountImgPath"; + public const string USER_WECHAT_ACCOUNT_IMG_PATH = "UserWechatAccountImgPath"; + public const string USER_ICON_IMG_PATH = "UserIconImgPath"; + public const string USER_ACCOUNTS_EXPIRE_DATETIME = "UserAccountsExpireDatetime"; + public const string USER_IS_VALID_INDC = "UserIsValidIndc"; + public const string IS_FORECE_CHANGE_PASSWORD_INDC = "IsForeceChangePasswordIndc"; + public const string POSTCODE = "Postcode"; + public const string COMMUNITY = "Community"; + public const string USER_GRADE = "UserGrade"; + public const string USER_TYPE = "UserType"; + public const string USER_WECHAT_ACCOUNT = "UserWechatAccount"; + public const string USER_ALIPAY_ACCOUNT = "UserAlipayAccount"; + public const string USER_ENG_NAME = "UserEngName"; + public const string COMPANY_NAME = "CompanyName"; + public const string INDUSTRY = "Industry"; + public const string USER_PASSWORD = "UserPassword"; + public const string USER_MOBILE = "UserMobile"; + public const string USER_ADDRESS = "UserAddress"; public const string DB_NAME_SECURITY_USER = "security_user"; - public const string DB_NAME_FIELDS="created_datetime,created_by,updated_datetime,updated_by,user_name,user_local_name,user_eng_name,company_name,industry,user_password,user_mobile,user_address,postcode,community,user_grade,user_type,user_wechat_account,user_alipay_account,user_alipay_account_img_path,user_wechat_account_img_path,user_icon_img_path,user_accounts_expire_datetime,user_is_valid_indc,is_forece_change_password_indc,is_accounts_locked_indc,accounts_locked_datetime,user_remark,welcome_page,user_type_end_datetime,room_name,last_login_datetime,error_login_count"; + public const string DB_NAME_FIELDS="created_datetime,created_by,updated_datetime,updated_by,user_name,user_local_name,biometric_enrolled_at,biometric_last_used_at,last_login_datetime,error_login_count,biometric_credential_id,biometric_public_key,biometric_counter,biometric_device_name,is_accounts_locked_indc,accounts_locked_datetime,user_remark,welcome_page,user_type_end_datetime,room_name,user_alipay_account_img_path,user_wechat_account_img_path,user_icon_img_path,user_accounts_expire_datetime,user_is_valid_indc,is_forece_change_password_indc,postcode,community,user_grade,user_type,user_wechat_account,user_alipay_account,user_eng_name,company_name,industry,user_password,user_mobile,user_address"; public const string DB_CREATED_DATETIME = "created_datetime"; public const string DB_CREATED_BY = "created_by"; @@ -121,32 +139,38 @@ namespace CloudBuilder.Security.Entity public const string DB_UPDATED_BY = "updated_by"; public const string DB_USER_NAME = "user_name"; public const string DB_USER_LOCAL_NAME = "user_local_name"; - public const string DB_USER_ENG_NAME = "user_eng_name"; - public const string DB_COMPANY_NAME = "company_name"; - public const string DB_INDUSTRY = "industry"; - public const string DB_USER_PASSWORD = "user_password"; - public const string DB_USER_MOBILE = "user_mobile"; - public const string DB_USER_ADDRESS = "user_address"; - public const string DB_POSTCODE = "postcode"; - public const string DB_COMMUNITY = "community"; - public const string DB_USER_GRADE = "user_grade"; - public const string DB_USER_TYPE = "user_type"; - public const string DB_USER_WECHAT_ACCOUNT = "user_wechat_account"; - public const string DB_USER_ALIPAY_ACCOUNT = "user_alipay_account"; - public const string DB_USER_ALIPAY_ACCOUNT_IMG_PATH = "user_alipay_account_img_path"; - public const string DB_USER_WECHAT_ACCOUNT_IMG_PATH = "user_wechat_account_img_path"; - public const string DB_USER_ICON_IMG_PATH = "user_icon_img_path"; - public const string DB_USER_ACCOUNTS_EXPIRE_DATETIME = "user_accounts_expire_datetime"; - public const string DB_USER_IS_VALID_INDC = "user_is_valid_indc"; - public const string DB_IS_FORECE_CHANGE_PASSWORD_INDC = "is_forece_change_password_indc"; + public const string DB_BIOMETRIC_ENROLLED_AT = "biometric_enrolled_at"; + public const string DB_BIOMETRIC_LAST_USED_AT = "biometric_last_used_at"; + public const string DB_LAST_LOGIN_DATETIME = "last_login_datetime"; + public const string DB_ERROR_LOGIN_COUNT = "error_login_count"; + public const string DB_BIOMETRIC_CREDENTIAL_ID = "biometric_credential_id"; + public const string DB_BIOMETRIC_PUBLIC_KEY = "biometric_public_key"; + public const string DB_BIOMETRIC_COUNTER = "biometric_counter"; + public const string DB_BIOMETRIC_DEVICE_NAME = "biometric_device_name"; public const string DB_IS_ACCOUNTS_LOCKED_INDC = "is_accounts_locked_indc"; public const string DB_ACCOUNTS_LOCKED_DATETIME = "accounts_locked_datetime"; public const string DB_USER_REMARK = "user_remark"; public const string DB_WELCOME_PAGE = "welcome_page"; public const string DB_USER_TYPE_END_DATETIME = "user_type_end_datetime"; public const string DB_ROOM_NAME = "room_name"; - public const string DB_LAST_LOGIN_DATETIME = "last_login_datetime"; - public const string DB_ERROR_LOGIN_COUNT = "error_login_count"; + public const string DB_USER_ALIPAY_ACCOUNT_IMG_PATH = "user_alipay_account_img_path"; + public const string DB_USER_WECHAT_ACCOUNT_IMG_PATH = "user_wechat_account_img_path"; + public const string DB_USER_ICON_IMG_PATH = "user_icon_img_path"; + public const string DB_USER_ACCOUNTS_EXPIRE_DATETIME = "user_accounts_expire_datetime"; + public const string DB_USER_IS_VALID_INDC = "user_is_valid_indc"; + public const string DB_IS_FORECE_CHANGE_PASSWORD_INDC = "is_forece_change_password_indc"; + public const string DB_POSTCODE = "postcode"; + public const string DB_COMMUNITY = "community"; + public const string DB_USER_GRADE = "user_grade"; + public const string DB_USER_TYPE = "user_type"; + public const string DB_USER_WECHAT_ACCOUNT = "user_wechat_account"; + public const string DB_USER_ALIPAY_ACCOUNT = "user_alipay_account"; + public const string DB_USER_ENG_NAME = "user_eng_name"; + public const string DB_COMPANY_NAME = "company_name"; + public const string DB_INDUSTRY = "industry"; + public const string DB_USER_PASSWORD = "user_password"; + public const string DB_USER_MOBILE = "user_mobile"; + public const string DB_USER_ADDRESS = "user_address"; public static SecurityUserEntity GetInstance() @@ -172,32 +196,38 @@ namespace CloudBuilder.Security.Entity this.UpdatedBy = source.UpdatedBy; this.UserName = source.UserName; this.UserLocalName = source.UserLocalName; - this.UserEngName = source.UserEngName; - this.CompanyName = source.CompanyName; - this.Industry = source.Industry; - this.UserPassword = source.UserPassword; - this.UserMobile = source.UserMobile; - this.UserAddress = source.UserAddress; - this.Postcode = source.Postcode; - this.Community = source.Community; - this.UserGrade = source.UserGrade; - this.UserType = source.UserType; - this.UserWechatAccount = source.UserWechatAccount; - this.UserAlipayAccount = source.UserAlipayAccount; - this.UserAlipayAccountImgPath = source.UserAlipayAccountImgPath; - this.UserWechatAccountImgPath = source.UserWechatAccountImgPath; - this.UserIconImgPath = source.UserIconImgPath; - this.UserAccountsExpireDatetime = source.UserAccountsExpireDatetime; - this.UserIsValidIndc = source.UserIsValidIndc; - this.IsForeceChangePasswordIndc = source.IsForeceChangePasswordIndc; + this.BiometricEnrolledAt = source.BiometricEnrolledAt; + this.BiometricLastUsedAt = source.BiometricLastUsedAt; + this.LastLoginDatetime = source.LastLoginDatetime; + this.ErrorLoginCount = source.ErrorLoginCount; + this.BiometricCredentialId = source.BiometricCredentialId; + this.BiometricPublicKey = source.BiometricPublicKey; + this.BiometricCounter = source.BiometricCounter; + this.BiometricDeviceName = source.BiometricDeviceName; this.IsAccountsLockedIndc = source.IsAccountsLockedIndc; this.AccountsLockedDatetime = source.AccountsLockedDatetime; this.UserRemark = source.UserRemark; this.WelcomePage = source.WelcomePage; this.UserTypeEndDatetime = source.UserTypeEndDatetime; this.RoomName = source.RoomName; - this.LastLoginDatetime = source.LastLoginDatetime; - this.ErrorLoginCount = source.ErrorLoginCount; + this.UserAlipayAccountImgPath = source.UserAlipayAccountImgPath; + this.UserWechatAccountImgPath = source.UserWechatAccountImgPath; + this.UserIconImgPath = source.UserIconImgPath; + this.UserAccountsExpireDatetime = source.UserAccountsExpireDatetime; + this.UserIsValidIndc = source.UserIsValidIndc; + this.IsForeceChangePasswordIndc = source.IsForeceChangePasswordIndc; + this.Postcode = source.Postcode; + this.Community = source.Community; + this.UserGrade = source.UserGrade; + this.UserType = source.UserType; + this.UserWechatAccount = source.UserWechatAccount; + this.UserAlipayAccount = source.UserAlipayAccount; + this.UserEngName = source.UserEngName; + this.CompanyName = source.CompanyName; + this.Industry = source.Industry; + this.UserPassword = source.UserPassword; + this.UserMobile = source.UserMobile; + this.UserAddress = source.UserAddress; if (includeSystemFields) { diff --git a/CloudBuilder.Security/Service/IAuthService.cs b/CloudBuilder.Security/Service/IAuthService.cs index 645ce89..44c7a62 100644 --- a/CloudBuilder.Security/Service/IAuthService.cs +++ b/CloudBuilder.Security/Service/IAuthService.cs @@ -24,5 +24,16 @@ namespace CloudBuilder.Security.Service void Logout(); string GetRsaPublicKey(); + + BiometricLoginChallengeDto BeginBiometricLogin(string username); + + LoginProfile FinishBiometricLogin(FinishBiometricLoginData dto); + + bool ConfirmPassword(string password); + + string BeginBiometricRegistration(string deviceName); + BiometricRegistrationResultDto FinishBiometricRegistration(FinishBiometricRegistrationData dto); + + void RemoveBiometric(); } } diff --git a/CloudBuilder.Security/Service/IWebAuthnService.cs b/CloudBuilder.Security/Service/IWebAuthnService.cs new file mode 100644 index 0000000..fe8d513 --- /dev/null +++ b/CloudBuilder.Security/Service/IWebAuthnService.cs @@ -0,0 +1,32 @@ +using Fido2NetLib; +using Fido2NetLib.Objects; +using CloudBuilder.Core.Service; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using CloudBuilder.Security.Data; + +namespace CloudBuilder.Security.Service +{ + public interface IWebAuthnService : IService + { + CredentialCreateOptions BuildCreationOptions(string username, byte[] userId, IReadOnlyList existingCredentialIds); + + Task FinishRegistrationAsync( + AuthenticatorAttestationRawResponse attestationResponse, + CredentialCreateOptions originalOptions, + Func> isCredentialIdUnique); + + AssertionOptions BuildRequestOptions(byte[] storedCredentialId); + + string BuildRequestOptionsJson(byte[] storedCredentialId); + + Task FinishAssertionAsync( + AuthenticatorAssertionRawResponse assertionResponse, + AssertionOptions originalOptions, + byte[] storedPublicKey, + uint storedCounter); + } +} diff --git a/CloudBuilder.Web.Entry.sln b/CloudBuilder.Web.Entry.sln index e7ff3a6..b1939a8 100644 --- a/CloudBuilder.Web.Entry.sln +++ b/CloudBuilder.Web.Entry.sln @@ -7,12 +7,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CloudBuilder.Web.Entry", "C EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CloudBuilder.Security.Service", "CloudBuilder.Security.Service\CloudBuilder.Security.Service.csproj", "{D4371FF5-55AA-F6FB-9D02-FA0486C7F492}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CloudBuilder.AI", "CloudBuilder.AI\CloudBuilder.AI.csproj", "{8C849E66-BEC0-F09D-791F-FB353DA7DBE7}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CloudBuilder.AI.Service", "CloudBuilder.AI.Service\CloudBuilder.AI.Service.csproj", "{5767BF2F-70B3-4FF6-CB85-9C0F8F0C8612}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CloudBuilder.Core", "CloudBuilder.Core\CloudBuilder.Core.csproj", "{AFA9D41C-E8A5-32D3-E81B-2ABA7C37D46C}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CloudBuilder.Security", "CloudBuilder.Security\CloudBuilder.Security.csproj", "{269A5036-BAE9-FD61-1DD5-0EA93B79F0D7}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -27,18 +25,14 @@ Global {D4371FF5-55AA-F6FB-9D02-FA0486C7F492}.Debug|Any CPU.Build.0 = Debug|Any CPU {D4371FF5-55AA-F6FB-9D02-FA0486C7F492}.Release|Any CPU.ActiveCfg = Release|Any CPU {D4371FF5-55AA-F6FB-9D02-FA0486C7F492}.Release|Any CPU.Build.0 = Release|Any CPU - {8C849E66-BEC0-F09D-791F-FB353DA7DBE7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {8C849E66-BEC0-F09D-791F-FB353DA7DBE7}.Debug|Any CPU.Build.0 = Debug|Any CPU - {8C849E66-BEC0-F09D-791F-FB353DA7DBE7}.Release|Any CPU.ActiveCfg = Release|Any CPU - {8C849E66-BEC0-F09D-791F-FB353DA7DBE7}.Release|Any CPU.Build.0 = Release|Any CPU - {5767BF2F-70B3-4FF6-CB85-9C0F8F0C8612}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {5767BF2F-70B3-4FF6-CB85-9C0F8F0C8612}.Debug|Any CPU.Build.0 = Debug|Any CPU - {5767BF2F-70B3-4FF6-CB85-9C0F8F0C8612}.Release|Any CPU.ActiveCfg = Release|Any CPU - {5767BF2F-70B3-4FF6-CB85-9C0F8F0C8612}.Release|Any CPU.Build.0 = Release|Any CPU {AFA9D41C-E8A5-32D3-E81B-2ABA7C37D46C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {AFA9D41C-E8A5-32D3-E81B-2ABA7C37D46C}.Debug|Any CPU.Build.0 = Debug|Any CPU {AFA9D41C-E8A5-32D3-E81B-2ABA7C37D46C}.Release|Any CPU.ActiveCfg = Release|Any CPU {AFA9D41C-E8A5-32D3-E81B-2ABA7C37D46C}.Release|Any CPU.Build.0 = Release|Any CPU + {269A5036-BAE9-FD61-1DD5-0EA93B79F0D7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {269A5036-BAE9-FD61-1DD5-0EA93B79F0D7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {269A5036-BAE9-FD61-1DD5-0EA93B79F0D7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {269A5036-BAE9-FD61-1DD5-0EA93B79F0D7}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/CloudBuilder.Web.Entry/CloudBuilder.Web.Entry.csproj b/CloudBuilder.Web.Entry/CloudBuilder.Web.Entry.csproj index c0cd16e..8b9b97b 100644 --- a/CloudBuilder.Web.Entry/CloudBuilder.Web.Entry.csproj +++ b/CloudBuilder.Web.Entry/CloudBuilder.Web.Entry.csproj @@ -25,16 +25,10 @@ - + - - ..\CloudBuilder.AI\bin\Debug\net8.0\CloudBuilder.AI.dll - - - ..\CloudBuilder.AI.Service\bin\Debug\net8.0\CloudBuilder.AI.Service.dll - ..\CloudBuilder.Core\bin\Debug\net8.0\CloudBuilder.Core.dll @@ -47,7 +41,6 @@ - diff --git a/CloudBuilder.Web.Entry/Controllers/Ai/AiBookController.auto.cs b/CloudBuilder.Web.Entry/Controllers/Ai/AiBookController.auto.cs deleted file mode 100644 index 9e5339c..0000000 --- a/CloudBuilder.Web.Entry/Controllers/Ai/AiBookController.auto.cs +++ /dev/null @@ -1,301 +0,0 @@ - -using CloudBuilder.Core.DatabaseAccessor; -using CloudBuilder.Core.Policy; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using System.Transactions; -using CloudBuilder.Core.Service; -using CloudBuilder.AI.Service; - using CloudBuilder.Core.DatabaseAccessor.Entity; - using CloudBuilder.AI.Data; - using System; - using CloudBuilder.AI.Entity; - using CloudBuilder.AI.SearchCriteria; - using Microsoft.AspNetCore.Mvc; - -namespace CloudBuilder.Web.Entry.Controllers.Ai -{ -[Authorize(AuthorizePolicy.PERMISSION)] -[ApiController] -[Route("[controller]/[action]")] -public class AiBookController : ControllerBase -{ -private readonly IApplicationService service; -private readonly IAiBookService aiBookService; - -public AiBookController(IApplicationService service, IAiBookService aiBookService) -{ -this.service = service; -this.aiBookService= aiBookService; -} - - [PermissionOperate(name: "AiBook", operate: "Find")] - [HttpPost] - public dynamic FindByKey(string key) - { - try - { - - var result = aiBookService.FindByKey(key); - return result; - } - catch (ValidatedException vex) - { - return vex; - } - catch (Exception ex) - { - return new ServiceErrorException(ex); - } - } - - [PermissionOperate(name: "AiBook", operate: "Find")] - [HttpPost] - public dynamic FindByTitle(string title) - { - try - { - - var result = aiBookService.FindByTitle(title); - return result; - } - catch (ValidatedException vex) - { - return vex; - } - catch (Exception ex) - { - return new ServiceErrorException(ex); - } - } - - [PermissionOperate(name: "AiBook", operate: "Find")] - [HttpPost] - public dynamic FindBySearchCriteria(AiBookSearchCriteria criteria) - { - try - { - - var result = aiBookService.FindBySearchCriteria(criteria); - return result; - } - catch (ValidatedException vex) - { - return vex; - } - catch (Exception ex) - { - return new ServiceErrorException(ex); - } - } - - [PermissionOperate(name: "AiBook", operate: "Insert")] - [HttpPost] - [UnitOfWork] - public dynamic Insert(AiBookData data) - { - try - { - - var result = aiBookService.Insert(data); - return result; - } - catch (ValidatedException vex) - { - return vex; - } - catch (Exception ex) - { - return new ServiceErrorException(ex); - } - } - - [PermissionOperate(name: "AiBook", operate: "Update")] - [HttpPost] - [UnitOfWork] - public dynamic Update(AiBookData data) - { - try - { - - var result = aiBookService.Update(data); - return result; - } - catch (ValidatedException vex) - { - return vex; - } - catch (Exception ex) - { - return new ServiceErrorException(ex); - } - } - - [PermissionOperate(name: "AiBook", operate: "Delete")] - [HttpPost] - [UnitOfWork] - public dynamic Delete(string key){ - try - { - aiBookService.Delete(key); - - return string.Empty; - } - catch (ValidatedException vex) - { - return vex; - } - catch (Exception ex) - { - return new ServiceErrorException(ex); - } - } - - [PermissionOperate(name: "AiBook", operate: "Delete")] - [HttpPost] - [UnitOfWork] - public dynamic Deletes(string[] keys){ - try - { - aiBookService.Deletes(keys); - - return string.Empty; - } - catch (ValidatedException vex) - { - return vex; - } - catch (Exception ex) - { - return new ServiceErrorException(ex); - } - } - - - public class ProcessBookCompositionData - { - public string key { get; set; } - public string encoding { get; set; } - public string pattern { get; set; } - - } - [PermissionOperate(name: "AiBook", operate: "Update")] - [HttpPost] - [UnitOfWork] - public dynamic ProcessBook(ProcessBookCompositionData compositionData) - { - try - { - - var result = aiBookService.ProcessBook(compositionData.key,compositionData.encoding,compositionData.pattern); - return result; - } - catch (ValidatedException vex) - { - return vex; - } - catch (Exception ex) - { - return new ServiceErrorException(ex); - } - } - - [PermissionOperate(name: "AiBook", operate: "Find")] - [HttpPost] - public dynamic GetMp3Stream(AiSentenceViewEntity aiSentence) - { - try - { - - var result = aiBookService.GetMp3Stream(aiSentence); - return result; - } - catch (ValidatedException vex) - { - return vex; - } - catch (Exception ex) - { - return new ServiceErrorException(ex); - } - } - - [PermissionOperate(name: "AiBook", operate: "Find")] - [HttpPost] - public dynamic GenerateMp3(AiChapterEntity[] chapters){ - try - { - aiBookService.GenerateMp3(chapters); - - return string.Empty; - } - catch (ValidatedException vex) - { - return vex; - } - catch (Exception ex) - { - return new ServiceErrorException(ex); - } - } - - [PermissionOperate(name: "AiBook", operate: "Find")] - [HttpPost] - public dynamic GenerateMp3ByBookId(string bookId){ - try - { - aiBookService.GenerateMp3ByBookId(bookId); - - return string.Empty; - } - catch (ValidatedException vex) - { - return vex; - } - catch (Exception ex) - { - return new ServiceErrorException(ex); - } - } - - [PermissionOperate(name: "AiBook", operate: "Find")] - [HttpPost] - public dynamic PackPersonNameByBookId(string bookId){ - try - { - aiBookService.PackPersonNameByBookId(bookId); - - return string.Empty; - } - catch (ValidatedException vex) - { - return vex; - } - catch (Exception ex) - { - return new ServiceErrorException(ex); - } - } - - [PermissionOperate(name: "AiBook", operate: "Find")] - [HttpPost] - public dynamic PackPersonName(AiChapterEntity[] chapters){ - try - { - aiBookService.PackPersonName(chapters); - - return string.Empty; - } - catch (ValidatedException vex) - { - return vex; - } - catch (Exception ex) - { - return new ServiceErrorException(ex); - } - } - - -} -} \ No newline at end of file diff --git a/CloudBuilder.Web.Entry/Controllers/Ai/AiChapterController.auto.cs b/CloudBuilder.Web.Entry/Controllers/Ai/AiChapterController.auto.cs deleted file mode 100644 index 2412867..0000000 --- a/CloudBuilder.Web.Entry/Controllers/Ai/AiChapterController.auto.cs +++ /dev/null @@ -1,137 +0,0 @@ - -using CloudBuilder.Core.DatabaseAccessor; -using CloudBuilder.Core.Policy; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using System.Transactions; -using CloudBuilder.Core.Service; -using CloudBuilder.AI.Service; - using CloudBuilder.Core.DatabaseAccessor.Entity; - using CloudBuilder.AI.Data; - using System; - using CloudBuilder.AI.Entity; - using CloudBuilder.AI.SearchCriteria; - using Microsoft.AspNetCore.Mvc; - using CloudBuilder.AI; - -namespace CloudBuilder.Web.Entry.Controllers.Ai -{ -[Authorize(AuthorizePolicy.PERMISSION)] -[ApiController] -[Route("[controller]/[action]")] -public class AiChapterController : ControllerBase -{ -private readonly IApplicationService service; -private readonly IAiChapterService aiChapterService; - -public AiChapterController(IApplicationService service, IAiChapterService aiChapterService) -{ -this.service = service; -this.aiChapterService= aiChapterService; -} - - [PermissionOperate(name: "AiBook", operate: "Find")] - [HttpPost] - public dynamic FindByKey(AiChapterEntityKey key) - { - try - { - - var result = aiChapterService.FindByKey(key); - return result; - } - catch (ValidatedException vex) - { - return vex; - } - catch (Exception ex) - { - return new ServiceErrorException(ex); - } - } - - [PermissionOperate(name: "AiBook", operate: "Find")] - [HttpPost] - public dynamic FindBySearchCriteria(AiChapterSearchCriteria criteria) - { - try - { - - var result = aiChapterService.FindBySearchCriteria(criteria); - return result; - } - catch (ValidatedException vex) - { - return vex; - } - catch (Exception ex) - { - return new ServiceErrorException(ex); - } - } - - [PermissionOperate(name: "AiBook", operate: "Insert")] - [HttpPost] - [UnitOfWork] - public dynamic Insert(AiChapterEntity data) - { - try - { - - var result = aiChapterService.Insert(data); - return result; - } - catch (ValidatedException vex) - { - return vex; - } - catch (Exception ex) - { - return new ServiceErrorException(ex); - } - } - - [PermissionOperate(name: "AiBook", operate: "Update")] - [HttpPost] - [UnitOfWork] - public dynamic Update(AiChapterEntity data) - { - try - { - - var result = aiChapterService.Update(data); - return result; - } - catch (ValidatedException vex) - { - return vex; - } - catch (Exception ex) - { - return new ServiceErrorException(ex); - } - } - - [PermissionOperate(name: "AiBook", operate: "Delete")] - [HttpPost] - [UnitOfWork] - public dynamic Delete(AiChapterEntityKey key){ - try - { - aiChapterService.Delete(key); - - return string.Empty; - } - catch (ValidatedException vex) - { - return vex; - } - catch (Exception ex) - { - return new ServiceErrorException(ex); - } - } - - -} -} \ No newline at end of file diff --git a/CloudBuilder.Web.Entry/Controllers/Ai/AiSentenceController.auto.cs b/CloudBuilder.Web.Entry/Controllers/Ai/AiSentenceController.auto.cs deleted file mode 100644 index ea8bc56..0000000 --- a/CloudBuilder.Web.Entry/Controllers/Ai/AiSentenceController.auto.cs +++ /dev/null @@ -1,184 +0,0 @@ - -using CloudBuilder.Core.DatabaseAccessor; -using CloudBuilder.Core.Policy; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using System.Transactions; -using CloudBuilder.Core.Service; -using CloudBuilder.AI.Service; - using CloudBuilder.Core.DatabaseAccessor.Entity; - using CloudBuilder.AI.Data; - using System; - using CloudBuilder.AI.Entity; - using CloudBuilder.AI.SearchCriteria; - using Microsoft.AspNetCore.Mvc; - using CloudBuilder.AI; - -namespace CloudBuilder.Web.Entry.Controllers.Ai -{ -[Authorize(AuthorizePolicy.PERMISSION)] -[ApiController] -[Route("[controller]/[action]")] -public class AiSentenceController : ControllerBase -{ -private readonly IApplicationService service; -private readonly IAiSentenceService aiSentenceService; - -public AiSentenceController(IApplicationService service, IAiSentenceService aiSentenceService) -{ -this.service = service; -this.aiSentenceService= aiSentenceService; -} - - [PermissionOperate(name: "AiBook", operate: "Find")] - [HttpPost] - public dynamic FindByKey(Int64 key) - { - try - { - - var result = aiSentenceService.FindByKey(key); - return result; - } - catch (ValidatedException vex) - { - return vex; - } - catch (Exception ex) - { - return new ServiceErrorException(ex); - } - } - - - public class FindByChapterIdCompositionData - { - public string bookId { get; set; } - public Int64 chapterId { get; set; } - - } - [PermissionOperate(name: "AiBook", operate: "Find")] - [HttpPost] - public dynamic FindByChapterId(FindByChapterIdCompositionData compositionData) - { - try - { - - var result = aiSentenceService.FindByChapterId(compositionData.bookId,compositionData.chapterId); - return result; - } - catch (ValidatedException vex) - { - return vex; - } - catch (Exception ex) - { - return new ServiceErrorException(ex); - } - } - - [PermissionOperate(name: "AiBook", operate: "Find")] - [HttpPost] - public dynamic FindBySearchCriteria(AiSentenceSearchCriteria criteria) - { - try - { - - var result = aiSentenceService.FindBySearchCriteria(criteria); - return result; - } - catch (ValidatedException vex) - { - return vex; - } - catch (Exception ex) - { - return new ServiceErrorException(ex); - } - } - - [PermissionOperate(name: "AiBook", operate: "Insert")] - [HttpPost] - [UnitOfWork] - public dynamic Insert(AiSentenceEntity data) - { - try - { - - var result = aiSentenceService.Insert(data); - return result; - } - catch (ValidatedException vex) - { - return vex; - } - catch (Exception ex) - { - return new ServiceErrorException(ex); - } - } - - [PermissionOperate(name: "AiParagraph", operate: "Insert")] - [HttpPost] - [UnitOfWork] - public dynamic Inserts(AiSentenceEntity[] datas){ - try - { - aiSentenceService.Inserts(datas); - - return string.Empty; - } - catch (ValidatedException vex) - { - return vex; - } - catch (Exception ex) - { - return new ServiceErrorException(ex); - } - } - - [PermissionOperate(name: "AiBook", operate: "Update")] - [HttpPost] - [UnitOfWork] - public dynamic Update(AiSentenceEntity data) - { - try - { - - var result = aiSentenceService.Update(data); - return result; - } - catch (ValidatedException vex) - { - return vex; - } - catch (Exception ex) - { - return new ServiceErrorException(ex); - } - } - - [PermissionOperate(name: "AiBook", operate: "Delete")] - [HttpPost] - [UnitOfWork] - public dynamic Delete(Int64 key){ - try - { - aiSentenceService.Delete(key); - - return string.Empty; - } - catch (ValidatedException vex) - { - return vex; - } - catch (Exception ex) - { - return new ServiceErrorException(ex); - } - } - - -} -} \ No newline at end of file diff --git a/CloudBuilder.Web.Entry/Controllers/Ai/DmsFileController.auto.cs b/CloudBuilder.Web.Entry/Controllers/Ai/DmsFileController.auto.cs deleted file mode 100644 index ea1f91a..0000000 --- a/CloudBuilder.Web.Entry/Controllers/Ai/DmsFileController.auto.cs +++ /dev/null @@ -1,422 +0,0 @@ - -using CloudBuilder.Core.DatabaseAccessor; -using CloudBuilder.Core.Policy; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using System.Transactions; -using CloudBuilder.Core.Service; -using CloudBuilder.AI.Service; - using CloudBuilder.Core.DatabaseAccessor.Entity; - using CloudBuilder.AI.Data; - using System; - using CloudBuilder.AI.Entity; - using CloudBuilder.AI.SearchCriteria; - using Microsoft.AspNetCore.Mvc; - using CloudBuilder.AI; - using Microsoft.AspNetCore.Http; - -namespace CloudBuilder.Web.Entry.Controllers.Ai -{ -[Authorize(AuthorizePolicy.PERMISSION)] -[ApiController] -[Route("[controller]/[action]")] -public class DmsFileController : ControllerBase -{ -private readonly IApplicationService service; -private readonly IDmsFileService dmsFileService; - -public DmsFileController(IApplicationService service, IDmsFileService dmsFileService) -{ -this.service = service; -this.dmsFileService= dmsFileService; -} - - [PermissionOperate(name: "DmsFile", operate: "Find")] - [HttpPost] - public dynamic FindByKey(Int32 key) - { - try - { - - var result = dmsFileService.FindByKey(key); - return result; - } - catch (ValidatedException vex) - { - return vex; - } - catch (Exception ex) - { - return new ServiceErrorException(ex); - } - } - - [PermissionOperate(name: "DmsFile", operate: "Find")] - [HttpPost] - public dynamic FindBySearchCriteria(DmsFileSearchCriteria criteria) - { - try - { - - var result = dmsFileService.FindBySearchCriteria(criteria); - return result; - } - catch (ValidatedException vex) - { - return vex; - } - catch (Exception ex) - { - return new ServiceErrorException(ex); - } - } - - [PermissionOperate(name: "DmsFile", operate: "Find")] - [HttpPost] - public dynamic FindDatasBySearchCriteria(DmsFileSearchCriteria criteria) - { - try - { - - var result = dmsFileService.FindDatasBySearchCriteria(criteria); - return result; - } - catch (ValidatedException vex) - { - return vex; - } - catch (Exception ex) - { - return new ServiceErrorException(ex); - } - } - - [PermissionOperate(name: "DmsFile", operate: "Print")] - [HttpPost] - public dynamic PrintBySearchCriteria(DmsFileSearchCriteria criteria) - { - try - { - - var result = dmsFileService.PrintBySearchCriteria(criteria); - return result; - } - catch (ValidatedException vex) - { - return vex; - } - catch (Exception ex) - { - return new ServiceErrorException(ex); - } - } - - [PermissionOperate(name: "DmsFile", operate: "Insert")] - [HttpPost] - [UnitOfWork] - public dynamic Insert(DmsFileEntity data) - { - try - { - - var result = dmsFileService.Insert(data); - return result; - } - catch (ValidatedException vex) - { - return vex; - } - catch (Exception ex) - { - return new ServiceErrorException(ex); - } - } - - [PermissionOperate(name: "DmsFile", operate: "Update")] - [HttpPost] - [UnitOfWork] - public dynamic Update(DmsFileEntity data) - { - try - { - - var result = dmsFileService.Update(data); - return result; - } - catch (ValidatedException vex) - { - return vex; - } - catch (Exception ex) - { - return new ServiceErrorException(ex); - } - } - - [PermissionOperate(name: "DmsFile", operate: "Delete")] - [HttpPost] - [UnitOfWork] - public dynamic Delete(Int32 key){ - try - { - dmsFileService.Delete(key); - - return string.Empty; - } - catch (ValidatedException vex) - { - return vex; - } - catch (Exception ex) - { - return new ServiceErrorException(ex); - } - } - - [PermissionOperate(name: "DmsFile", operate: "Insert")] - [HttpPost] - [UnitOfWork] - public dynamic GetFolderName(string folderLocalName) - { - try - { - - var result = dmsFileService.GetFolderName(folderLocalName); - return result; - } - catch (ValidatedException vex) - { - return vex; - } - catch (Exception ex) - { - return new ServiceErrorException(ex); - } - } - - [PermissionOperate(name: "DmsFile", operate: "Insert")] - [HttpPost] - [UnitOfWork] - public dynamic CreateDmsFolder(DmsFileEntity dmsFile) - { - try - { - - var result = dmsFileService.CreateDmsFolder(dmsFile); - return result; - } - catch (ValidatedException vex) - { - return vex; - } - catch (Exception ex) - { - return new ServiceErrorException(ex); - } - } - - [PermissionOperate(name: "DmsFile", operate: "Update")] - [HttpPost] - [UnitOfWork] - public dynamic UpdateDmsFolder(DmsFileEntity dmsFile) - { - try - { - - var result = dmsFileService.UpdateDmsFolder(dmsFile); - return result; - } - catch (ValidatedException vex) - { - return vex; - } - catch (Exception ex) - { - return new ServiceErrorException(ex); - } - } - - [PermissionOperate(name: "DmsFile", operate: "Find")] - [HttpPost] - public dynamic FindDmsFolders() - { - try - { - - var result = dmsFileService.FindDmsFolders(); - return result; - } - catch (ValidatedException vex) - { - return vex; - } - catch (Exception ex) - { - return new ServiceErrorException(ex); - } - } - - [PermissionOperate(name: "DmsFile", operate: "Find")] - [HttpPost] - public dynamic FindDmsFolderTreeData() - { - try - { - - var result = dmsFileService.FindDmsFolderTreeData(); - return result; - } - catch (ValidatedException vex) - { - return vex; - } - catch (Exception ex) - { - return new ServiceErrorException(ex); - } - } - - [PermissionOperate(name: "DmsFile", operate: "Update")] - [HttpPost] - [UnitOfWork] - public dynamic MoveDmsFolder(DmsFileEntity dmsFile){ - try - { - dmsFileService.MoveDmsFolder(dmsFile); - - return string.Empty; - } - catch (ValidatedException vex) - { - return vex; - } - catch (Exception ex) - { - return new ServiceErrorException(ex); - } - } - - [PermissionOperate(name: "DmsFile", operate: "Insert")] - [HttpPost] - [UnitOfWork] - public dynamic UploadFile() - { - try - { - - var result = dmsFileService.UploadFile(); - return result; - } - catch (ValidatedException vex) - { - return vex; - } - catch (Exception ex) - { - return new ServiceErrorException(ex); - } - } - - [PermissionOperate(name: "DmsFile", operate: "Find")] - [HttpPost] - public dynamic DownloadFile(Int32 fileId) - { - try - { - - var result = dmsFileService.DownloadFile(fileId); - return result; - } - catch (ValidatedException vex) - { - return vex; - } - catch (Exception ex) - { - return new ServiceErrorException(ex); - } - } - - [PermissionOperate(name: "DmsFile", operate: "Find")] - [HttpPost] - public dynamic DownloadFilePreview(DmsFileEntity dmsFile) - { - try - { - - var result = dmsFileService.DownloadFilePreview(dmsFile); - return result; - } - catch (ValidatedException vex) - { - return vex; - } - catch (Exception ex) - { - return new ServiceErrorException(ex); - } - } - - [PermissionOperate(name: "DmsFile", operate: "Find")] - [HttpPost] - public dynamic DownloadFileCombine(string fileName) - { - try - { - - var result = dmsFileService.DownloadFileCombine(fileName); - return result; - } - catch (ValidatedException vex) - { - return vex; - } - catch (Exception ex) - { - return new ServiceErrorException(ex); - } - } - - [PermissionOperate(name: "DmsFile", operate: "Find")] - [HttpPost] - public dynamic FindDmsFolderMapByFolderName(string folderName) - { - try - { - - var result = dmsFileService.FindDmsFolderMapByFolderName(folderName); - return result; - } - catch (ValidatedException vex) - { - return vex; - } - catch (Exception ex) - { - return new ServiceErrorException(ex); - } - } - - [PermissionOperate(name: "DmsFile", operate: "Find")] - [HttpPost] - public dynamic FindFolderNameParentByFolderName(string folderName) - { - try - { - - var result = dmsFileService.FindFolderNameParentByFolderName(folderName); - return result; - } - catch (ValidatedException vex) - { - return vex; - } - catch (Exception ex) - { - return new ServiceErrorException(ex); - } - } - - -} -} \ No newline at end of file diff --git a/CloudBuilder.Web.Entry/Controllers/Security/AuthController.auto.cs b/CloudBuilder.Web.Entry/Controllers/Security/AuthController.auto.cs index 6abcf1e..da097ff 100644 --- a/CloudBuilder.Web.Entry/Controllers/Security/AuthController.auto.cs +++ b/CloudBuilder.Web.Entry/Controllers/Security/AuthController.auto.cs @@ -12,6 +12,7 @@ using CloudBuilder.Security.Service; using CloudBuilder.Core.DependencyInjection.Request; using CloudBuilder.Security.Data; using CloudBuilder.Core.Authorization; + using System; namespace CloudBuilder.Web.Entry.Controllers.Security { @@ -174,6 +175,127 @@ this.authService= authService; } } + [PermissionOperate(name: "SecurityUser", operate: "Find")] + [HttpPost] + public dynamic ConfirmPassword(string password) + { + try + { + + var result = authService.ConfirmPassword(password); + return result; + } + catch (ValidatedException vex) + { + return vex; + } + catch (Exception ex) + { + return new ServiceErrorException(ex); + } + } + + [PermissionOperate(name: "SecurityUser", operate: "Find")] + [HttpPost] + public dynamic BeginBiometricRegistration(string deviceName) + { + try + { + + var result = authService.BeginBiometricRegistration(deviceName); + return result; + } + catch (ValidatedException vex) + { + return vex; + } + catch (Exception ex) + { + return new ServiceErrorException(ex); + } + } + + [PermissionOperate(name: "SecurityUser", operate: "Find")] + [HttpPost] + public dynamic FinishBiometricRegistration(FinishBiometricRegistrationData dto) + { + try + { + + var result = authService.FinishBiometricRegistration(dto); + return result; + } + catch (ValidatedException vex) + { + return vex; + } + catch (Exception ex) + { + return new ServiceErrorException(ex); + } + } + + [AllowAnonymous] + [HttpPost] + public dynamic BeginBiometricLogin(string username) + { + try + { + + var result = authService.BeginBiometricLogin(username); + return result; + } + catch (ValidatedException vex) + { + return vex; + } + catch (Exception ex) + { + return new ServiceErrorException(ex); + } + } + + [AllowAnonymous] + [HttpPost] + public dynamic FinishBiometricLogin(FinishBiometricLoginData dto) + { + try + { + + var result = authService.FinishBiometricLogin(dto); + return result; + } + catch (ValidatedException vex) + { + return vex; + } + catch (Exception ex) + { + return new ServiceErrorException(ex); + } + } + + [PermissionOperate(name: "SecurityUser", operate: "Find")] + [HttpPost] + public dynamic RemoveBiometric() + { + try + { + + authService.RemoveBiometric(); + + return string.Empty; + } + catch (ValidatedException vex) + { + return vex; + } + catch (Exception ex) + { + return new ServiceErrorException(ex); + } + } + } } \ No newline at end of file diff --git a/CloudBuilder.Web.Entry/Controllers/Security/LocaleController.auto.cs b/CloudBuilder.Web.Entry/Controllers/Security/LocaleController.auto.cs index 04ec67a..1dca1d1 100644 --- a/CloudBuilder.Web.Entry/Controllers/Security/LocaleController.auto.cs +++ b/CloudBuilder.Web.Entry/Controllers/Security/LocaleController.auto.cs @@ -12,6 +12,7 @@ using CloudBuilder.Security.Service; using CloudBuilder.Core.DependencyInjection.Request; using CloudBuilder.Security.Data; using CloudBuilder.Core.Authorization; + using System; using CloudBuilder.Security.Entity; using CloudBuilder.Security.SearchCriteria; using CloudBuilder.Security; diff --git a/CloudBuilder.Web.Entry/Controllers/Security/SecurityActorController.auto.cs b/CloudBuilder.Web.Entry/Controllers/Security/SecurityActorController.auto.cs index 46e2962..6a97d8a 100644 --- a/CloudBuilder.Web.Entry/Controllers/Security/SecurityActorController.auto.cs +++ b/CloudBuilder.Web.Entry/Controllers/Security/SecurityActorController.auto.cs @@ -12,10 +12,10 @@ using CloudBuilder.Security.Service; using CloudBuilder.Core.DependencyInjection.Request; using CloudBuilder.Security.Data; using CloudBuilder.Core.Authorization; + using System; using CloudBuilder.Security.Entity; using CloudBuilder.Security.SearchCriteria; using CloudBuilder.Security; - using System; namespace CloudBuilder.Web.Entry.Controllers.Security { diff --git a/CloudBuilder.Web.Entry/Controllers/Security/SecurityFunctionController.auto.cs b/CloudBuilder.Web.Entry/Controllers/Security/SecurityFunctionController.auto.cs index b1ee59a..136fdb0 100644 --- a/CloudBuilder.Web.Entry/Controllers/Security/SecurityFunctionController.auto.cs +++ b/CloudBuilder.Web.Entry/Controllers/Security/SecurityFunctionController.auto.cs @@ -12,10 +12,10 @@ using CloudBuilder.Security.Service; using CloudBuilder.Core.DependencyInjection.Request; using CloudBuilder.Security.Data; using CloudBuilder.Core.Authorization; + using System; using CloudBuilder.Security.Entity; using CloudBuilder.Security.SearchCriteria; using CloudBuilder.Security; - using System; namespace CloudBuilder.Web.Entry.Controllers.Security { diff --git a/CloudBuilder.Web.Entry/Controllers/Security/SecurityGeneratorNumberController.auto.cs b/CloudBuilder.Web.Entry/Controllers/Security/SecurityGeneratorNumberController.auto.cs index a6e75d0..c5b26e6 100644 --- a/CloudBuilder.Web.Entry/Controllers/Security/SecurityGeneratorNumberController.auto.cs +++ b/CloudBuilder.Web.Entry/Controllers/Security/SecurityGeneratorNumberController.auto.cs @@ -12,10 +12,10 @@ using CloudBuilder.Security.Service; using CloudBuilder.Core.DependencyInjection.Request; using CloudBuilder.Security.Data; using CloudBuilder.Core.Authorization; + using System; using CloudBuilder.Security.Entity; using CloudBuilder.Security.SearchCriteria; using CloudBuilder.Security; - using System; namespace CloudBuilder.Web.Entry.Controllers.Security { diff --git a/CloudBuilder.Web.Entry/Controllers/Security/SecurityGroupController.auto.cs b/CloudBuilder.Web.Entry/Controllers/Security/SecurityGroupController.auto.cs index 9beae52..b9906bc 100644 --- a/CloudBuilder.Web.Entry/Controllers/Security/SecurityGroupController.auto.cs +++ b/CloudBuilder.Web.Entry/Controllers/Security/SecurityGroupController.auto.cs @@ -12,10 +12,10 @@ using CloudBuilder.Security.Service; using CloudBuilder.Core.DependencyInjection.Request; using CloudBuilder.Security.Data; using CloudBuilder.Core.Authorization; + using System; using CloudBuilder.Security.Entity; using CloudBuilder.Security.SearchCriteria; using CloudBuilder.Security; - using System; namespace CloudBuilder.Web.Entry.Controllers.Security { diff --git a/CloudBuilder.Web.Entry/Controllers/Security/SecurityLockController.auto.cs b/CloudBuilder.Web.Entry/Controllers/Security/SecurityLockController.auto.cs index dfc27f3..4d40f9c 100644 --- a/CloudBuilder.Web.Entry/Controllers/Security/SecurityLockController.auto.cs +++ b/CloudBuilder.Web.Entry/Controllers/Security/SecurityLockController.auto.cs @@ -12,10 +12,10 @@ using CloudBuilder.Security.Service; using CloudBuilder.Core.DependencyInjection.Request; using CloudBuilder.Security.Data; using CloudBuilder.Core.Authorization; + using System; using CloudBuilder.Security.Entity; using CloudBuilder.Security.SearchCriteria; using CloudBuilder.Security; - using System; namespace CloudBuilder.Web.Entry.Controllers.Security { diff --git a/CloudBuilder.Web.Entry/Controllers/Security/SecurityMasterController.auto.cs b/CloudBuilder.Web.Entry/Controllers/Security/SecurityMasterController.auto.cs index caa28e1..8c2bb9a 100644 --- a/CloudBuilder.Web.Entry/Controllers/Security/SecurityMasterController.auto.cs +++ b/CloudBuilder.Web.Entry/Controllers/Security/SecurityMasterController.auto.cs @@ -12,10 +12,10 @@ using CloudBuilder.Security.Service; using CloudBuilder.Core.DependencyInjection.Request; using CloudBuilder.Security.Data; using CloudBuilder.Core.Authorization; + using System; using CloudBuilder.Security.Entity; using CloudBuilder.Security.SearchCriteria; using CloudBuilder.Security; - using System; namespace CloudBuilder.Web.Entry.Controllers.Security { diff --git a/CloudBuilder.Web.Entry/Controllers/Security/SecurityPermissionController.auto.cs b/CloudBuilder.Web.Entry/Controllers/Security/SecurityPermissionController.auto.cs index 18032c7..9b7ce12 100644 --- a/CloudBuilder.Web.Entry/Controllers/Security/SecurityPermissionController.auto.cs +++ b/CloudBuilder.Web.Entry/Controllers/Security/SecurityPermissionController.auto.cs @@ -12,10 +12,10 @@ using CloudBuilder.Security.Service; using CloudBuilder.Core.DependencyInjection.Request; using CloudBuilder.Security.Data; using CloudBuilder.Core.Authorization; + using System; using CloudBuilder.Security.Entity; using CloudBuilder.Security.SearchCriteria; using CloudBuilder.Security; - using System; namespace CloudBuilder.Web.Entry.Controllers.Security { diff --git a/CloudBuilder.Web.Entry/Controllers/Security/SecurityUserController.auto.cs b/CloudBuilder.Web.Entry/Controllers/Security/SecurityUserController.auto.cs index 0ea68ba..9c58c8b 100644 --- a/CloudBuilder.Web.Entry/Controllers/Security/SecurityUserController.auto.cs +++ b/CloudBuilder.Web.Entry/Controllers/Security/SecurityUserController.auto.cs @@ -12,10 +12,10 @@ using CloudBuilder.Security.Service; using CloudBuilder.Core.DependencyInjection.Request; using CloudBuilder.Security.Data; using CloudBuilder.Core.Authorization; + using System; using CloudBuilder.Security.Entity; using CloudBuilder.Security.SearchCriteria; using CloudBuilder.Security; - using System; namespace CloudBuilder.Web.Entry.Controllers.Security { diff --git a/CloudBuilder.Web.Entry/Controllers/Security/SecurityUserPreferenceController.auto.cs b/CloudBuilder.Web.Entry/Controllers/Security/SecurityUserPreferenceController.auto.cs index c9fa37b..f81b284 100644 --- a/CloudBuilder.Web.Entry/Controllers/Security/SecurityUserPreferenceController.auto.cs +++ b/CloudBuilder.Web.Entry/Controllers/Security/SecurityUserPreferenceController.auto.cs @@ -12,10 +12,10 @@ using CloudBuilder.Security.Service; using CloudBuilder.Core.DependencyInjection.Request; using CloudBuilder.Security.Data; using CloudBuilder.Core.Authorization; + using System; using CloudBuilder.Security.Entity; using CloudBuilder.Security.SearchCriteria; using CloudBuilder.Security; - using System; namespace CloudBuilder.Web.Entry.Controllers.Security { diff --git a/CloudBuilder.Web.Entry/Controllers/Security/YesNoPolicyController.auto.cs b/CloudBuilder.Web.Entry/Controllers/Security/YesNoPolicyController.auto.cs index 3a93cd3..10ccde6 100644 --- a/CloudBuilder.Web.Entry/Controllers/Security/YesNoPolicyController.auto.cs +++ b/CloudBuilder.Web.Entry/Controllers/Security/YesNoPolicyController.auto.cs @@ -12,10 +12,11 @@ using CloudBuilder.Security.Service; using CloudBuilder.Core.DependencyInjection.Request; using CloudBuilder.Security.Data; using CloudBuilder.Core.Authorization; + using System; using CloudBuilder.Security.Entity; using CloudBuilder.Security.SearchCriteria; using CloudBuilder.Security; - using System; + using Microsoft.Extensions.Options; namespace CloudBuilder.Web.Entry.Controllers.Security { diff --git a/CloudBuilder.Web.Entry/Program.cs b/CloudBuilder.Web.Entry/Program.cs index 845a680..760cc45 100644 --- a/CloudBuilder.Web.Entry/Program.cs +++ b/CloudBuilder.Web.Entry/Program.cs @@ -1,6 +1,8 @@ using CloudBuilder.Core.DependencyInjection.Form; using CloudBuilder.Core.Extensions; using CloudBuilder.Core.Service; +using CloudBuilder.Security.Data; +using CloudBuilder.Security.Service; using DocumentFormat.OpenXml.Office2016.Drawing.ChartDrawing; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Http.Json; @@ -14,7 +16,7 @@ var builder = WebApplication.CreateBuilder(args); builder.Services.Initialize(); - +builder.Services.Configure(builder.Configuration.GetSection("WebAuthn")); // Add services to the container. builder.Services.AddHttpClient().AddControllers() diff --git a/CloudBuilder.Web.Entry/Properties/PublishProfiles/FolderProfile1.pubxml.user b/CloudBuilder.Web.Entry/Properties/PublishProfiles/FolderProfile1.pubxml.user index 19e2189..3845089 100644 --- a/CloudBuilder.Web.Entry/Properties/PublishProfiles/FolderProfile1.pubxml.user +++ b/CloudBuilder.Web.Entry/Properties/PublishProfiles/FolderProfile1.pubxml.user @@ -3,7 +3,7 @@ <_PublishTargetUrl>C:\inetpub\wwwroot\webapi - True|2026-06-24T00:26:55.0324041Z||;True|2026-06-24T08:21:40.3334931+08:00||;True|2026-06-24T08:20:33.0781131+08:00||;True|2026-06-24T08:09:48.1948679+08:00||;True|2026-06-23T10:11:00.8522806+08:00||; + True|2026-08-06T05:11:18.2517555Z||;True|2026-08-06T13:09:44.6703394+08:00||;True|2026-08-06T10:39:04.1814630+08:00||;True|2026-08-06T10:03:51.6290574+08:00||;True|2026-08-06T10:03:46.6433716+08:00||;True|2026-08-06T09:57:39.5500548+08:00||;True|2026-08-06T09:43:56.5264669+08:00||;False|2026-08-06T09:42:30.9515003+08:00||;True|2026-08-06T09:39:51.6165569+08:00||;True|2026-08-06T09:38:12.2610385+08:00||;True|2026-08-06T08:18:49.5758328+08:00||;True|2026-06-24T08:26:55.0324041+08:00||;True|2026-06-24T08:21:40.3334931+08:00||;True|2026-06-24T08:20:33.0781131+08:00||;True|2026-06-24T08:09:48.1948679+08:00||;True|2026-06-23T10:11:00.8522806+08:00||; \ No newline at end of file diff --git a/CloudBuilder.Web.Entry/appsettings.json b/CloudBuilder.Web.Entry/appsettings.json index eb7766e..613d87e 100644 --- a/CloudBuilder.Web.Entry/appsettings.json +++ b/CloudBuilder.Web.Entry/appsettings.json @@ -53,7 +53,7 @@ "UploadDmsFileDirectory": "D:\\Net8\\FileServer\\Upload\\DmsFile", //上传文件目录,有可能是分片文件,仅当临时文件使用 "BackupDmsFileDirectory": "D:\\Net8\\FileServer\\Backup\\DmsFile", //这个是备份目录,也是完整文件目录,原文件目录 "ScaleDmsFileDirectory": "D:\\Net8\\FileServer\\Scale\\DmsFile", //这个是压缩图片或者视频目录 - "ExcelOutputDirectory": "D:\\Net8\\FileServer\\Output" + "ExcelOutputDirectory": "D:\\Net8\\FileServer\\Output" }, "Ffmpeg": { "Path": "D:\\NET8\\Exe\\ffmpeg\\bin\\ffmpeg.exe", //视频转码 @@ -102,5 +102,12 @@ ] }, + "WebAuthn": { + "//": "RpId is the registrable domain suffix of Origin's host. It is a domain (or IP), NOT a full URL — never include the port. Origin is the full URL (scheme + host + port + path) the browser is loading the page from. For IP-based origins the rpId is just the IP (e.g. '172.16.253.243'); the port goes in Origin only. Examples: dev → RpId 'localhost' / Origin 'https://localhost:8000'; UAT → RpId '172.16.253.243' / Origin 'https://172.16.253.243:8002'; prod → RpId 'www.youjiala.com' / Origin 'https://www.youjiala.com'.", + "RpId": "uat.youjiala.com", + "RpName": "Cloud Builder", + "Origin": "https://uat.youjiala.com", + "TimeoutMs": 60000 + }, "AllowedHosts": "*" }