diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000000000000000000000000000000000000..9552eb63839f6c2a9399fa0f6486bb95fab8b500 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,6 @@ +root = true + +[*.{cs,csproj,sln,md,json,xml,yml,yaml,config,props,targets,sql,ps1,sh,bat,txt}] +charset = utf-8 +insert_final_newline = true +trim_trailing_whitespace = true diff --git a/.env b/.env new file mode 100644 index 0000000000000000000000000000000000000000..73af504bbc5082fe7c769c48edb488d915d399ad --- /dev/null +++ b/.env @@ -0,0 +1,57 @@ +# Docker Compose settings +TSHOTEL_IMAGE=yjj6731/tshotel-management-system-api:latest +CONTAINER_NAME=tshotel-api +API_HOST_PORT=63001 +APP_CONFIG_DIR=./docker-data/config +APP_KEYS_DIR=./docker-data/keys + +# Application settings +ASPNETCORE_ENVIRONMENT=docker +DefaultDatabase=MariaDB +InitializeDatabase=false + +# Database connection strings (pick one based on DefaultDatabase) +MariaDBConnectStr=Server=your_db_host;Database=tshoteldb;User=tshoteldb;Password=your_password; +MySqlConnectStr= +PgSqlConnectStr= +SqlServerConnectStr= + +# Security +Jwt__Key=replace_with_a_long_random_secret +Jwt__ExpiryMinutes=20 + +# CORS +AllowedOrigins__0=http://localhost:8080 +AllowedOrigins__1=https://www.yourdomain.com + +# Quartz jobs +JobKeys__0=ReservationExpirationCheckJob +JobKeys__1=MailServiceCheckJob +JobKeys__2=RedisServiceCheckJob +ExpirationSettings__NotifyDaysBefore=3 +ExpirationSettings__CheckIntervalMinutes=5 + +# Mail service +Mail__Enabled=false +Mail__Host=smtp.example.com +Mail__UserName=admin@example.com +Mail__Password=replace_with_mail_password +Mail__Port=465 +Mail__EnableSsl=true +Mail__DisplayName=TSHotel + +# Redis +Redis__Enabled=false +Redis__ConnectionString=host:port,password=your_redis_password +Redis__DefaultDatabase=0 + +# Lsky (optional) +Lsky__Enabled=false +Lsky__BaseAddress= +Lsky__Email= +Lsky__Password= +Lsky__UploadApi= +Lsky__GetTokenApi= + +# Version display (optional) +SoftwareVersion=1.0.0 diff --git a/.gitignore b/.gitignore index cc917a08e1f15ab71f26da94a786b153d620fdc5..c502942a4207a76caa10d9d886f45dd066d91c2a 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,8 @@ docker-images/ frontend/ .buildnumber .version +*.txt +docs/ # Visual Studio 2015/2017 cache/options directory .vs/ @@ -391,4 +393,5 @@ FodyWeavers.xsd # JetBrains Rider .idea/ -*.sln.iml \ No newline at end of file +*.sln.iml +/version.txt diff --git a/EOM.TSHotelManagement.API/.config/dotnet-tools.json b/EOM.TSHotelManagement.API/.config/dotnet-tools.json deleted file mode 100644 index 43a4368a7de95b2f94dbcbc3073029fec3ec355c..0000000000000000000000000000000000000000 --- a/EOM.TSHotelManagement.API/.config/dotnet-tools.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "version": 1, - "isRoot": true, - "tools": { - "dotnet-ef": { - "version": "7.0.1", - "commands": [ - "dotnet-ef" - ] - } - } -} \ No newline at end of file diff --git a/EOM.TSHotelManagement.API/Controllers/Business/Customer/CustomerAccountController.cs b/EOM.TSHotelManagement.API/Controllers/Business/Customer/CustomerAccountController.cs index 50fd840b8757a45ea4c409fe114916fbdd1123dc..15b12f08fb976203c99649a16fc9ca15a5d37dfb 100644 --- a/EOM.TSHotelManagement.API/Controllers/Business/Customer/CustomerAccountController.cs +++ b/EOM.TSHotelManagement.API/Controllers/Business/Customer/CustomerAccountController.cs @@ -2,6 +2,7 @@ using EOM.TSHotelManagement.Service; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using System.Security.Claims; namespace EOM.TSHotelManagement.WebApi.Controllers { @@ -39,5 +40,69 @@ namespace EOM.TSHotelManagement.WebApi.Controllers { return _customerAccountService.Register(readCustomerAccountInputDto); } + + /// + /// 获取当前客户账号的 2FA 状态 + /// + /// + [HttpGet] + public SingleOutputDto GetTwoFactorStatus() + { + return _customerAccountService.GetTwoFactorStatus(GetCurrentSerialNumber()); + } + + /// + /// 生成当前客户账号的 2FA 绑定信息 + /// + /// + [HttpPost] + public SingleOutputDto GenerateTwoFactorSetup() + { + return _customerAccountService.GenerateTwoFactorSetup(GetCurrentSerialNumber()); + } + + /// + /// 启用当前客户账号 2FA + /// + /// + /// + [HttpPost] + public SingleOutputDto EnableTwoFactor([FromBody] TwoFactorCodeInputDto inputDto) + { + return _customerAccountService.EnableTwoFactor(GetCurrentSerialNumber(), inputDto); + } + + /// + /// 关闭当前客户账号 2FA + /// + /// + /// + [HttpPost] + public BaseResponse DisableTwoFactor([FromBody] TwoFactorCodeInputDto inputDto) + { + return _customerAccountService.DisableTwoFactor(GetCurrentSerialNumber(), inputDto); + } + + /// + /// 重置当前客户账号恢复备用码 + /// + /// + /// + [HttpPost] + public SingleOutputDto RegenerateTwoFactorRecoveryCodes([FromBody] TwoFactorCodeInputDto inputDto) + { + return _customerAccountService.RegenerateTwoFactorRecoveryCodes(GetCurrentSerialNumber(), inputDto); + } + + /// + /// 从当前登录上下文中读取账号序列号 + /// + /// + private string GetCurrentSerialNumber() + { + return User.FindFirstValue(ClaimTypes.SerialNumber) + ?? User.FindFirst("serialnumber")?.Value + ?? string.Empty; + } } } diff --git a/EOM.TSHotelManagement.API/Controllers/Employee/EmployeeController.cs b/EOM.TSHotelManagement.API/Controllers/Employee/EmployeeController.cs index 64a0bad9dc6dec0e98ffba0c10a5704df3392b58..85007ae45cb6eff0b0d43a61ac97666aaffc1cc1 100644 --- a/EOM.TSHotelManagement.API/Controllers/Employee/EmployeeController.cs +++ b/EOM.TSHotelManagement.API/Controllers/Employee/EmployeeController.cs @@ -3,6 +3,7 @@ using EOM.TSHotelManagement.Service; using EOM.TSHotelManagement.WebApi.Authorization; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using System.Security.Claims; namespace EOM.TSHotelManagement.WebApi.Controllers { @@ -91,6 +92,64 @@ namespace EOM.TSHotelManagement.WebApi.Controllers return workerService.EmployeeLogin(inputDto); } + /// + /// 获取当前员工账号的 2FA 状态 + /// + /// + [RequirePermission("staffmanagement.get2fa")] + [HttpGet] + public SingleOutputDto GetTwoFactorStatus() + { + return workerService.GetTwoFactorStatus(GetCurrentSerialNumber()); + } + + /// + /// 生成当前员工账号的 2FA 绑定信息 + /// + /// + [RequirePermission("staffmanagement.generate2fa")] + [HttpPost] + public SingleOutputDto GenerateTwoFactorSetup() + { + return workerService.GenerateTwoFactorSetup(GetCurrentSerialNumber()); + } + + /// + /// 启用当前员工账号 2FA + /// + /// + /// + [RequirePermission("staffmanagement.enable2fa")] + [HttpPost] + public SingleOutputDto EnableTwoFactor([FromBody] TwoFactorCodeInputDto inputDto) + { + return workerService.EnableTwoFactor(GetCurrentSerialNumber(), inputDto); + } + + /// + /// 关闭当前员工账号 2FA + /// + /// + /// + [RequirePermission("staffmanagement.disable2fa")] + [HttpPost] + public BaseResponse DisableTwoFactor([FromBody] TwoFactorCodeInputDto inputDto) + { + return workerService.DisableTwoFactor(GetCurrentSerialNumber(), inputDto); + } + + /// + /// 重置当前员工账号恢复备用码 + /// + /// + /// + [RequirePermission("staffmanagement.recovery2fa")] + [HttpPost] + public SingleOutputDto RegenerateTwoFactorRecoveryCodes([FromBody] TwoFactorCodeInputDto inputDto) + { + return workerService.RegenerateTwoFactorRecoveryCodes(GetCurrentSerialNumber(), inputDto); + } + /// /// 修改员工账号密码 /// @@ -113,5 +172,16 @@ namespace EOM.TSHotelManagement.WebApi.Controllers { return workerService.ResetEmployeeAccountPassword(updateEmployeeInputDto); } + + /// + /// 从当前登录上下文中读取账号序列号 + /// + /// + private string GetCurrentSerialNumber() + { + return User.FindFirstValue(ClaimTypes.SerialNumber) + ?? User.FindFirst("serialnumber")?.Value + ?? string.Empty; + } } -} \ No newline at end of file +} diff --git a/EOM.TSHotelManagement.API/Controllers/SystemManagement/Administrator/AdminController.cs b/EOM.TSHotelManagement.API/Controllers/SystemManagement/Administrator/AdminController.cs index 6965c360cabeb00b9e1062d297fcb2aaa569b670..15e0112f0088aef526cb439638129970d2c5a546 100644 --- a/EOM.TSHotelManagement.API/Controllers/SystemManagement/Administrator/AdminController.cs +++ b/EOM.TSHotelManagement.API/Controllers/SystemManagement/Administrator/AdminController.cs @@ -4,6 +4,7 @@ using EOM.TSHotelManagement.Service; using EOM.TSHotelManagement.WebApi.Authorization; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using System.Security.Claims; namespace EOM.TSHotelManagement.WebApi.Controllers { @@ -39,6 +40,64 @@ namespace EOM.TSHotelManagement.WebApi.Controllers return adminService.Login(admin); } + /// + /// 获取当前管理员账号的 2FA 状态 + /// + /// + [RequirePermission("system:admin:get2fa")] + [HttpGet] + public SingleOutputDto GetTwoFactorStatus() + { + return adminService.GetTwoFactorStatus(GetCurrentSerialNumber()); + } + + /// + /// 生成当前管理员账号的 2FA 绑定信息 + /// + /// + [RequirePermission("system:admin:generate2fa")] + [HttpPost] + public SingleOutputDto GenerateTwoFactorSetup() + { + return adminService.GenerateTwoFactorSetup(GetCurrentSerialNumber()); + } + + /// + /// 启用当前管理员账号 2FA + /// + /// + /// + [RequirePermission("system:admin:enable2fa")] + [HttpPost] + public SingleOutputDto EnableTwoFactor([FromBody] TwoFactorCodeInputDto inputDto) + { + return adminService.EnableTwoFactor(GetCurrentSerialNumber(), inputDto); + } + + /// + /// 关闭当前管理员账号 2FA + /// + /// + /// + [RequirePermission("system:admin:disable2fa")] + [HttpPost] + public BaseResponse DisableTwoFactor([FromBody] TwoFactorCodeInputDto inputDto) + { + return adminService.DisableTwoFactor(GetCurrentSerialNumber(), inputDto); + } + + /// + /// 重置当前管理员账号恢复备用码 + /// + /// + /// + [RequirePermission("system:admin:recovery2fa")] + [HttpPost] + public SingleOutputDto RegenerateTwoFactorRecoveryCodes([FromBody] TwoFactorCodeInputDto inputDto) + { + return adminService.RegenerateTwoFactorRecoveryCodes(GetCurrentSerialNumber(), inputDto); + } + /// /// 获取所有管理员列表 /// @@ -188,5 +247,16 @@ namespace EOM.TSHotelManagement.WebApi.Controllers { return adminService.ReadUserDirectPermissions(userNumber); } + + /// + /// 从当前登录上下文中读取账号序列号 + /// + /// + private string GetCurrentSerialNumber() + { + return User.FindFirstValue(ClaimTypes.SerialNumber) + ?? User.FindFirst("serialnumber")?.Value + ?? string.Empty; + } } } diff --git a/EOM.TSHotelManagement.API/Extensions/AutofacConfigExtensions.cs b/EOM.TSHotelManagement.API/Extensions/AutofacConfigExtensions.cs index 6c75f1490f6ab88d148e4c2e82b8bb7fa952efae..cc3ded21df517902e120b2591ffb5c33e5b9a224 100644 --- a/EOM.TSHotelManagement.API/Extensions/AutofacConfigExtensions.cs +++ b/EOM.TSHotelManagement.API/Extensions/AutofacConfigExtensions.cs @@ -33,6 +33,7 @@ namespace EOM.TSHotelManagement.WebApi builder.RegisterType().AsSelf().InstancePerLifetimeScope(); builder.RegisterType().AsSelf().InstancePerLifetimeScope(); builder.RegisterType().AsSelf().InstancePerLifetimeScope(); + builder.RegisterType().AsSelf().InstancePerLifetimeScope(); builder.RegisterGeneric(typeof(GenericRepository<>)).AsSelf().InstancePerLifetimeScope(); diff --git a/EOM.TSHotelManagement.API/Extensions/ServiceExtensions.cs b/EOM.TSHotelManagement.API/Extensions/ServiceExtensions.cs index 2e9b9daed0d509005e28fd9527fb2bee66face23..a753ecefa627196a74486ca673ec3bba508556b2 100644 --- a/EOM.TSHotelManagement.API/Extensions/ServiceExtensions.cs +++ b/EOM.TSHotelManagement.API/Extensions/ServiceExtensions.cs @@ -131,6 +131,7 @@ namespace EOM.TSHotelManagement.WebApi services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.Configure(configuration.GetSection("CsrfToken")); services.AddSingleton(); diff --git a/EOM.TSHotelManagement.API/appsettings.Services.json b/EOM.TSHotelManagement.API/appsettings.Services.json index 7c2ba5c86f09548e840d325f34cc2ac308ac4b81..98919c7f93592bcdd935169956c2c5fa02e0756f 100644 --- a/EOM.TSHotelManagement.API/appsettings.Services.json +++ b/EOM.TSHotelManagement.API/appsettings.Services.json @@ -19,5 +19,15 @@ "Password": "", "UploadApi": "", "GetTokenApi": "" + }, + "TwoFactor": { + "Issuer": "TSHotel", + "SecretSize": 20, + "CodeDigits": 6, + "TimeStepSeconds": 30, + "AllowedDriftWindows": 1, + "RecoveryCodeCount": 8, + "RecoveryCodeLength": 10, + "RecoveryCodeGroupSize": 5 } -} \ No newline at end of file +} diff --git a/EOM.TSHotelManagement.API/appsettings.json b/EOM.TSHotelManagement.API/appsettings.json index e1c9e6f5b820b3a58afee6bccf4429f9188c119a..d9d9a9bff6fd6f3ee7ea00de958f135a4a28c6e6 100644 --- a/EOM.TSHotelManagement.API/appsettings.json +++ b/EOM.TSHotelManagement.API/appsettings.json @@ -6,47 +6,5 @@ "Microsoft.Hosting.Lifetime": "Information" } }, - "DefaultDatabase": "MariaDB", - "ConnectionStrings": { - "PgSqlConnectStr": "Host=my_pgsql_host;Port=5432;Username=my_pgsql_user;Password=my_pgsql_password;Database=tshoteldb;", - "MySqlConnectStr": "Server=my_mysql_host;Database=tshoteldb;User=my_mysql_user;Password=my_mysql_password;", - "MariaDBConnectStr": "Server=localhost;Database=tshoteldb;User=my_mariadb_user;Password=my_mariadb_password;", - "SqlServerConnectStr": "Server=my_sqlserver_host;Database=tshoteldb;User Id=my_sqlserver_user;Password=my_sqlserver_password;", - "OracleConnectStr": "User Id=my_oracle_user;Password=my_oracle_password;Data Source=(DESCRIPTION=(ADDRESS_LIST=(ADDRESS=(PROTOCOL=TCP)(HOST=my_oracle_host)(PORT=1521)))(CONNECT_DATA=(SERVER=DEDICATED)(SERVICE_NAME=my_oracle_service_name)));" - }, - "AllowedOrigins": [ - "http://localhost:8080", - "https://tshotel.oscode.top" - ], - "AllowedHosts": "*", - "InitializeDatabase": true, - "JobKeys": [ - "ReservationExpirationCheckJob" - ], - "ExpirationSettings": { - "NotifyDaysBefore": 3, - "CheckIntervalMinutes": 5 - }, - "Jwt": { - "Key": "", - "ExpiryMinutes": 20 - }, - "Mail": { - "Enabled": false, // Whether to enable email functionality - "Host": "...", - "Port": 465, - "UserName": "", - "Password": "", - "EnableSsl": true, - "DisplayName": "" - }, - "SoftwareVersion": "1.0.0", - "Lsky": { - "Enabled": false, // Whether to enable Lsky image hosting integration - "BaseAddress": "", - "Email": "", - "Password": "", - "UploadApi": "", - "GetTokenApi": "" - } + "AllowedHosts": "*" } diff --git a/EOM.TSHotelManagement.Common/Enums/TwoFactorUserType.cs b/EOM.TSHotelManagement.Common/Enums/TwoFactorUserType.cs new file mode 100644 index 0000000000000000000000000000000000000000..f2376a9a60054ced53df0be04d0259e3b70f53f1 --- /dev/null +++ b/EOM.TSHotelManagement.Common/Enums/TwoFactorUserType.cs @@ -0,0 +1,10 @@ +namespace EOM.TSHotelManagement.Common +{ + public enum TwoFactorUserType + { + Unknown = 0, + Employee = 1, + Administrator = 2, + Customer = 3 + } +} diff --git a/EOM.TSHotelManagement.Common/Helper/DataProtectionHelper.cs b/EOM.TSHotelManagement.Common/Helper/DataProtectionHelper.cs index 499a5649cc08c254d7cd299cbdc9dc127180a85d..efbff0176698c9a2ce2b9f7abded2ee8ffc441d2 100644 --- a/EOM.TSHotelManagement.Common/Helper/DataProtectionHelper.cs +++ b/EOM.TSHotelManagement.Common/Helper/DataProtectionHelper.cs @@ -10,6 +10,7 @@ namespace EOM.TSHotelManagement.Common private readonly IDataProtector _reservationProtector; private readonly IDataProtector _customerProtector; private readonly IDataProtector _adminProtector; + private readonly IDataProtector _twoFactorProtector; public DataProtectionHelper(IDataProtectionProvider dataProtectionProvider) { @@ -17,6 +18,7 @@ namespace EOM.TSHotelManagement.Common _reservationProtector = dataProtectionProvider.CreateProtector("ReservationInfoProtector"); _customerProtector = dataProtectionProvider.CreateProtector("CustomerInfoProtector"); _adminProtector = dataProtectionProvider.CreateProtector("AdminInfoProtector"); + _twoFactorProtector = dataProtectionProvider.CreateProtector("TwoFactorProtector"); } private string DecryptData(string encryptedData, IDataProtector protector) @@ -111,5 +113,11 @@ namespace EOM.TSHotelManagement.Common public string EncryptAdministratorData(string plainText) => EncryptData(plainText, _adminProtector); + + public string SafeDecryptTwoFactorData(string encryptedData) + => SafeDecryptData(encryptedData, _twoFactorProtector); + + public string EncryptTwoFactorData(string plainText) + => EncryptData(plainText, _twoFactorProtector); } } diff --git a/EOM.TSHotelManagement.Common/Helper/EntityMapper.cs b/EOM.TSHotelManagement.Common/Helper/EntityMapper.cs index 3a464f8812b1b6e0f197d315c29fcffd3b0fa591..08f9a869aafa8f0113b8f5e83c2ef26f13d737ff 100644 --- a/EOM.TSHotelManagement.Common/Helper/EntityMapper.cs +++ b/EOM.TSHotelManagement.Common/Helper/EntityMapper.cs @@ -8,7 +8,7 @@ namespace EOM.TSHotelManagement.Common public static class EntityMapper { /// - /// ӳ䵥ʵ + /// 映射单个实体 /// public static TDestination Map(TSource source) where TDestination : new() @@ -70,7 +70,7 @@ namespace EOM.TSHotelManagement.Common } /// - /// ת + /// 智能类型转换 /// private static object SmartConvert(object value, Type targetType) { @@ -119,7 +119,7 @@ namespace EOM.TSHotelManagement.Common } /// - /// ǷΪСֵ + /// 检查是否为最小值 /// private static bool IsMinValue(object value) { @@ -133,7 +133,7 @@ namespace EOM.TSHotelManagement.Common } /// - /// СֵתΪֵ + /// 将最小值转换为空值 /// private static object ConvertMinValueToNull(object value, Type targetType) { @@ -151,7 +151,7 @@ namespace EOM.TSHotelManagement.Common } /// - /// DateOnly ת + /// 处理 DateOnly 类型转换 /// private static object HandleDateOnlyConversion(DateOnly dateOnly, Type targetType) { @@ -182,7 +182,7 @@ namespace EOM.TSHotelManagement.Common } /// - /// DateTime ת + /// 处理 DateTime 类型转换 /// private static object HandleDateTimeConversion(DateTime dateTime, Type targetType) { @@ -213,7 +213,7 @@ namespace EOM.TSHotelManagement.Common } /// - /// ַת + /// 处理字符串日期转换 /// private static object HandleStringConversion(string dateString, Type targetType) { @@ -236,7 +236,7 @@ namespace EOM.TSHotelManagement.Common } /// - /// жǷҪת + /// 判断是否需要类型转换 /// private static bool NeedConversion(Type sourceType, Type targetType) { @@ -249,7 +249,7 @@ namespace EOM.TSHotelManagement.Common } /// - /// ӳʵб + /// 映射实体列表 /// public static List MapList(List sourceList) where TDestination : new() diff --git a/EOM.TSHotelManagement.Common/Helper/LocalizationHelper.cs b/EOM.TSHotelManagement.Common/Helper/LocalizationHelper.cs index 376da2a45633619e1355c831ba801e0720c54402..ff2f82b6336a66f27cef7e282c05b5b2aa40464b 100644 --- a/EOM.TSHotelManagement.Common/Helper/LocalizationHelper.cs +++ b/EOM.TSHotelManagement.Common/Helper/LocalizationHelper.cs @@ -6,11 +6,11 @@ namespace EOM.TSHotelManagement.Common public static class LocalizationHelper { /// - /// ȡػַ + /// 获取本地化字符串 /// - /// Ӣı - /// ı - /// ݵǰĻӦı + /// 英文文本 + /// 中文文本 + /// 根据当前文化返回相应的文本 public static string GetLocalizedString(string englishText, string chineseText) { var culture = CultureInfo.CurrentCulture.Name; @@ -18,9 +18,9 @@ namespace EOM.TSHotelManagement.Common } /// - /// õǰĻ + /// 设置当前文化 /// - /// Ļ + /// 文化名称 public static void SetCulture(string culture) { CultureInfo.CurrentCulture = new CultureInfo(culture); diff --git a/EOM.TSHotelManagement.Common/Helper/TwoFactorHelper.cs b/EOM.TSHotelManagement.Common/Helper/TwoFactorHelper.cs new file mode 100644 index 0000000000000000000000000000000000000000..5134c82224c49c698b876a2f4e5e61103366e5b2 --- /dev/null +++ b/EOM.TSHotelManagement.Common/Helper/TwoFactorHelper.cs @@ -0,0 +1,372 @@ +using EOM.TSHotelManagement.Infrastructure; +using System.Security.Cryptography; +using System.Text; + +namespace EOM.TSHotelManagement.Common +{ + /// + /// TOTP(2FA)工具类 + /// + public class TwoFactorHelper + { + private const string Base32Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + private const string RecoveryCodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + private readonly TwoFactorConfigFactory _configFactory; + + /// + /// 构造函数 + /// + /// + public TwoFactorHelper(TwoFactorConfigFactory configFactory) + { + _configFactory = configFactory; + } + + /// + /// 生成 Base32 格式的 2FA 密钥 + /// + /// + public string GenerateSecretKey() + { + var config = GetConfig(); + var secretSize = config.SecretSize <= 0 ? 20 : config.SecretSize; + var secretBytes = RandomNumberGenerator.GetBytes(secretSize); + return Base32Encode(secretBytes); + } + + /// + /// 生成恢复备用码(仅明文返回一次) + /// + /// + public List GenerateRecoveryCodes() + { + var config = GetConfig(); + var result = new List(config.RecoveryCodeCount); + + for (var i = 0; i < config.RecoveryCodeCount; i++) + { + var chars = new char[config.RecoveryCodeLength]; + for (var j = 0; j < chars.Length; j++) + { + chars[j] = RecoveryCodeAlphabet[RandomNumberGenerator.GetInt32(0, RecoveryCodeAlphabet.Length)]; + } + + var raw = new string(chars); + result.Add(FormatRecoveryCode(raw, config.RecoveryCodeGroupSize)); + } + + return result; + } + + /// + /// 生成恢复备用码盐值 + /// + /// + public string CreateRecoveryCodeSalt() + { + return Convert.ToHexString(RandomNumberGenerator.GetBytes(16)); + } + + /// + /// 对恢复备用码进行哈希 + /// + /// 备用码(可带分隔符) + /// 盐值 + /// + public string HashRecoveryCode(string recoveryCode, string salt) + { + var normalized = NormalizeRecoveryCode(recoveryCode); + if (string.IsNullOrWhiteSpace(normalized) || string.IsNullOrWhiteSpace(salt)) + { + return string.Empty; + } + + using var sha = SHA256.Create(); + var payload = Encoding.UTF8.GetBytes($"{salt}:{normalized}"); + return Convert.ToHexString(sha.ComputeHash(payload)); + } + + /// + /// 校验恢复备用码 + /// + /// 备用码(可带分隔符) + /// 盐值 + /// 库内哈希 + /// + public bool VerifyRecoveryCode(string recoveryCode, string salt, string expectedHash) + { + if (string.IsNullOrWhiteSpace(expectedHash)) + { + return false; + } + + var currentHash = HashRecoveryCode(recoveryCode, salt); + if (string.IsNullOrWhiteSpace(currentHash)) + { + return false; + } + + return FixedTimeEquals(currentHash, expectedHash); + } + + /// + /// 归一化恢复备用码(去空格和连接符) + /// + /// + /// + public string NormalizeRecoveryCode(string code) + { + if (string.IsNullOrWhiteSpace(code)) + { + return string.Empty; + } + + return new string(code + .Where(c => char.IsLetterOrDigit(c)) + .Select(char.ToUpperInvariant) + .ToArray()); + } + + /// + /// 生成符合 Google Authenticator 的 otpauth URI + /// + /// 账号标识 + /// Base32 密钥 + /// + public string BuildOtpAuthUri(string accountName, string secretKey) + { + var config = GetConfig(); + var issuer = config.Issuer ?? "TSHotel"; + var encodedIssuer = Uri.EscapeDataString(issuer); + var encodedAccount = Uri.EscapeDataString(accountName ?? "user"); + + return $"otpauth://totp/{encodedIssuer}:{encodedAccount}?secret={secretKey}&issuer={encodedIssuer}&digits={config.CodeDigits}&period={config.TimeStepSeconds}"; + } + + /// + /// 校验 TOTP 验证码 + /// + /// Base32 密钥 + /// 验证码 + /// 校验时间(UTC,空时取当前) + /// + public bool VerifyCode(string secretKey, string code, DateTime? utcNow = null) + { + return TryVerifyCode(secretKey, code, out _, utcNow); + } + + /// + /// 校验 TOTP 验证码,并返回命中的计数器(counter) + /// + /// Base32 密钥 + /// 验证码 + /// 命中的计数器 + /// 校验时间(UTC,空时取当前) + /// + public bool TryVerifyCode(string secretKey, string code, out long validatedCounter, DateTime? utcNow = null) + { + validatedCounter = -1; + + if (string.IsNullOrWhiteSpace(secretKey) || string.IsNullOrWhiteSpace(code)) + return false; + + var config = GetConfig(); + var normalizedCode = new string(code.Where(char.IsDigit).ToArray()); + if (normalizedCode.Length != config.CodeDigits) + return false; + + var key = Base32Decode(secretKey); + var unixTime = new DateTimeOffset(utcNow ?? DateTime.UtcNow).ToUnixTimeSeconds(); + var step = config.TimeStepSeconds <= 0 ? 30 : config.TimeStepSeconds; + var counter = unixTime / step; + var drift = config.AllowedDriftWindows < 0 ? 0 : config.AllowedDriftWindows; + + for (var i = -drift; i <= drift; i++) + { + var currentCounter = counter + i; + if (currentCounter < 0) + continue; + + var expected = ComputeTotp(key, currentCounter, config.CodeDigits); + if (FixedTimeEquals(expected, normalizedCode)) + { + validatedCounter = currentCounter; + return true; + } + } + + return false; + } + + /// + /// 获取验证码位数 + /// + /// + public int GetCodeDigits() + { + return GetConfig().CodeDigits; + } + + /// + /// 获取时间步长(秒) + /// + /// + public int GetTimeStepSeconds() + { + return GetConfig().TimeStepSeconds; + } + + private TwoFactorConfig GetConfig() + { + var config = _configFactory.GetTwoFactorConfig(); + if (config.CodeDigits is < 6 or > 8) + { + config.CodeDigits = 6; + } + + if (config.TimeStepSeconds <= 0) + { + config.TimeStepSeconds = 30; + } + + if (config.SecretSize <= 0) + { + config.SecretSize = 20; + } + + if (config.RecoveryCodeCount <= 0) + { + config.RecoveryCodeCount = 8; + } + + if (config.RecoveryCodeLength < 8) + { + config.RecoveryCodeLength = 10; + } + + if (config.RecoveryCodeGroupSize <= 0) + { + config.RecoveryCodeGroupSize = 5; + } + + return config; + } + + private static string FormatRecoveryCode(string raw, int groupSize) + { + if (string.IsNullOrWhiteSpace(raw) || groupSize <= 0) + { + return raw; + } + + var normalized = raw.ToUpperInvariant(); + var sb = new StringBuilder(normalized.Length + normalized.Length / groupSize); + + for (var i = 0; i < normalized.Length; i++) + { + if (i > 0 && i % groupSize == 0) + { + sb.Append('-'); + } + + sb.Append(normalized[i]); + } + + return sb.ToString(); + } + + private static string ComputeTotp(byte[] key, long counter, int digits) + { + var counterBytes = BitConverter.GetBytes(counter); + if (BitConverter.IsLittleEndian) + { + Array.Reverse(counterBytes); + } + + using var hmac = new HMACSHA1(key); + var hash = hmac.ComputeHash(counterBytes); + var offset = hash[^1] & 0x0F; + var binaryCode = ((hash[offset] & 0x7F) << 24) + | (hash[offset + 1] << 16) + | (hash[offset + 2] << 8) + | hash[offset + 3]; + + var otp = binaryCode % (int)Math.Pow(10, digits); + return otp.ToString().PadLeft(digits, '0'); + } + + private static bool FixedTimeEquals(string left, string right) + { + var leftBytes = Encoding.UTF8.GetBytes(left); + var rightBytes = Encoding.UTF8.GetBytes(right); + return CryptographicOperations.FixedTimeEquals(leftBytes, rightBytes); + } + + private static string Base32Encode(byte[] data) + { + if (data.Length == 0) + return string.Empty; + + var output = new StringBuilder((int)Math.Ceiling(data.Length / 5d) * 8); + var bitBuffer = 0; + var bitCount = 0; + + foreach (var b in data) + { + bitBuffer = (bitBuffer << 8) | b; + bitCount += 8; + + while (bitCount >= 5) + { + var index = (bitBuffer >> (bitCount - 5)) & 0x1F; + output.Append(Base32Alphabet[index]); + bitCount -= 5; + } + } + + if (bitCount > 0) + { + var index = (bitBuffer << (5 - bitCount)) & 0x1F; + output.Append(Base32Alphabet[index]); + } + + return output.ToString(); + } + + private static byte[] Base32Decode(string base32) + { + var normalized = (base32 ?? string.Empty) + .Trim() + .TrimEnd('=') + .Replace(" ", string.Empty) + .ToUpperInvariant(); + + if (normalized.Length == 0) + return Array.Empty(); + + var bytes = new List(normalized.Length * 5 / 8); + var bitBuffer = 0; + var bitCount = 0; + + foreach (var c in normalized) + { + var index = Base32Alphabet.IndexOf(c); + if (index < 0) + { + throw new ArgumentException("Invalid Base32 secret key."); + } + + bitBuffer = (bitBuffer << 5) | index; + bitCount += 5; + + if (bitCount >= 8) + { + bytes.Add((byte)((bitBuffer >> (bitCount - 8)) & 0xFF)); + bitCount -= 8; + } + } + + return bytes.ToArray(); + } + } +} diff --git a/EOM.TSHotelManagement.Common/Templates/EmailTemplate.cs b/EOM.TSHotelManagement.Common/Templates/EmailTemplate.cs index b503a4a27b85d5f9feed2673e46bcda380ddccb5..5778df491992ee8f48c2e9c2c398ea8f5ff9802f 100644 --- a/EOM.TSHotelManagement.Common/Templates/EmailTemplate.cs +++ b/EOM.TSHotelManagement.Common/Templates/EmailTemplate.cs @@ -136,6 +136,29 @@ public static class EmailTemplate }; } + public static MailTemplate GetTwoFactorRecoveryCodeLoginAlertTemplate(string userName, string userIdentity, DateTime? loginTime = null) + { + var occurredAt = loginTime ?? DateTime.Now; + var safeUserName = string.IsNullOrWhiteSpace(userName) ? "User" : userName; + var safeIdentity = string.IsNullOrWhiteSpace(userIdentity) ? "-" : userIdentity; + + return new MailTemplate + { + Subject = LocalizationHelper.GetLocalizedString("Security alert: recovery code login", "安全提醒:检测到备用码登录"), + Body = BasicTemplate( + SystemConstant.BranchName.Code, + SystemConstant.BranchLogo.Code, + "账户安全提醒", + safeUserName, + $@"

{LocalizationHelper.GetLocalizedString("A login used a 2FA recovery code.", "检测到您的账号使用了 2FA 恢复备用码登录。")}

+

{LocalizationHelper.GetLocalizedString("Time", "时间")}:{occurredAt:yyyy-MM-dd HH:mm:ss}

+

{LocalizationHelper.GetLocalizedString("User", "用户")}:{safeUserName} ({safeIdentity})

+

{LocalizationHelper.GetLocalizedString("If this was not you, please reset your password and rebind your authenticator immediately.", "如果不是本人操作,请立即修改密码并重新绑定验证器。")}

", + "#FF6600", + "中") + }; + } + public static MailTemplate SendReservationExpirationNotificationTemplate(string roomNo, string reservationChannel, string customerName, string roomType, DateTime endDate, int daysLeft) { diff --git a/EOM.TSHotelManagement.Contract/Application/NavBar/Dto/CreateNavBarInputDto.cs b/EOM.TSHotelManagement.Contract/Application/NavBar/Dto/CreateNavBarInputDto.cs index a85651503a01b2aee252c574962a3a4d7f6a012b..5874b2b9edaf1b9b101a160e763eb852502a738a 100644 --- a/EOM.TSHotelManagement.Contract/Application/NavBar/Dto/CreateNavBarInputDto.cs +++ b/EOM.TSHotelManagement.Contract/Application/NavBar/Dto/CreateNavBarInputDto.cs @@ -4,11 +4,11 @@ namespace EOM.TSHotelManagement.Contract { public class CreateNavBarInputDto : BaseInputDto { - [Required(ErrorMessage = "Ϊֶ"), MaxLength(50, ErrorMessage = "󳤶Ϊ50ַ")] + [Required(ErrorMessage = "导航栏名称为必填字段"), MaxLength(50, ErrorMessage = "导航栏名称最大长度为50字符")] public string NavigationBarName { get; set; } public int NavigationBarOrder { get; set; } public string NavigationBarImage { get; set; } - [Required(ErrorMessage = "¼Ϊֶ"), MaxLength(200, ErrorMessage = "¼󳤶Ϊ200ַ")] + [Required(ErrorMessage = "导航栏事件名为必填字段"), MaxLength(200, ErrorMessage = "导航栏事件名最大长度为200字符")] public string NavigationBarEvent { get; set; } public int MarginLeft { get; set; } } diff --git a/EOM.TSHotelManagement.Contract/Business/Customer/Dto/CustoType/CreateCustoTypeInputDto.cs b/EOM.TSHotelManagement.Contract/Business/Customer/Dto/CustoType/CreateCustoTypeInputDto.cs index 3c3c3216a1e551f326cb1039a508578b60b77f7c..737cf68884fa58bec83441d1c9da8fb434ee04e2 100644 --- a/EOM.TSHotelManagement.Contract/Business/Customer/Dto/CustoType/CreateCustoTypeInputDto.cs +++ b/EOM.TSHotelManagement.Contract/Business/Customer/Dto/CustoType/CreateCustoTypeInputDto.cs @@ -3,17 +3,17 @@ namespace EOM.TSHotelManagement.Contract public class CreateCustoTypeInputDto : BaseInputDto { /// - /// ͻ (Customer Type) + /// 客户类型 (Customer Type) /// public int CustomerType { get; set; } /// - /// ͻ (Customer Type Name) + /// 客户类型名称 (Customer Type Name) /// public string CustomerTypeName { get; set; } /// - /// Żۿ + /// 优惠折扣 /// public decimal Discount { get; set; } } diff --git a/EOM.TSHotelManagement.Contract/Business/Customer/Dto/CustomerAccount/ReadCustomerAccountInputDto.cs b/EOM.TSHotelManagement.Contract/Business/Customer/Dto/CustomerAccount/ReadCustomerAccountInputDto.cs index 74d08caf83c3623a9dddf02cc61b8bdfc10028c3..8e82bcbafd8b6d1c91528d8a32959158c56222eb 100644 --- a/EOM.TSHotelManagement.Contract/Business/Customer/Dto/CustomerAccount/ReadCustomerAccountInputDto.cs +++ b/EOM.TSHotelManagement.Contract/Business/Customer/Dto/CustomerAccount/ReadCustomerAccountInputDto.cs @@ -14,5 +14,9 @@ /// 邮箱 (Email) /// public string? EmailAddress { get; set; } + /// + /// 二次验证码 (2FA Code) + /// + public string? TwoFactorCode { get; set; } } -} \ No newline at end of file +} diff --git a/EOM.TSHotelManagement.Contract/Business/Customer/Dto/CustomerAccount/ReadCustomerAccountOutputDto.cs b/EOM.TSHotelManagement.Contract/Business/Customer/Dto/CustomerAccount/ReadCustomerAccountOutputDto.cs index 3e5b69b741f2ab32743c170b9ae7b4793c73b0cd..13f30ab1b56400a61416561bf0e9121848db61cc 100644 --- a/EOM.TSHotelManagement.Contract/Business/Customer/Dto/CustomerAccount/ReadCustomerAccountOutputDto.cs +++ b/EOM.TSHotelManagement.Contract/Business/Customer/Dto/CustomerAccount/ReadCustomerAccountOutputDto.cs @@ -30,5 +30,15 @@ /// 最后一次登录时间 (Last Login Time) /// public DateTime? LastLoginTime { get; set; } + + /// + /// 是否需要2FA + /// + public bool RequiresTwoFactor { get; set; } + + /// + /// 本次登录是否通过恢复备用码完成 2FA + /// + public bool UsedRecoveryCodeLogin { get; set; } } -} \ No newline at end of file +} diff --git a/EOM.TSHotelManagement.Contract/Common/Dto/TwoFactor/TwoFactorCodeInputDto.cs b/EOM.TSHotelManagement.Contract/Common/Dto/TwoFactor/TwoFactorCodeInputDto.cs new file mode 100644 index 0000000000000000000000000000000000000000..169ed6bafa3d64536f159369e4c4ed57a816f914 --- /dev/null +++ b/EOM.TSHotelManagement.Contract/Common/Dto/TwoFactor/TwoFactorCodeInputDto.cs @@ -0,0 +1,13 @@ +namespace EOM.TSHotelManagement.Contract +{ + /// + /// 2FA 验证码输入 + /// + public class TwoFactorCodeInputDto : BaseInputDto + { + /// + /// 验证码 + /// + public string VerificationCode { get; set; } = string.Empty; + } +} diff --git a/EOM.TSHotelManagement.Contract/Common/Dto/TwoFactor/TwoFactorRecoveryCodesOutputDto.cs b/EOM.TSHotelManagement.Contract/Common/Dto/TwoFactor/TwoFactorRecoveryCodesOutputDto.cs new file mode 100644 index 0000000000000000000000000000000000000000..7b9e97403c759b6a0990ae0548c764529419675b --- /dev/null +++ b/EOM.TSHotelManagement.Contract/Common/Dto/TwoFactor/TwoFactorRecoveryCodesOutputDto.cs @@ -0,0 +1,18 @@ +namespace EOM.TSHotelManagement.Contract +{ + /// + /// 2FA 恢复备用码输出 + /// + public class TwoFactorRecoveryCodesOutputDto : BaseOutputDto + { + /// + /// 新生成的恢复备用码(仅返回一次) + /// + public List RecoveryCodes { get; set; } = new(); + + /// + /// 剩余可用恢复备用码数量 + /// + public int RemainingCount { get; set; } + } +} diff --git a/EOM.TSHotelManagement.Contract/Common/Dto/TwoFactor/TwoFactorSetupOutputDto.cs b/EOM.TSHotelManagement.Contract/Common/Dto/TwoFactor/TwoFactorSetupOutputDto.cs new file mode 100644 index 0000000000000000000000000000000000000000..1ef37114e65e5f503fc8af1cb78edb17b0ea9504 --- /dev/null +++ b/EOM.TSHotelManagement.Contract/Common/Dto/TwoFactor/TwoFactorSetupOutputDto.cs @@ -0,0 +1,38 @@ +namespace EOM.TSHotelManagement.Contract +{ + /// + /// 2FA 绑定信息输出 + /// + public class TwoFactorSetupOutputDto : BaseOutputDto + { + /// + /// 是否已启用 2FA + /// + public bool IsEnabled { get; set; } + + /// + /// 账号标识(Authenticator 展示) + /// + public string AccountName { get; set; } = string.Empty; + + /// + /// 手动录入密钥(Base32) + /// + public string ManualEntryKey { get; set; } = string.Empty; + + /// + /// otpauth URI + /// + public string OtpAuthUri { get; set; } = string.Empty; + + /// + /// 验证码位数 + /// + public int CodeDigits { get; set; } + + /// + /// 时间步长(秒) + /// + public int TimeStepSeconds { get; set; } + } +} diff --git a/EOM.TSHotelManagement.Contract/Common/Dto/TwoFactor/TwoFactorStatusOutputDto.cs b/EOM.TSHotelManagement.Contract/Common/Dto/TwoFactor/TwoFactorStatusOutputDto.cs new file mode 100644 index 0000000000000000000000000000000000000000..b83b49bf10a6f2465ffcd1d1548ae97b0818cdd1 --- /dev/null +++ b/EOM.TSHotelManagement.Contract/Common/Dto/TwoFactor/TwoFactorStatusOutputDto.cs @@ -0,0 +1,28 @@ +namespace EOM.TSHotelManagement.Contract +{ + /// + /// 2FA 状态输出 + /// + public class TwoFactorStatusOutputDto : BaseOutputDto + { + /// + /// 是否已启用 + /// + public bool IsEnabled { get; set; } + + /// + /// 启用时间 + /// + public DateTime? EnabledAt { get; set; } + + /// + /// 最近一次验证时间 + /// + public DateTime? LastVerifiedAt { get; set; } + + /// + /// 剩余可用恢复备用码数量 + /// + public int RemainingRecoveryCodes { get; set; } + } +} diff --git a/EOM.TSHotelManagement.Contract/Employee/Dto/Employee/EmployeeLoginDto.cs b/EOM.TSHotelManagement.Contract/Employee/Dto/Employee/EmployeeLoginDto.cs index e6797240c2d9018aa26be7d780faa307428ed12f..ec6edfa5e1dc9b8317746a55caa4cc57dfe93d65 100644 --- a/EOM.TSHotelManagement.Contract/Employee/Dto/Employee/EmployeeLoginDto.cs +++ b/EOM.TSHotelManagement.Contract/Employee/Dto/Employee/EmployeeLoginDto.cs @@ -9,5 +9,6 @@ namespace EOM.TSHotelManagement.Contract public string EmployeeId { get; set; } public string Password { get; set; } public string EmailAddress { get; set; } + public string? TwoFactorCode { get; set; } } } diff --git a/EOM.TSHotelManagement.Contract/Employee/Dto/Employee/ReadEmployeeOutputDto.cs b/EOM.TSHotelManagement.Contract/Employee/Dto/Employee/ReadEmployeeOutputDto.cs index 7ec59a5766e2061d610176b16286662c4af3044a..66b0108424ba7de38a6684b0b35cc618e0a0aa07 100644 --- a/EOM.TSHotelManagement.Contract/Employee/Dto/Employee/ReadEmployeeOutputDto.cs +++ b/EOM.TSHotelManagement.Contract/Employee/Dto/Employee/ReadEmployeeOutputDto.cs @@ -38,5 +38,11 @@ public string Password { get; set; } public string EmailAddress { get; set; } public string PhotoUrl { get; set; } + public bool RequiresTwoFactor { get; set; } + + /// + /// 本次登录是否通过恢复备用码完成 2FA + /// + public bool UsedRecoveryCodeLogin { get; set; } } } diff --git a/EOM.TSHotelManagement.Contract/SystemManagement/Dto/Administrator/ReadAdministratorInputDto.cs b/EOM.TSHotelManagement.Contract/SystemManagement/Dto/Administrator/ReadAdministratorInputDto.cs index 8a5f0ec9170bbf1fe448a628b814c1b340f7ff75..86dd7f4f803f899bfd5e3aaba49ce8d3a904f61d 100644 --- a/EOM.TSHotelManagement.Contract/SystemManagement/Dto/Administrator/ReadAdministratorInputDto.cs +++ b/EOM.TSHotelManagement.Contract/SystemManagement/Dto/Administrator/ReadAdministratorInputDto.cs @@ -6,6 +6,7 @@ public string? Password { get; set; } public string? Type { get; set; } public string? Name { get; set; } + public string? TwoFactorCode { get; set; } public int? IsSuperAdmin { get; set; } } diff --git a/EOM.TSHotelManagement.Contract/SystemManagement/Dto/Administrator/ReadAdministratorOutputDto.cs b/EOM.TSHotelManagement.Contract/SystemManagement/Dto/Administrator/ReadAdministratorOutputDto.cs index 3bbfad6cf05483d9f5e71a059ca5108992153db1..6421a0cc161dbfe0378546dcf7decaa0de75c383 100644 --- a/EOM.TSHotelManagement.Contract/SystemManagement/Dto/Administrator/ReadAdministratorOutputDto.cs +++ b/EOM.TSHotelManagement.Contract/SystemManagement/Dto/Administrator/ReadAdministratorOutputDto.cs @@ -13,6 +13,13 @@ public string IsSuperAdminDescription { get; set; } public string TypeName { get; set; } + + public bool RequiresTwoFactor { get; set; } + + /// + /// 本次登录是否通过恢复备用码完成 2FA + /// + public bool UsedRecoveryCodeLogin { get; set; } } } diff --git a/EOM.TSHotelManagement.Contract/SystemManagement/Dto/Position/CreatePositionInputDto.cs b/EOM.TSHotelManagement.Contract/SystemManagement/Dto/Position/CreatePositionInputDto.cs index 4542ca2e6a39c87e391a42c0cc40c248ff37886e..f1c85c41d9599cd222231f9e547d3ecb63dd7fd8 100644 --- a/EOM.TSHotelManagement.Contract/SystemManagement/Dto/Position/CreatePositionInputDto.cs +++ b/EOM.TSHotelManagement.Contract/SystemManagement/Dto/Position/CreatePositionInputDto.cs @@ -4,9 +4,9 @@ namespace EOM.TSHotelManagement.Contract { public class CreatePositionInputDto : BaseInputDto { - [Required(ErrorMessage = "ְλΪֶ"), MaxLength(128, ErrorMessage = "ְλ󳤶Ϊ128ַ")] + [Required(ErrorMessage = "职位编号为必填字段"), MaxLength(128, ErrorMessage = "职位编号最大长度为128字符")] public string PositionNumber { get; set; } - [Required(ErrorMessage = "ְλΪֶ"), MaxLength(200, ErrorMessage = "ְλ󳤶Ϊ200ַ")] + [Required(ErrorMessage = "职位名称为必填字段"), MaxLength(200, ErrorMessage = "职位名称最大长度为200字符")] public string PositionName { get; set; } } } diff --git a/EOM.TSHotelManagement.Data/DatabaseInitializer/DatabaseInitializer.cs b/EOM.TSHotelManagement.Data/DatabaseInitializer/DatabaseInitializer.cs index 48455e1aa2984f73be588a6fabe1ae6371e8fc13..6367387a8b95bb02169d5fe42e697040cc708542 100644 --- a/EOM.TSHotelManagement.Data/DatabaseInitializer/DatabaseInitializer.cs +++ b/EOM.TSHotelManagement.Data/DatabaseInitializer/DatabaseInitializer.cs @@ -15,7 +15,9 @@ namespace EOM.TSHotelManagement.Data private readonly ISqlSugarClientConnector _connector; private readonly IConfiguration _configuration; private readonly string _initialAdminEncryptedPassword; + private readonly string _initialEmployeeEncryptedPassword; private const string AdminProtectorPurpose = "AdminInfoProtector"; + private const string EmployeeProtectorPurpose = "EmployeeInfoProtector"; public DatabaseInitializer( ISqlSugarClient client, @@ -29,6 +31,9 @@ namespace EOM.TSHotelManagement.Data _initialAdminEncryptedPassword = dataProtectionProvider .CreateProtector(AdminProtectorPurpose) .Protect("admin"); + _initialEmployeeEncryptedPassword = dataProtectionProvider + .CreateProtector(EmployeeProtectorPurpose) + .Protect("WK010"); } #region initlize database @@ -72,7 +77,7 @@ namespace EOM.TSHotelManagement.Data { Console.WriteLine("Initializing database schema..."); - var entityBuilder = new EntityBuilder(_initialAdminEncryptedPassword); + var entityBuilder = new EntityBuilder(_initialAdminEncryptedPassword, _initialEmployeeEncryptedPassword); var dbTables = db.DbMaintenance.GetTableInfoList() .Select(a => a.Name.Trim().ToLower()) @@ -92,7 +97,8 @@ namespace EOM.TSHotelManagement.Data .ToArray(); db.CodeFirst.InitTables(needCreateTableTypes); - + EnsureTwoFactorForeignKeys(db, dbSettings.DbType); + Console.WriteLine("Database schema initialized"); SeedInitialData(db); @@ -248,6 +254,58 @@ namespace EOM.TSHotelManagement.Data return configString; } + private void EnsureTwoFactorForeignKeys(ISqlSugarClient db, DbType dbType) + { + try + { + if (dbType is DbType.MySql or DbType.MySqlConnector) + { + EnsureMySqlForeignKey(db, "two_factor_auth", "fk_2fa_employee", "employee_pk", "employee", "id"); + EnsureMySqlForeignKey(db, "two_factor_auth", "fk_2fa_administrator", "administrator_pk", "administrator", "id"); + EnsureMySqlForeignKey(db, "two_factor_auth", "fk_2fa_customer_account", "customer_account_pk", "customer_account", "id"); + EnsureMySqlForeignKey(db, "two_factor_recovery_code", "fk_2fa_recovery_auth", "two_factor_auth_pk", "two_factor_auth", "id", "CASCADE"); + } + } + catch (Exception ex) + { + Console.WriteLine($"EnsureTwoFactorForeignKeys skipped: {ex.Message}"); + } + } + + private static void EnsureMySqlForeignKey( + ISqlSugarClient db, + string tableName, + string constraintName, + string columnName, + string referenceTable, + string referenceColumn, + string onDeleteAction = "SET NULL") + { + var existsSql = @"SELECT COUNT(1) + FROM information_schema.TABLE_CONSTRAINTS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = @tableName + AND CONSTRAINT_NAME = @constraintName"; + + var exists = db.Ado.GetInt( + existsSql, + new SugarParameter("@tableName", tableName), + new SugarParameter("@constraintName", constraintName)) > 0; + if (exists) + { + return; + } + + var addConstraintSql = $@"ALTER TABLE `{tableName}` + ADD CONSTRAINT `{constraintName}` + FOREIGN KEY (`{columnName}`) + REFERENCES `{referenceTable}`(`{referenceColumn}`) + ON UPDATE RESTRICT + ON DELETE {onDeleteAction};"; + + db.Ado.ExecuteCommand(addConstraintSql); + } + private void SeedInitialData(ISqlSugarClient db) { Console.WriteLine("Initializing database data..."); diff --git a/EOM.TSHotelManagement.Domain/Business/Customer/CustomerAccount.cs b/EOM.TSHotelManagement.Domain/Business/Customer/CustomerAccount.cs index 123544934e496a98c90ec2b70da163e24a8af38f..957fe625782f6fa655d3fbd8a8a146f957bf1043 100644 --- a/EOM.TSHotelManagement.Domain/Business/Customer/CustomerAccount.cs +++ b/EOM.TSHotelManagement.Domain/Business/Customer/CustomerAccount.cs @@ -1,8 +1,8 @@ -using System; +using System; namespace EOM.TSHotelManagement.Domain { - [SqlSugar.SugarTable("custoemr_account", "客户账号表")] + [SqlSugar.SugarTable("customer_account", "客户账号表")] public class CustomerAccount : BaseEntity { /// diff --git a/EOM.TSHotelManagement.Domain/SystemManagement/TwoFactorAuth.cs b/EOM.TSHotelManagement.Domain/SystemManagement/TwoFactorAuth.cs new file mode 100644 index 0000000000000000000000000000000000000000..a87512de1263ed621183552e44b024e7fb96bf0a --- /dev/null +++ b/EOM.TSHotelManagement.Domain/SystemManagement/TwoFactorAuth.cs @@ -0,0 +1,38 @@ +using SqlSugar; + +namespace EOM.TSHotelManagement.Domain +{ + [SugarTable("two_factor_auth", "2FA配置表")] + [SugarIndex("ux_2fa_employee_pk", nameof(EmployeePk), OrderByType.Asc, true)] + [SugarIndex("ux_2fa_administrator_pk", nameof(AdministratorPk), OrderByType.Asc, true)] + [SugarIndex("ux_2fa_customer_account_pk", nameof(CustomerAccountPk), OrderByType.Asc, true)] + public class TwoFactorAuth : BaseEntity + { + [SugarColumn(ColumnName = "id", IsIdentity = true, IsPrimaryKey = true, IsNullable = false, ColumnDescription = "索引ID")] + public int Id { get; set; } + + [SugarColumn(ColumnName = "employee_pk", IsNullable = true, ColumnDescription = "员工表主键ID(FK->employee.id)")] + public int? EmployeePk { get; set; } + + [SugarColumn(ColumnName = "administrator_pk", IsNullable = true, ColumnDescription = "管理员表主键ID(FK->administrator.id)")] + public int? AdministratorPk { get; set; } + + [SugarColumn(ColumnName = "customer_account_pk", IsNullable = true, ColumnDescription = "客户账号表主键ID(FK->customer_account.id)")] + public int? CustomerAccountPk { get; set; } + + [SugarColumn(ColumnName = "secret_key", Length = 512, IsNullable = true, ColumnDescription = "2FA密钥(加密存储)")] + public string? SecretKey { get; set; } + + [SugarColumn(ColumnName = "is_enabled", IsNullable = false, DefaultValue = "0", ColumnDescription = "是否启用2FA(0:否,1:是)")] + public int IsEnabled { get; set; } = 0; + + [SugarColumn(ColumnName = "enabled_at", IsNullable = true, ColumnDescription = "启用时间")] + public DateTime? EnabledAt { get; set; } + + [SugarColumn(ColumnName = "last_verified_at", IsNullable = true, ColumnDescription = "最近一次验证时间")] + public DateTime? LastVerifiedAt { get; set; } + + [SugarColumn(ColumnName = "last_validated_counter", IsNullable = true, ColumnDescription = "last accepted TOTP counter")] + public long? LastValidatedCounter { get; set; } + } +} diff --git a/EOM.TSHotelManagement.Domain/SystemManagement/TwoFactorRecoveryCode.cs b/EOM.TSHotelManagement.Domain/SystemManagement/TwoFactorRecoveryCode.cs new file mode 100644 index 0000000000000000000000000000000000000000..108125ac2cde198de047cf1728a83f7b53ce9978 --- /dev/null +++ b/EOM.TSHotelManagement.Domain/SystemManagement/TwoFactorRecoveryCode.cs @@ -0,0 +1,28 @@ +using SqlSugar; + +namespace EOM.TSHotelManagement.Domain +{ + [SugarTable("two_factor_recovery_code", "2FA recovery codes")] + [SugarIndex("idx_2fa_recovery_auth", nameof(TwoFactorAuthPk), OrderByType.Asc)] + [SugarIndex("idx_2fa_recovery_used", nameof(IsUsed), OrderByType.Asc)] + public class TwoFactorRecoveryCode : BaseEntity + { + [SugarColumn(ColumnName = "id", IsIdentity = true, IsPrimaryKey = true, IsNullable = false, ColumnDescription = "Primary key")] + public int Id { get; set; } + + [SugarColumn(ColumnName = "two_factor_auth_pk", IsNullable = false, ColumnDescription = "FK->two_factor_auth.id")] + public int TwoFactorAuthPk { get; set; } + + [SugarColumn(ColumnName = "code_salt", Length = 64, IsNullable = false, ColumnDescription = "Recovery code salt")] + public string CodeSalt { get; set; } = string.Empty; + + [SugarColumn(ColumnName = "code_hash", Length = 128, IsNullable = false, ColumnDescription = "Recovery code hash")] + public string CodeHash { get; set; } = string.Empty; + + [SugarColumn(ColumnName = "is_used", IsNullable = false, DefaultValue = "0", ColumnDescription = "Whether used")] + public int IsUsed { get; set; } = 0; + + [SugarColumn(ColumnName = "used_at", IsNullable = true, ColumnDescription = "Used time")] + public DateTime? UsedAt { get; set; } + } +} diff --git a/EOM.TSHotelManagement.Infrastructure/Config/TwoFactorConfig.cs b/EOM.TSHotelManagement.Infrastructure/Config/TwoFactorConfig.cs new file mode 100644 index 0000000000000000000000000000000000000000..02ea824c428597f864584d102c6341ec010d8f83 --- /dev/null +++ b/EOM.TSHotelManagement.Infrastructure/Config/TwoFactorConfig.cs @@ -0,0 +1,48 @@ +namespace EOM.TSHotelManagement.Infrastructure +{ + /// + /// 2FA(TOTP)配置 + /// + public class TwoFactorConfig + { + /// + /// 签发方名称(Authenticator 展示) + /// + public string Issuer { get; set; } = "TSHotel"; + + /// + /// 密钥字节长度 + /// + public int SecretSize { get; set; } = 20; + + /// + /// 验证码位数 + /// + public int CodeDigits { get; set; } = 6; + + /// + /// 时间步长(秒) + /// + public int TimeStepSeconds { get; set; } = 30; + + /// + /// 允许时间漂移窗口数 + /// + public int AllowedDriftWindows { get; set; } = 1; + + /// + /// 每次生成的恢复备用码数量 + /// + public int RecoveryCodeCount { get; set; } = 8; + + /// + /// 单个恢复备用码字符长度(不含分隔符) + /// + public int RecoveryCodeLength { get; set; } = 10; + + /// + /// 恢复备用码分组长度(用于展示格式,如 5-5) + /// + public int RecoveryCodeGroupSize { get; set; } = 5; + } +} diff --git a/EOM.TSHotelManagement.Infrastructure/Factory/TwoFactorConfigFactory.cs b/EOM.TSHotelManagement.Infrastructure/Factory/TwoFactorConfigFactory.cs new file mode 100644 index 0000000000000000000000000000000000000000..7014471ed2a8a63808425ee4ecc606b6b0856f75 --- /dev/null +++ b/EOM.TSHotelManagement.Infrastructure/Factory/TwoFactorConfigFactory.cs @@ -0,0 +1,40 @@ +using Microsoft.Extensions.Configuration; + +namespace EOM.TSHotelManagement.Infrastructure +{ + /// + /// 2FA 配置工厂 + /// + public class TwoFactorConfigFactory + { + private readonly IConfiguration _configuration; + + /// + /// 构造函数 + /// + /// + public TwoFactorConfigFactory(IConfiguration configuration) + { + _configuration = configuration; + } + + /// + /// 读取 2FA 配置 + /// + /// + public TwoFactorConfig GetTwoFactorConfig() + { + return new TwoFactorConfig + { + Issuer = _configuration.GetSection("TwoFactor").GetValue("Issuer") ?? "TSHotel", + SecretSize = _configuration.GetSection("TwoFactor").GetValue("SecretSize") ?? 20, + CodeDigits = _configuration.GetSection("TwoFactor").GetValue("CodeDigits") ?? 6, + TimeStepSeconds = _configuration.GetSection("TwoFactor").GetValue("TimeStepSeconds") ?? 30, + AllowedDriftWindows = _configuration.GetSection("TwoFactor").GetValue("AllowedDriftWindows") ?? 1, + RecoveryCodeCount = _configuration.GetSection("TwoFactor").GetValue("RecoveryCodeCount") ?? 8, + RecoveryCodeLength = _configuration.GetSection("TwoFactor").GetValue("RecoveryCodeLength") ?? 10, + RecoveryCodeGroupSize = _configuration.GetSection("TwoFactor").GetValue("RecoveryCodeGroupSize") ?? 5 + }; + } + } +} diff --git a/EOM.TSHotelManagement.Migration/EntityBuilder.cs b/EOM.TSHotelManagement.Migration/EntityBuilder.cs index 19fcea0ea6dc07d579a1abf27509fc46a702de43..58c50668d06128608732c6ccf558f9635e3a00e1 100644 --- a/EOM.TSHotelManagement.Migration/EntityBuilder.cs +++ b/EOM.TSHotelManagement.Migration/EntityBuilder.cs @@ -4,9 +4,9 @@ namespace EOM.TSHotelManagement.Migration { public class EntityBuilder { - public EntityBuilder(string? initialAdminEncryptedPassword = null) + public EntityBuilder(string? initialAdminEncryptedPassword = null, string? initialEmployeeEncryptedPassword = null) { - if (string.IsNullOrWhiteSpace(initialAdminEncryptedPassword)) + if (string.IsNullOrWhiteSpace(initialAdminEncryptedPassword) || string.IsNullOrWhiteSpace(initialEmployeeEncryptedPassword)) { return; } @@ -19,6 +19,15 @@ namespace EOM.TSHotelManagement.Migration { admin.Password = initialAdminEncryptedPassword; } + + var employee = entityDatas + .OfType() + .FirstOrDefault(a => string.Equals(a.EmployeeId, "WK010", StringComparison.OrdinalIgnoreCase)); + + if (employee != null) + { + employee.Password = initialEmployeeEncryptedPassword; + } } private readonly Type[] entityTypes = @@ -61,7 +70,9 @@ namespace EOM.TSHotelManagement.Migration typeof(VipLevelRule), typeof(RequestLog), typeof(News), - typeof(Permission) + typeof(Permission), + typeof(TwoFactorAuth), + typeof(TwoFactorRecoveryCode) }; private readonly List entityDatas = new() @@ -585,7 +596,7 @@ namespace EOM.TSHotelManagement.Migration EmployeeId = "WK010", EmployeeName = "阿杰", DateOfBirth = DateOnly.FromDateTime(new DateTime(1999,7,20,0,0,0)), - Password="oi6+T4604MqlB/SWAvrJBQ==·?bdc^^ private readonly JWTHelper jWTHelper; + /// + /// 2FA服务 + /// + private readonly ITwoFactorAuthService twoFactorAuthService; + + /// + /// 邮件助手 + /// + private readonly MailHelper mailHelper; + + /// + /// 日志 + /// + private readonly ILogger logger; private readonly IHttpContextAccessor _httpContextAccessor; @@ -55,9 +71,7 @@ namespace EOM.TSHotelManagement.Service /// /// /// - /// - /// - public CustomerAccountService(GenericRepository customerAccountRepository, GenericRepository customerRepository, GenericRepository roleRepository, GenericRepository userRoleRepository, DataProtectionHelper dataProtector, JWTHelper jWTHelper, IHttpContextAccessor httpContextAccessor, Regex accountRegex, Regex passwordRegex) + public CustomerAccountService(GenericRepository customerAccountRepository, GenericRepository customerRepository, GenericRepository roleRepository, GenericRepository userRoleRepository, DataProtectionHelper dataProtector, JWTHelper jWTHelper, ITwoFactorAuthService twoFactorAuthService, MailHelper mailHelper, IHttpContextAccessor httpContextAccessor, ILogger logger) { this.customerAccountRepository = customerAccountRepository; this.customerRepository = customerRepository; @@ -65,9 +79,10 @@ namespace EOM.TSHotelManagement.Service this.userRoleRepository = userRoleRepository; this.dataProtector = dataProtector; this.jWTHelper = jWTHelper; + this.twoFactorAuthService = twoFactorAuthService; + this.mailHelper = mailHelper; _httpContextAccessor = httpContextAccessor; - AccountRegex = accountRegex; - PasswordRegex = passwordRegex; + this.logger = logger; } /// @@ -87,6 +102,41 @@ namespace EOM.TSHotelManagement.Service if (!dataProtector.CompareCustomerData(customerAccount.Password, readCustomerAccountInputDto.Password)) return new SingleOutputDto() { Code = BusinessStatusCode.Unauthorized, Message = LocalizationHelper.GetLocalizedString("Invalid account or password", "账号或密码错误"), Data = new ReadCustomerAccountOutputDto() }; + var usedRecoveryCode = false; + if (twoFactorAuthService.RequiresTwoFactor(TwoFactorUserType.Customer, customerAccount.Id)) + { + if (string.IsNullOrWhiteSpace(readCustomerAccountInputDto.TwoFactorCode)) + { + return new SingleOutputDto() + { + Code = BusinessStatusCode.Unauthorized, + Message = LocalizationHelper.GetLocalizedString("2FA code is required", "需要输入2FA验证码"), + Data = new ReadCustomerAccountOutputDto + { + Account = customerAccount.Account, + Name = customerAccount.Name, + RequiresTwoFactor = true + } + }; + } + + var passed = twoFactorAuthService.VerifyLoginCode(TwoFactorUserType.Customer, customerAccount.Id, readCustomerAccountInputDto.TwoFactorCode, out usedRecoveryCode); + if (!passed) + { + return new SingleOutputDto() + { + Code = BusinessStatusCode.Unauthorized, + Message = LocalizationHelper.GetLocalizedString("Invalid 2FA code", "2FA验证码错误"), + Data = new ReadCustomerAccountOutputDto + { + Account = customerAccount.Account, + Name = customerAccount.Name, + RequiresTwoFactor = true + } + }; + } + } + var copyCustomerAccount = customerAccount; var context = _httpContextAccessor.HttpContext; @@ -106,6 +156,11 @@ namespace EOM.TSHotelManagement.Service new Claim(ClaimTypes.UserData, JsonSerializer.Serialize(customerAccount)), }), 10080); // 7天有效期 + if (usedRecoveryCode) + { + NotifyRecoveryCodeLoginByEmail(copyCustomerAccount.EmailAddress, copyCustomerAccount.Name, copyCustomerAccount.Account); + } + return new SingleOutputDto() { Code = BusinessStatusCode.Success, @@ -118,11 +173,64 @@ namespace EOM.TSHotelManagement.Service LastLoginIp = copyCustomerAccount.LastLoginIp, LastLoginTime = copyCustomerAccount.LastLoginTime, Status = copyCustomerAccount.Status, - UserToken = copyCustomerAccount.UserToken + UserToken = copyCustomerAccount.UserToken, + RequiresTwoFactor = false, + UsedRecoveryCodeLogin = usedRecoveryCode }, }; } + /// + /// 备用码登录后邮件通知(客户) + /// + /// 邮箱 + /// 客户名称 + /// 客户账号 + private void NotifyRecoveryCodeLoginByEmail(string? emailAddress, string? customerName, string? account) + { + if (string.IsNullOrWhiteSpace(emailAddress) || !MailAddress.TryCreate(emailAddress, out _)) + { + logger.LogWarning("Recovery-code login alert skipped for customer {Account}: invalid email.", account ?? string.Empty); + return; + } + + var recipient = emailAddress; + var name = customerName ?? "Customer"; + var identity = account ?? string.Empty; + _ = Task.Run(async () => + { + await SendCustomerRecoveryCodeAlertWithRetryAsync(recipient, name, identity); + }); + } + + private async Task SendCustomerRecoveryCodeAlertWithRetryAsync(string recipient, string customerName, string account) + { + const int maxAttempts = 3; + for (var attempt = 1; attempt <= maxAttempts; attempt++) + { + try + { + var template = EmailTemplate.GetTwoFactorRecoveryCodeLoginAlertTemplate(customerName, account, DateTime.Now); + var sent = mailHelper.SendMail(new List { recipient }, template.Subject, template.Body); + if (sent) + { + return; + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Recovery-code alert send failed for customer {Account}, attempt {Attempt}.", account, attempt); + } + + if (attempt < maxAttempts) + { + await Task.Delay(TimeSpan.FromSeconds(attempt)); + } + } + + logger.LogWarning("Recovery-code alert send exhausted retries for customer {Account}.", account); + } + /// /// 注册 /// @@ -245,12 +353,66 @@ namespace EOM.TSHotelManagement.Service LastLoginIp = customerAccount.LastLoginIp, LastLoginTime = customerAccount.LastLoginTime, Status = customerAccount.Status, - UserToken = customerAccount.UserToken + UserToken = customerAccount.UserToken, + RequiresTwoFactor = false }, }; } } + /// + /// 获取客户账号 2FA 状态 + /// + /// 客户编号(JWT SerialNumber) + /// + public SingleOutputDto GetTwoFactorStatus(string customerSerialNumber) + { + return twoFactorAuthService.GetStatus(TwoFactorUserType.Customer, customerSerialNumber); + } + + /// + /// 生成客户账号 2FA 绑定信息 + /// + /// 客户编号(JWT SerialNumber) + /// + public SingleOutputDto GenerateTwoFactorSetup(string customerSerialNumber) + { + return twoFactorAuthService.GenerateSetup(TwoFactorUserType.Customer, customerSerialNumber); + } + + /// + /// 启用客户账号 2FA + /// + /// 客户编号(JWT SerialNumber) + /// 验证码输入 + /// + public SingleOutputDto EnableTwoFactor(string customerSerialNumber, TwoFactorCodeInputDto inputDto) + { + return twoFactorAuthService.Enable(TwoFactorUserType.Customer, customerSerialNumber, inputDto?.VerificationCode ?? string.Empty); + } + + /// + /// 关闭客户账号 2FA + /// + /// 客户编号(JWT SerialNumber) + /// 验证码输入 + /// + public BaseResponse DisableTwoFactor(string customerSerialNumber, TwoFactorCodeInputDto inputDto) + { + return twoFactorAuthService.Disable(TwoFactorUserType.Customer, customerSerialNumber, inputDto?.VerificationCode ?? string.Empty); + } + + /// + /// 重置客户账号恢复备用码 + /// + /// 客户编号(JWT SerialNumber) + /// 验证码或恢复备用码输入 + /// + public SingleOutputDto RegenerateTwoFactorRecoveryCodes(string customerSerialNumber, TwoFactorCodeInputDto inputDto) + { + return twoFactorAuthService.RegenerateRecoveryCodes(TwoFactorUserType.Customer, customerSerialNumber, inputDto?.VerificationCode ?? string.Empty); + } + private readonly Regex AccountRegex = new Regex(@"^[a-zA-Z0-9_]+$", RegexOptions.Compiled); private readonly Regex PasswordRegex = new Regex(@"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d]{8,}$", RegexOptions.Compiled); diff --git a/EOM.TSHotelManagement.Service/Business/Customer/Account/ICustomerAccountService.cs b/EOM.TSHotelManagement.Service/Business/Customer/Account/ICustomerAccountService.cs index 107d876f482757d925b6b51bda1cf97e77c8ba94..eee0de87b7f5add4e38207adce3dc92a884fec55 100644 --- a/EOM.TSHotelManagement.Service/Business/Customer/Account/ICustomerAccountService.cs +++ b/EOM.TSHotelManagement.Service/Business/Customer/Account/ICustomerAccountService.cs @@ -17,5 +17,43 @@ namespace EOM.TSHotelManagement.Service /// /// SingleOutputDto Register(ReadCustomerAccountInputDto readCustomerAccountInputDto); + + /// + /// 获取客户账号的 2FA 状态 + /// + /// 客户编号(JWT SerialNumber) + /// + SingleOutputDto GetTwoFactorStatus(string customerSerialNumber); + + /// + /// 生成客户账号的 2FA 绑定信息 + /// + /// 客户编号(JWT SerialNumber) + /// + SingleOutputDto GenerateTwoFactorSetup(string customerSerialNumber); + + /// + /// 启用客户账号 2FA + /// + /// 客户编号(JWT SerialNumber) + /// 验证码输入 + /// + SingleOutputDto EnableTwoFactor(string customerSerialNumber, TwoFactorCodeInputDto inputDto); + + /// + /// 关闭客户账号 2FA + /// + /// 客户编号(JWT SerialNumber) + /// 验证码输入 + /// + BaseResponse DisableTwoFactor(string customerSerialNumber, TwoFactorCodeInputDto inputDto); + + /// + /// 重置客户账号恢复备用码 + /// + /// 客户编号(JWT SerialNumber) + /// 验证码或恢复备用码输入 + /// + SingleOutputDto RegenerateTwoFactorRecoveryCodes(string customerSerialNumber, TwoFactorCodeInputDto inputDto); } } diff --git a/EOM.TSHotelManagement.Service/Employee/EmployeeService.cs b/EOM.TSHotelManagement.Service/Employee/EmployeeService.cs index 987d7ea1c8067cf8f0740d33d38bba5a5357f3f9..6355b7f3a09a6f71bb19b83d492597e65878686c 100644 --- a/EOM.TSHotelManagement.Service/Employee/EmployeeService.cs +++ b/EOM.TSHotelManagement.Service/Employee/EmployeeService.cs @@ -28,6 +28,7 @@ using EOM.TSHotelManagement.Domain; using jvncorelib.EntityLib; using Microsoft.Extensions.Logging; using SqlSugar; +using System.Net.Mail; using System.Security.Claims; namespace EOM.TSHotelManagement.Service @@ -48,7 +49,7 @@ namespace EOM.TSHotelManagement.Service /// /// /// - public class EmployeeService(GenericRepository workerRepository, GenericRepository photoRepository, GenericRepository educationRepository, GenericRepository nationRepository, GenericRepository deptRepository, GenericRepository positionRepository, GenericRepository passportTypeRepository, JWTHelper jWTHelper, MailHelper mailHelper, DataProtectionHelper dataProtectionHelper, ILogger logger) : IEmployeeService + public class EmployeeService(GenericRepository workerRepository, GenericRepository photoRepository, GenericRepository educationRepository, GenericRepository nationRepository, GenericRepository deptRepository, GenericRepository positionRepository, GenericRepository passportTypeRepository, JWTHelper jWTHelper, MailHelper mailHelper, DataProtectionHelper dataProtectionHelper, ITwoFactorAuthService twoFactorAuthService, ILogger logger) : IEmployeeService { /// /// 员工信息 @@ -100,6 +101,11 @@ namespace EOM.TSHotelManagement.Service /// private readonly MailHelper mailHelper = mailHelper; + /// + /// 2FA服务 + /// + private readonly ITwoFactorAuthService twoFactorAuthService = twoFactorAuthService; + private readonly ILogger logger = logger; /// @@ -439,14 +445,50 @@ namespace EOM.TSHotelManagement.Service if (w == null) { w = null; - return new SingleOutputDto { Data = null, Message = LocalizationHelper.GetLocalizedString("Employee does not exist or entered incorrectly", "员工不存在或输入有误") }; + return new SingleOutputDto { Code = BusinessStatusCode.BadRequest, Data = null, Message = LocalizationHelper.GetLocalizedString("Employee does not exist or entered incorrectly", "员工不存在或输入有误") }; } var correctPassword = dataProtector.CompareEmployeeData(w.Password, employeeLoginDto.Password); if (!correctPassword) { - return new SingleOutputDto { Data = null, Message = LocalizationHelper.GetLocalizedString("Invalid account or password", "账号或密码错误") }; + return new SingleOutputDto { Code = BusinessStatusCode.BadRequest, Data = null, Message = LocalizationHelper.GetLocalizedString("Invalid account or password", "账号或密码错误") }; + } + + var usedRecoveryCode = false; + if (twoFactorAuthService.RequiresTwoFactor(TwoFactorUserType.Employee, w.Id)) + { + if (string.IsNullOrWhiteSpace(employeeLoginDto.TwoFactorCode)) + { + return new SingleOutputDto + { + Code = BusinessStatusCode.Unauthorized, + Message = LocalizationHelper.GetLocalizedString("2FA code is required", "需要输入2FA验证码"), + Data = new ReadEmployeeOutputDto + { + EmployeeId = w.EmployeeId, + EmployeeName = w.EmployeeName, + RequiresTwoFactor = true + } + }; + } + + var passed = twoFactorAuthService.VerifyLoginCode(TwoFactorUserType.Employee, w.Id, employeeLoginDto.TwoFactorCode, out usedRecoveryCode); + if (!passed) + { + return new SingleOutputDto + { + Code = BusinessStatusCode.Unauthorized, + Message = LocalizationHelper.GetLocalizedString("Invalid 2FA code", "2FA验证码错误"), + Data = new ReadEmployeeOutputDto + { + EmployeeId = w.EmployeeId, + EmployeeName = w.EmployeeName, + RequiresTwoFactor = true + } + }; + } } + w.Password = ""; //性别类型 var sexType = genders.SingleOrDefault(a => a.Id == w.Gender); @@ -469,7 +511,65 @@ namespace EOM.TSHotelManagement.Service new Claim(ClaimTypes.Name, w.EmployeeName), new Claim(ClaimTypes.SerialNumber, w.EmployeeId) })); - return new SingleOutputDto { Data = EntityMapper.Map(w) }; + var output = EntityMapper.Map(w); + output.RequiresTwoFactor = false; + output.UsedRecoveryCodeLogin = usedRecoveryCode; + if (usedRecoveryCode) + { + NotifyRecoveryCodeLoginByEmail(w.EmailAddress, w.EmployeeName, w.EmployeeId); + } + return new SingleOutputDto { Data = output }; + } + + /// + /// 备用码登录后邮件通知(员工) + /// + /// 邮箱 + /// 员工姓名 + /// 员工工号 + private void NotifyRecoveryCodeLoginByEmail(string? emailAddress, string? employeeName, string? employeeId) + { + if (string.IsNullOrWhiteSpace(emailAddress) || !MailAddress.TryCreate(emailAddress, out _)) + { + logger.LogWarning("Recovery-code login alert skipped for employee {EmployeeId}: invalid email.", employeeId ?? string.Empty); + return; + } + + var recipient = emailAddress; + var name = employeeName ?? "Employee"; + var identity = employeeId ?? string.Empty; + _ = Task.Run(async () => + { + await SendEmployeeRecoveryCodeAlertWithRetryAsync(recipient, name, identity); + }); + } + + private async Task SendEmployeeRecoveryCodeAlertWithRetryAsync(string recipient, string employeeName, string employeeId) + { + const int maxAttempts = 3; + for (var attempt = 1; attempt <= maxAttempts; attempt++) + { + try + { + var template = EmailTemplate.GetTwoFactorRecoveryCodeLoginAlertTemplate(employeeName, employeeId, DateTime.Now); + var sent = mailHelper.SendMail(new List { recipient }, template.Subject, template.Body); + if (sent) + { + return; + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Recovery-code alert send failed for employee {EmployeeId}, attempt {Attempt}.", employeeId, attempt); + } + + if (attempt < maxAttempts) + { + await Task.Delay(TimeSpan.FromSeconds(attempt)); + } + } + + logger.LogWarning("Recovery-code alert send exhausted retries for employee {EmployeeId}.", employeeId); } /// @@ -582,5 +682,58 @@ namespace EOM.TSHotelManagement.Service return new BaseResponse(); } + + /// + /// 获取员工账号 2FA 状态 + /// + /// 员工工号(JWT SerialNumber) + /// + public SingleOutputDto GetTwoFactorStatus(string employeeSerialNumber) + { + return twoFactorAuthService.GetStatus(TwoFactorUserType.Employee, employeeSerialNumber); + } + + /// + /// 生成员工账号 2FA 绑定信息 + /// + /// 员工工号(JWT SerialNumber) + /// + public SingleOutputDto GenerateTwoFactorSetup(string employeeSerialNumber) + { + return twoFactorAuthService.GenerateSetup(TwoFactorUserType.Employee, employeeSerialNumber); + } + + /// + /// 启用员工账号 2FA + /// + /// 员工工号(JWT SerialNumber) + /// 验证码输入 + /// + public SingleOutputDto EnableTwoFactor(string employeeSerialNumber, TwoFactorCodeInputDto inputDto) + { + return twoFactorAuthService.Enable(TwoFactorUserType.Employee, employeeSerialNumber, inputDto?.VerificationCode ?? string.Empty); + } + + /// + /// 关闭员工账号 2FA + /// + /// 员工工号(JWT SerialNumber) + /// 验证码输入 + /// + public BaseResponse DisableTwoFactor(string employeeSerialNumber, TwoFactorCodeInputDto inputDto) + { + return twoFactorAuthService.Disable(TwoFactorUserType.Employee, employeeSerialNumber, inputDto?.VerificationCode ?? string.Empty); + } + + /// + /// 重置员工账号恢复备用码 + /// + /// 员工工号(JWT SerialNumber) + /// 验证码或恢复备用码输入 + /// + public SingleOutputDto RegenerateTwoFactorRecoveryCodes(string employeeSerialNumber, TwoFactorCodeInputDto inputDto) + { + return twoFactorAuthService.RegenerateRecoveryCodes(TwoFactorUserType.Employee, employeeSerialNumber, inputDto?.VerificationCode ?? string.Empty); + } } } diff --git a/EOM.TSHotelManagement.Service/Employee/IEmployeeService.cs b/EOM.TSHotelManagement.Service/Employee/IEmployeeService.cs index 0caeb10d10326c4604ba580d1c686b3affa47c12..1069f906997c2ba00e65e8091f3a21648331692e 100644 --- a/EOM.TSHotelManagement.Service/Employee/IEmployeeService.cs +++ b/EOM.TSHotelManagement.Service/Employee/IEmployeeService.cs @@ -84,5 +84,43 @@ namespace EOM.TSHotelManagement.Service /// /// BaseResponse ResetEmployeeAccountPassword(UpdateEmployeeInputDto updateEmployeeInputDto); + + /// + /// 获取员工账号的 2FA 状态 + /// + /// 员工工号(JWT SerialNumber) + /// + SingleOutputDto GetTwoFactorStatus(string employeeSerialNumber); + + /// + /// 生成员工账号的 2FA 绑定信息 + /// + /// 员工工号(JWT SerialNumber) + /// + SingleOutputDto GenerateTwoFactorSetup(string employeeSerialNumber); + + /// + /// 启用员工账号 2FA + /// + /// 员工工号(JWT SerialNumber) + /// 验证码输入 + /// + SingleOutputDto EnableTwoFactor(string employeeSerialNumber, TwoFactorCodeInputDto inputDto); + + /// + /// 关闭员工账号 2FA + /// + /// 员工工号(JWT SerialNumber) + /// 验证码输入 + /// + BaseResponse DisableTwoFactor(string employeeSerialNumber, TwoFactorCodeInputDto inputDto); + + /// + /// 重置员工账号恢复备用码 + /// + /// 员工工号(JWT SerialNumber) + /// 验证码或恢复备用码输入 + /// + SingleOutputDto RegenerateTwoFactorRecoveryCodes(string employeeSerialNumber, TwoFactorCodeInputDto inputDto); } -} \ No newline at end of file +} diff --git a/EOM.TSHotelManagement.Service/Security/ITwoFactorAuthService.cs b/EOM.TSHotelManagement.Service/Security/ITwoFactorAuthService.cs new file mode 100644 index 0000000000000000000000000000000000000000..bebd17706fe383b73eb82424cea400ce209cb31a --- /dev/null +++ b/EOM.TSHotelManagement.Service/Security/ITwoFactorAuthService.cs @@ -0,0 +1,72 @@ +using EOM.TSHotelManagement.Common; +using EOM.TSHotelManagement.Contract; + +namespace EOM.TSHotelManagement.Service +{ + /// + /// 统一 2FA 业务接口 + /// + public interface ITwoFactorAuthService + { + /// + /// 判断账号是否已启用 2FA + /// + /// 账号类型 + /// 账号主键ID + /// 是否需要 2FA 校验 + bool RequiresTwoFactor(TwoFactorUserType userType, int userPrimaryKey); + + /// + /// 校验登录场景的 2FA 验证码(支持 TOTP 或恢复备用码) + /// + /// 账号类型 + /// 账号主键ID + /// 验证码或恢复备用码 + /// 是否使用了恢复备用码 + /// 是否校验通过 + bool VerifyLoginCode(TwoFactorUserType userType, int userPrimaryKey, string? code, out bool usedRecoveryCode); + + /// + /// 获取当前账号的 2FA 状态 + /// + /// 账号类型 + /// 账号业务编号(JWT SerialNumber) + /// 2FA 状态 + SingleOutputDto GetStatus(TwoFactorUserType userType, string serialNumber); + + /// + /// 生成 2FA 绑定信息(密钥与 otpauth URI) + /// + /// 账号类型 + /// 账号业务编号(JWT SerialNumber) + /// 绑定信息 + SingleOutputDto GenerateSetup(TwoFactorUserType userType, string serialNumber); + + /// + /// 启用 2FA + /// + /// 账号类型 + /// 账号业务编号(JWT SerialNumber) + /// 验证码 + /// 操作结果与首批恢复备用码 + SingleOutputDto Enable(TwoFactorUserType userType, string serialNumber, string verificationCode); + + /// + /// 关闭 2FA + /// + /// 账号类型 + /// 账号业务编号(JWT SerialNumber) + /// 验证码或恢复备用码 + /// 操作结果 + BaseResponse Disable(TwoFactorUserType userType, string serialNumber, string verificationCode); + + /// + /// 重置恢复备用码(会使旧备用码全部失效) + /// + /// 账号类型 + /// 账号业务编号(JWT SerialNumber) + /// 验证码或恢复备用码 + /// 新恢复备用码 + SingleOutputDto RegenerateRecoveryCodes(TwoFactorUserType userType, string serialNumber, string verificationCode); + } +} diff --git a/EOM.TSHotelManagement.Service/Security/TwoFactorAuthService.cs b/EOM.TSHotelManagement.Service/Security/TwoFactorAuthService.cs new file mode 100644 index 0000000000000000000000000000000000000000..9d504e5034eeaf9cd65618d5d4b27b527b2c77aa --- /dev/null +++ b/EOM.TSHotelManagement.Service/Security/TwoFactorAuthService.cs @@ -0,0 +1,700 @@ +using EOM.TSHotelManagement.Common; +using EOM.TSHotelManagement.Contract; +using EOM.TSHotelManagement.Data; +using EOM.TSHotelManagement.Domain; +using Microsoft.Extensions.Logging; + +namespace EOM.TSHotelManagement.Service +{ + /// + /// 2FA(TOTP)统一服务实现 + /// + public class TwoFactorAuthService : ITwoFactorAuthService + { + private readonly GenericRepository _twoFactorRepository; + private readonly GenericRepository _recoveryCodeRepository; + private readonly GenericRepository _employeeRepository; + private readonly GenericRepository _administratorRepository; + private readonly GenericRepository _customerAccountRepository; + private readonly DataProtectionHelper _dataProtectionHelper; + private readonly TwoFactorHelper _twoFactorHelper; + private readonly ILogger _logger; + + /// + /// 构造函数 + /// + public TwoFactorAuthService( + GenericRepository twoFactorRepository, + GenericRepository recoveryCodeRepository, + GenericRepository employeeRepository, + GenericRepository administratorRepository, + GenericRepository customerAccountRepository, + DataProtectionHelper dataProtectionHelper, + TwoFactorHelper twoFactorHelper, + ILogger logger) + { + _twoFactorRepository = twoFactorRepository; + _recoveryCodeRepository = recoveryCodeRepository; + _employeeRepository = employeeRepository; + _administratorRepository = administratorRepository; + _customerAccountRepository = customerAccountRepository; + _dataProtectionHelper = dataProtectionHelper; + _twoFactorHelper = twoFactorHelper; + _logger = logger; + } + + /// + /// 判断指定账号是否启用了 2FA + /// + /// 账号类型 + /// 账号主键ID + /// + public bool RequiresTwoFactor(TwoFactorUserType userType, int userPrimaryKey) + { + var auth = GetByUserPrimaryKey(userType, userPrimaryKey); + return auth != null + && auth.IsDelete != 1 + && auth.IsEnabled == 1 + && !string.IsNullOrWhiteSpace(auth.SecretKey); + } + + /// + /// 校验登录 2FA 验证码(支持 TOTP 或恢复备用码) + /// + /// 账号类型 + /// 账号主键ID + /// 验证码或恢复备用码 + /// 是否使用了恢复备用码 + /// + public bool VerifyLoginCode(TwoFactorUserType userType, int userPrimaryKey, string? code, out bool usedRecoveryCode) + { + usedRecoveryCode = false; + + if (string.IsNullOrWhiteSpace(code)) + return false; + + var auth = GetByUserPrimaryKey(userType, userPrimaryKey); + if (auth == null || auth.IsDelete == 1 || auth.IsEnabled != 1 || string.IsNullOrWhiteSpace(auth.SecretKey)) + return false; + + if (TryVerifyTotp(auth, code, out var validatedCounter)) + { + return TryMarkTotpValidated(auth, validatedCounter); + } + + if (!TryConsumeRecoveryCode(auth.Id, code)) + { + return false; + } + + usedRecoveryCode = true; + return TouchLastVerifiedAt(auth.Id); + } + + /// + /// 获取账号 2FA 状态 + /// + /// 账号类型 + /// 账号业务编号(JWT SerialNumber) + /// + public SingleOutputDto GetStatus(TwoFactorUserType userType, string serialNumber) + { + var resolved = ResolveUser(userType, serialNumber); + if (resolved == null) + { + return new SingleOutputDto + { + Code = BusinessStatusCode.NotFound, + Message = LocalizationHelper.GetLocalizedString("User not found", "用户不存在"), + Data = null + }; + } + + var auth = GetByUserPrimaryKey(userType, resolved.UserPrimaryKey); + return new SingleOutputDto + { + Code = BusinessStatusCode.Success, + Message = LocalizationHelper.GetLocalizedString("Query success", "查询成功"), + Data = new TwoFactorStatusOutputDto + { + IsEnabled = auth?.IsEnabled == 1, + EnabledAt = auth?.EnabledAt, + LastVerifiedAt = auth?.LastVerifiedAt, + RemainingRecoveryCodes = auth == null ? 0 : GetRemainingRecoveryCodeCount(auth.Id) + } + }; + } + + /// + /// 生成账号 2FA 绑定信息(密钥与 otpauth URI) + /// + /// 账号类型 + /// 账号业务编号(JWT SerialNumber) + /// + public SingleOutputDto GenerateSetup(TwoFactorUserType userType, string serialNumber) + { + try + { + var resolved = ResolveUser(userType, serialNumber); + if (resolved == null) + { + return new SingleOutputDto + { + Code = BusinessStatusCode.NotFound, + Message = LocalizationHelper.GetLocalizedString("User not found", "用户不存在"), + Data = null + }; + } + + var secret = _twoFactorHelper.GenerateSecretKey(); + var encryptedSecret = _dataProtectionHelper.EncryptTwoFactorData(secret); + var auth = GetByUserPrimaryKey(userType, resolved.UserPrimaryKey); + + if (auth == null) + { + auth = new TwoFactorAuth + { + IsEnabled = 0, + SecretKey = encryptedSecret, + EnabledAt = null, + LastVerifiedAt = null, + LastValidatedCounter = null + }; + AttachUserForeignKey(auth, userType, resolved.UserPrimaryKey); + _twoFactorRepository.Insert(auth); + auth = GetByUserPrimaryKey(userType, resolved.UserPrimaryKey); + } + else + { + auth.SecretKey = encryptedSecret; + auth.IsEnabled = 0; + auth.EnabledAt = null; + auth.LastVerifiedAt = null; + auth.LastValidatedCounter = null; + _twoFactorRepository.Update(auth); + } + + if (auth != null) + { + ClearRecoveryCodes(auth.Id); + } + + return new SingleOutputDto + { + Code = BusinessStatusCode.Success, + Message = LocalizationHelper.GetLocalizedString("2FA setup created", "2FA绑定信息已生成"), + Data = new TwoFactorSetupOutputDto + { + IsEnabled = false, + AccountName = resolved.AccountName, + ManualEntryKey = secret, + OtpAuthUri = _twoFactorHelper.BuildOtpAuthUri(resolved.AccountName, secret), + CodeDigits = _twoFactorHelper.GetCodeDigits(), + TimeStepSeconds = _twoFactorHelper.GetTimeStepSeconds() + } + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "GenerateSetup failed for {UserType}-{SerialNumber}", userType, serialNumber); + return new SingleOutputDto + { + Code = BusinessStatusCode.InternalServerError, + Message = LocalizationHelper.GetLocalizedString("2FA setup failed", "2FA绑定信息生成失败"), + Data = null + }; + } + } + + /// + /// 启用账号 2FA + /// + /// 账号类型 + /// 账号业务编号(JWT SerialNumber) + /// 验证码 + /// + public SingleOutputDto Enable(TwoFactorUserType userType, string serialNumber, string verificationCode) + { + try + { + if (string.IsNullOrWhiteSpace(verificationCode)) + { + return new SingleOutputDto + { + Code = BusinessStatusCode.BadRequest, + Message = LocalizationHelper.GetLocalizedString("Verification code is required", "验证码不能为空"), + Data = null + }; + } + + var resolved = ResolveUser(userType, serialNumber); + if (resolved == null) + { + return new SingleOutputDto + { + Code = BusinessStatusCode.NotFound, + Message = LocalizationHelper.GetLocalizedString("User not found", "用户不存在"), + Data = null + }; + } + + var auth = GetByUserPrimaryKey(userType, resolved.UserPrimaryKey); + if (auth == null || string.IsNullOrWhiteSpace(auth.SecretKey)) + { + return new SingleOutputDto + { + Code = BusinessStatusCode.BadRequest, + Message = LocalizationHelper.GetLocalizedString("Please generate 2FA setup first", "请先生成2FA绑定信息"), + Data = null + }; + } + + if (!TryVerifyTotp(auth, verificationCode, out var validatedCounter)) + { + return new SingleOutputDto + { + Code = BusinessStatusCode.Unauthorized, + Message = LocalizationHelper.GetLocalizedString("Invalid 2FA code", "2FA验证码错误"), + Data = null + }; + } + + if (!TryMarkTotpValidated(auth, validatedCounter)) + { + return new SingleOutputDto + { + Code = BusinessStatusCode.Unauthorized, + Message = LocalizationHelper.GetLocalizedString("2FA code has already been used", "该2FA验证码已被使用,请等待下一个验证码。"), + Data = null + }; + } + + auth.IsEnabled = 1; + auth.EnabledAt = DateTime.Now; + if (!_twoFactorRepository.Update(auth)) + { + return new SingleOutputDto + { + Code = BusinessStatusCode.InternalServerError, + Message = LocalizationHelper.GetLocalizedString("Enable 2FA failed", "鍚敤2FA澶辫触"), + Data = null + }; + } + + // 启用时自动生成一组恢复备用码(仅保存哈希,明文只在本次响应返回) + var codes = ReplaceRecoveryCodes(auth.Id); + + return new SingleOutputDto + { + Code = BusinessStatusCode.Success, + Message = LocalizationHelper.GetLocalizedString("2FA enabled", "2FA已启用"), + Data = new TwoFactorRecoveryCodesOutputDto + { + RecoveryCodes = codes, + RemainingCount = codes.Count + } + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Enable 2FA failed for {UserType}-{SerialNumber}", userType, serialNumber); + return new SingleOutputDto + { + Code = BusinessStatusCode.InternalServerError, + Message = LocalizationHelper.GetLocalizedString("Enable 2FA failed", "启用2FA失败"), + Data = null + }; + } + } + + /// + /// 关闭账号 2FA + /// + /// 账号类型 + /// 账号业务编号(JWT SerialNumber) + /// 验证码或恢复备用码 + /// + public BaseResponse Disable(TwoFactorUserType userType, string serialNumber, string verificationCode) + { + try + { + if (string.IsNullOrWhiteSpace(verificationCode)) + { + return new BaseResponse(BusinessStatusCode.BadRequest, LocalizationHelper.GetLocalizedString("Verification code is required", "验证码不能为空")); + } + + var resolved = ResolveUser(userType, serialNumber); + if (resolved == null) + { + return new BaseResponse(BusinessStatusCode.NotFound, LocalizationHelper.GetLocalizedString("User not found", "用户不存在")); + } + + var auth = GetByUserPrimaryKey(userType, resolved.UserPrimaryKey); + if (auth == null || auth.IsEnabled != 1 || string.IsNullOrWhiteSpace(auth.SecretKey)) + { + return new BaseResponse(BusinessStatusCode.BadRequest, LocalizationHelper.GetLocalizedString("2FA is not enabled", "2FA未启用")); + } + + if (!VerifyTotpOrRecoveryCode(auth, verificationCode, allowRecoveryCode: true)) + { + return new BaseResponse(BusinessStatusCode.Unauthorized, LocalizationHelper.GetLocalizedString("Invalid 2FA code", "2FA验证码错误")); + } + + auth.IsEnabled = 0; + auth.SecretKey = null; + auth.EnabledAt = null; + auth.LastVerifiedAt = DateTime.Now; + auth.LastValidatedCounter = null; + if (!_twoFactorRepository.Update(auth)) + { + return new BaseResponse(BusinessStatusCode.InternalServerError, LocalizationHelper.GetLocalizedString("Disable 2FA failed", "鍏抽棴2FA澶辫触")); + } + + ClearRecoveryCodes(auth.Id); + + return new BaseResponse(BusinessStatusCode.Success, LocalizationHelper.GetLocalizedString("2FA disabled", "2FA已关闭")); + } + catch (Exception ex) + { + _logger.LogError(ex, "Disable 2FA failed for {UserType}-{SerialNumber}", userType, serialNumber); + return new BaseResponse(BusinessStatusCode.InternalServerError, LocalizationHelper.GetLocalizedString("Disable 2FA failed", "关闭2FA失败")); + } + } + + /// + /// 重置恢复备用码(会使旧备用码全部失效) + /// + /// 账号类型 + /// 账号业务编号(JWT SerialNumber) + /// 验证码或恢复备用码 + /// + public SingleOutputDto RegenerateRecoveryCodes(TwoFactorUserType userType, string serialNumber, string verificationCode) + { + try + { + if (string.IsNullOrWhiteSpace(verificationCode)) + { + return new SingleOutputDto + { + Code = BusinessStatusCode.BadRequest, + Message = LocalizationHelper.GetLocalizedString("Verification code is required", "验证码不能为空"), + Data = null + }; + } + + var resolved = ResolveUser(userType, serialNumber); + if (resolved == null) + { + return new SingleOutputDto + { + Code = BusinessStatusCode.NotFound, + Message = LocalizationHelper.GetLocalizedString("User not found", "用户不存在"), + Data = null + }; + } + + var auth = GetByUserPrimaryKey(userType, resolved.UserPrimaryKey); + if (auth == null || auth.IsEnabled != 1 || string.IsNullOrWhiteSpace(auth.SecretKey)) + { + return new SingleOutputDto + { + Code = BusinessStatusCode.BadRequest, + Message = LocalizationHelper.GetLocalizedString("2FA is not enabled", "2FA未启用"), + Data = null + }; + } + + if (!VerifyTotpOrRecoveryCode(auth, verificationCode, allowRecoveryCode: true)) + { + return new SingleOutputDto + { + Code = BusinessStatusCode.Unauthorized, + Message = LocalizationHelper.GetLocalizedString("Invalid 2FA code", "2FA验证码错误"), + Data = null + }; + } + + var codes = ReplaceRecoveryCodes(auth.Id); + if (!TouchLastVerifiedAt(auth.Id)) + { + return new SingleOutputDto + { + Code = BusinessStatusCode.InternalServerError, + Message = LocalizationHelper.GetLocalizedString("Recovery code regenerate failed", "备用码生成失败"), + Data = null + }; + } + + return new SingleOutputDto + { + Code = BusinessStatusCode.Success, + Message = LocalizationHelper.GetLocalizedString("Recovery codes regenerated", "恢复备用码已重置"), + Data = new TwoFactorRecoveryCodesOutputDto + { + RecoveryCodes = codes, + RemainingCount = codes.Count + } + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "RegenerateRecoveryCodes failed for {UserType}-{SerialNumber}", userType, serialNumber); + return new SingleOutputDto + { + Code = BusinessStatusCode.InternalServerError, + Message = LocalizationHelper.GetLocalizedString("Recovery code regenerate failed", "恢复备用码重置失败"), + Data = null + }; + } + } + + /// + /// 按账号主键查询 2FA 记录 + /// + /// 账号类型 + /// 账号主键ID + /// + private TwoFactorAuth? GetByUserPrimaryKey(TwoFactorUserType userType, int userPrimaryKey) + { + return userType switch + { + TwoFactorUserType.Employee => _twoFactorRepository.GetFirst(a => a.EmployeePk == userPrimaryKey && a.IsDelete != 1), + TwoFactorUserType.Administrator => _twoFactorRepository.GetFirst(a => a.AdministratorPk == userPrimaryKey && a.IsDelete != 1), + TwoFactorUserType.Customer => _twoFactorRepository.GetFirst(a => a.CustomerAccountPk == userPrimaryKey && a.IsDelete != 1), + _ => null + }; + } + + /// + /// 写入对应账号类型的外键字段 + /// + /// 2FA 实体 + /// 账号类型 + /// 账号主键ID + private static void AttachUserForeignKey(TwoFactorAuth auth, TwoFactorUserType userType, int userPrimaryKey) + { + switch (userType) + { + case TwoFactorUserType.Employee: + auth.EmployeePk = userPrimaryKey; + break; + case TwoFactorUserType.Administrator: + auth.AdministratorPk = userPrimaryKey; + break; + case TwoFactorUserType.Customer: + auth.CustomerAccountPk = userPrimaryKey; + break; + } + } + + /// + /// 校验 TOTP,失败后可回退校验恢复备用码(并一次性消费) + /// + /// + /// + /// + /// + private bool VerifyTotpOrRecoveryCode(TwoFactorAuth auth, string code, bool allowRecoveryCode) + { + if (string.IsNullOrWhiteSpace(auth.SecretKey) || string.IsNullOrWhiteSpace(code)) + { + return false; + } + + if (TryVerifyTotp(auth, code, out var validatedCounter)) + { + return TryMarkTotpValidated(auth, validatedCounter); + } + + return allowRecoveryCode && TryConsumeRecoveryCode(auth.Id, code); + } + + /// + /// 校验TOTP(不处理重放) + /// + private bool TryVerifyTotp(TwoFactorAuth auth, string code, out long validatedCounter) + { + validatedCounter = -1; + if (string.IsNullOrWhiteSpace(auth.SecretKey) || string.IsNullOrWhiteSpace(code)) + { + return false; + } + + var secret = _dataProtectionHelper.SafeDecryptTwoFactorData(auth.SecretKey); + return _twoFactorHelper.TryVerifyCode(secret, code, out validatedCounter); + } + + /// + /// TOTP防重放:拒绝重复或倒退counter + /// + private bool TryMarkTotpValidated(TwoFactorAuth auth, long validatedCounter) + { + var now = DateTime.Now; + var affected = _twoFactorRepository.Context + .Updateable() + .SetColumns(a => a.LastValidatedCounter == validatedCounter) + .SetColumns(a => a.LastVerifiedAt == now) + .Where(a => a.Id == auth.Id && a.IsDelete != 1) + .Where(a => a.LastValidatedCounter == null || a.LastValidatedCounter < validatedCounter) + .ExecuteCommand(); + + if (affected <= 0) + { + return false; + } + + auth.LastValidatedCounter = validatedCounter; + auth.LastVerifiedAt = now; + return true; + } + + private bool TouchLastVerifiedAt(int authId) + { + var now = DateTime.Now; + return _twoFactorRepository.Context + .Updateable() + .SetColumns(a => a.LastVerifiedAt == now) + .Where(a => a.Id == authId && a.IsDelete != 1) + .ExecuteCommand() > 0; + } + + /// + /// 获取剩余可用恢复备用码数量 + /// + /// + /// + private int GetRemainingRecoveryCodeCount(int twoFactorAuthId) + { + return _recoveryCodeRepository + .Count(a => a.TwoFactorAuthPk == twoFactorAuthId && a.IsDelete != 1 && a.IsUsed != 1); + } + + /// + /// 清理指定 2FA 的全部恢复备用码(硬删除) + /// + /// + private void ClearRecoveryCodes(int twoFactorAuthId) + { + _recoveryCodeRepository.Delete(a => a.TwoFactorAuthPk == twoFactorAuthId); + } + + /// + /// 重新生成恢复备用码(会清理旧数据) + /// + /// + /// 新备用码明文(仅返回一次) + private List ReplaceRecoveryCodes(int twoFactorAuthId) + { + ClearRecoveryCodes(twoFactorAuthId); + + var plainCodes = _twoFactorHelper.GenerateRecoveryCodes(); + foreach (var code in plainCodes) + { + var salt = _twoFactorHelper.CreateRecoveryCodeSalt(); + var hash = _twoFactorHelper.HashRecoveryCode(code, salt); + + _recoveryCodeRepository.Insert(new TwoFactorRecoveryCode + { + TwoFactorAuthPk = twoFactorAuthId, + CodeSalt = salt, + CodeHash = hash, + IsUsed = 0, + UsedAt = null, + IsDelete = 0 + }); + } + + return plainCodes; + } + + /// + /// 尝试消费一个恢复备用码(一次性) + /// + /// + /// + /// + private bool TryConsumeRecoveryCode(int twoFactorAuthId, string candidateCode) + { + var candidates = _recoveryCodeRepository + .GetList(a => a.TwoFactorAuthPk == twoFactorAuthId && a.IsDelete != 1 && a.IsUsed != 1); + + foreach (var item in candidates) + { + if (!_twoFactorHelper.VerifyRecoveryCode(candidateCode, item.CodeSalt, item.CodeHash)) + { + continue; + } + + item.IsUsed = 1; + item.UsedAt = DateTime.Now; + _recoveryCodeRepository.Update(item); + return true; + } + + return false; + } + + /// + /// 通过业务编号解析账号主键与账号标识 + /// + /// 账号类型 + /// 账号业务编号(JWT SerialNumber) + /// + private UserResolveResult? ResolveUser(TwoFactorUserType userType, string serialNumber) + { + if (string.IsNullOrWhiteSpace(serialNumber)) + return null; + + switch (userType) + { + case TwoFactorUserType.Employee: + var employee = _employeeRepository.GetFirst(a => a.EmployeeId == serialNumber && a.IsDelete != 1); + if (employee == null) + return null; + return new UserResolveResult(employee.Id, employee.EmployeeId); + + case TwoFactorUserType.Administrator: + var admin = _administratorRepository.GetFirst(a => a.Number == serialNumber && a.IsDelete != 1); + if (admin == null) + return null; + return new UserResolveResult(admin.Id, admin.Account ?? admin.Number); + + case TwoFactorUserType.Customer: + var customer = _customerAccountRepository.GetFirst(a => a.CustomerNumber == serialNumber && a.IsDelete != 1); + if (customer == null) + return null; + return new UserResolveResult(customer.Id, customer.Account ?? customer.CustomerNumber); + + default: + return null; + } + } + + /// + /// 账号解析结果 + /// + private sealed class UserResolveResult + { + /// + /// 账号主键ID + /// + public int UserPrimaryKey { get; } + + /// + /// 账号名称(用于构建 TOTP 标识) + /// + public string AccountName { get; } + + /// + /// 构造函数 + /// + /// + /// + public UserResolveResult(int userPrimaryKey, string accountName) + { + UserPrimaryKey = userPrimaryKey; + AccountName = accountName; + } + } + } +} diff --git a/EOM.TSHotelManagement.Service/SystemManagement/Administrator/AdminService.cs b/EOM.TSHotelManagement.Service/SystemManagement/Administrator/AdminService.cs index 1e43c01ef8e7f298e4262bdc918beeeb9403a5fb..c494ca895708248b7f9c9de05479f3f368d158e3 100644 --- a/EOM.TSHotelManagement.Service/SystemManagement/Administrator/AdminService.cs +++ b/EOM.TSHotelManagement.Service/SystemManagement/Administrator/AdminService.cs @@ -25,10 +25,10 @@ using EOM.TSHotelManagement.Common; using EOM.TSHotelManagement.Contract; using EOM.TSHotelManagement.Data; using EOM.TSHotelManagement.Domain; -using jvncorelib.EncryptorLib; using jvncorelib.EntityLib; using Microsoft.Extensions.Logging; using SqlSugar; +using System.Net.Mail; using System.Security.Claims; namespace EOM.TSHotelManagement.Service @@ -78,9 +78,19 @@ namespace EOM.TSHotelManagement.Service /// private readonly JWTHelper jWTHelper; + /// + /// 2FA服务 + /// + private readonly ITwoFactorAuthService twoFactorAuthService; + + /// + /// 邮件助手 + /// + private readonly MailHelper mailHelper; + private readonly ILogger logger; - public AdminService(GenericRepository adminRepository, GenericRepository adminTypeRepository, GenericRepository userRoleRepository, GenericRepository rolePermissionRepository, GenericRepository permissionRepository, GenericRepository roleRepository, DataProtectionHelper dataProtector, JWTHelper jWTHelper, ILogger logger) + public AdminService(GenericRepository adminRepository, GenericRepository adminTypeRepository, GenericRepository userRoleRepository, GenericRepository rolePermissionRepository, GenericRepository permissionRepository, GenericRepository roleRepository, DataProtectionHelper dataProtector, JWTHelper jWTHelper, ITwoFactorAuthService twoFactorAuthService, MailHelper mailHelper, ILogger logger) { this.adminRepository = adminRepository; this.adminTypeRepository = adminTypeRepository; @@ -90,6 +100,8 @@ namespace EOM.TSHotelManagement.Service this.roleRepository = roleRepository; this.dataProtector = dataProtector; this.jWTHelper = jWTHelper; + this.twoFactorAuthService = twoFactorAuthService; + this.mailHelper = mailHelper; this.logger = logger; } @@ -109,12 +121,22 @@ namespace EOM.TSHotelManagement.Service if (existingAdmin == null) { - return null; + return new SingleOutputDto + { + Code = BusinessStatusCode.NotFound, + Message = LocalizationHelper.GetLocalizedString("Administrator does not exist", "管理员不存在"), + Data = null + }; } if (existingAdmin.IsDelete == 1) { - return null; + return new SingleOutputDto + { + Code = BusinessStatusCode.NotFound, + Message = LocalizationHelper.GetLocalizedString("Administrator does not exist", "管理员不存在"), + Data = null + }; } @@ -123,7 +145,49 @@ namespace EOM.TSHotelManagement.Service var passed = originalPwd == currentPwd; if (!passed) { - return null; + return new SingleOutputDto + { + Code = BusinessStatusCode.Unauthorized, + Message = LocalizationHelper.GetLocalizedString("Invalid account or password", "账号或密码错误"), + Data = null + }; + } + + var usedRecoveryCode = false; + if (twoFactorAuthService.RequiresTwoFactor(TwoFactorUserType.Administrator, existingAdmin.Id)) + { + if (string.IsNullOrWhiteSpace(readAdministratorInputDto.TwoFactorCode)) + { + return new SingleOutputDto + { + Code = BusinessStatusCode.Unauthorized, + Message = LocalizationHelper.GetLocalizedString("2FA code is required", "需要输入2FA验证码"), + Data = new ReadAdministratorOutputDto + { + Number = existingAdmin.Number, + Account = existingAdmin.Account, + Name = existingAdmin.Name, + RequiresTwoFactor = true + } + }; + } + + var twoFactorPassed = twoFactorAuthService.VerifyLoginCode(TwoFactorUserType.Administrator, existingAdmin.Id, readAdministratorInputDto.TwoFactorCode, out usedRecoveryCode); + if (!twoFactorPassed) + { + return new SingleOutputDto + { + Code = BusinessStatusCode.Unauthorized, + Message = LocalizationHelper.GetLocalizedString("Invalid 2FA code", "2FA验证码错误"), + Data = new ReadAdministratorOutputDto + { + Number = existingAdmin.Number, + Account = existingAdmin.Account, + Name = existingAdmin.Name, + RequiresTwoFactor = true + } + }; + } } existingAdmin.Password = string.Empty; @@ -134,6 +198,12 @@ namespace EOM.TSHotelManagement.Service })); var source = EntityMapper.Map(existingAdmin); + source.RequiresTwoFactor = false; + source.UsedRecoveryCodeLogin = usedRecoveryCode; + if (usedRecoveryCode) + { + NotifyRecoveryCodeLoginByEmail(existingAdmin.Account, existingAdmin.Name); + } return new SingleOutputDto { @@ -141,6 +211,55 @@ namespace EOM.TSHotelManagement.Service }; } + /// + /// 备用码登录后邮件通知(管理员) + /// + /// 候选邮箱(管理员账号) + /// 显示名称 + private void NotifyRecoveryCodeLoginByEmail(string? emailCandidate, string? displayName) + { + if (string.IsNullOrWhiteSpace(emailCandidate) || !MailAddress.TryCreate(emailCandidate, out _)) + { + logger.LogWarning("Recovery-code login alert skipped for admin {AdminName}: account is not a valid email.", displayName ?? string.Empty); + return; + } + + var recipient = emailCandidate; + var name = displayName ?? "Administrator"; + _ = Task.Run(async () => + { + await SendRecoveryCodeLoginAlertWithRetryAsync(recipient, name, "admin"); + }); + } + + private async Task SendRecoveryCodeLoginAlertWithRetryAsync(string recipient, string displayName, string accountKind) + { + const int maxAttempts = 3; + for (var attempt = 1; attempt <= maxAttempts; attempt++) + { + try + { + var template = EmailTemplate.GetTwoFactorRecoveryCodeLoginAlertTemplate(displayName, recipient, DateTime.Now); + var sent = mailHelper.SendMail(new List { recipient }, template.Subject, template.Body); + if (sent) + { + return; + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Recovery-code alert send failed for {AccountKind} {DisplayName}, attempt {Attempt}.", accountKind, displayName, attempt); + } + + if (attempt < maxAttempts) + { + await Task.Delay(TimeSpan.FromSeconds(attempt)); + } + } + + logger.LogWarning("Recovery-code alert send exhausted retries for {AccountKind} {DisplayName}.", accountKind, displayName); + } + /// /// 获取所有管理员列表 /// @@ -879,5 +998,58 @@ namespace EOM.TSHotelManagement.Service }; } } + + /// + /// 获取管理员账号 2FA 状态 + /// + /// 管理员编号(JWT SerialNumber) + /// + public SingleOutputDto GetTwoFactorStatus(string adminSerialNumber) + { + return twoFactorAuthService.GetStatus(TwoFactorUserType.Administrator, adminSerialNumber); + } + + /// + /// 生成管理员账号 2FA 绑定信息 + /// + /// 管理员编号(JWT SerialNumber) + /// + public SingleOutputDto GenerateTwoFactorSetup(string adminSerialNumber) + { + return twoFactorAuthService.GenerateSetup(TwoFactorUserType.Administrator, adminSerialNumber); + } + + /// + /// 启用管理员账号 2FA + /// + /// 管理员编号(JWT SerialNumber) + /// 验证码输入 + /// + public SingleOutputDto EnableTwoFactor(string adminSerialNumber, TwoFactorCodeInputDto inputDto) + { + return twoFactorAuthService.Enable(TwoFactorUserType.Administrator, adminSerialNumber, inputDto?.VerificationCode ?? string.Empty); + } + + /// + /// 关闭管理员账号 2FA + /// + /// 管理员编号(JWT SerialNumber) + /// 验证码输入 + /// + public BaseResponse DisableTwoFactor(string adminSerialNumber, TwoFactorCodeInputDto inputDto) + { + return twoFactorAuthService.Disable(TwoFactorUserType.Administrator, adminSerialNumber, inputDto?.VerificationCode ?? string.Empty); + } + + /// + /// 重置管理员账号恢复备用码 + /// + /// 管理员编号(JWT SerialNumber) + /// 验证码或恢复备用码输入 + /// + public SingleOutputDto RegenerateTwoFactorRecoveryCodes(string adminSerialNumber, TwoFactorCodeInputDto inputDto) + { + return twoFactorAuthService.RegenerateRecoveryCodes(TwoFactorUserType.Administrator, adminSerialNumber, inputDto?.VerificationCode ?? string.Empty); + } } } diff --git a/EOM.TSHotelManagement.Service/SystemManagement/Administrator/IAdminService.cs b/EOM.TSHotelManagement.Service/SystemManagement/Administrator/IAdminService.cs index ddb36d5817e625f168c632356a783c4844138b4f..a88714a82ad52638266367a0e69f353d286a4f3b 100644 --- a/EOM.TSHotelManagement.Service/SystemManagement/Administrator/IAdminService.cs +++ b/EOM.TSHotelManagement.Service/SystemManagement/Administrator/IAdminService.cs @@ -125,5 +125,43 @@ namespace EOM.TSHotelManagement.Service /// 用户编码 /// 权限编码集合(PermissionNumber 列表) ListOutputDto ReadUserDirectPermissions(string userNumber); + + /// + /// 获取管理员账号的 2FA 状态 + /// + /// 管理员编号(JWT SerialNumber) + /// + SingleOutputDto GetTwoFactorStatus(string adminSerialNumber); + + /// + /// 生成管理员账号的 2FA 绑定信息 + /// + /// 管理员编号(JWT SerialNumber) + /// + SingleOutputDto GenerateTwoFactorSetup(string adminSerialNumber); + + /// + /// 启用管理员账号 2FA + /// + /// 管理员编号(JWT SerialNumber) + /// 验证码输入 + /// + SingleOutputDto EnableTwoFactor(string adminSerialNumber, TwoFactorCodeInputDto inputDto); + + /// + /// 关闭管理员账号 2FA + /// + /// 管理员编号(JWT SerialNumber) + /// 验证码输入 + /// + BaseResponse DisableTwoFactor(string adminSerialNumber, TwoFactorCodeInputDto inputDto); + + /// + /// 重置管理员账号恢复备用码 + /// + /// 管理员编号(JWT SerialNumber) + /// 验证码或恢复备用码输入 + /// + SingleOutputDto RegenerateTwoFactorRecoveryCodes(string adminSerialNumber, TwoFactorCodeInputDto inputDto); } -} \ No newline at end of file +} diff --git a/README.en.md b/README.en.md index 0c6954942af887d1fc937ba289466bd4d6526f9f..1f8b43f874a00d47692807b1ea75497b6784a283 100644 --- a/README.en.md +++ b/README.en.md @@ -18,6 +18,13 @@ Primarily designed to achieve front-end/back-end separation following the upgrad ## Core Functional Features +### 0. Account Security Enhancements (TOTP 2FA) +- **TOTP-based 2FA support**: Staff, administrators, and customers can all enable/disable 2FA. +- **Recovery code support**: Users can complete login with one-time recovery codes when authenticator access is lost. +- **Recovery codes returned on first enablement**: `EnableTwoFactor` now returns the initial batch of recovery codes directly to avoid duplicate regeneration from the frontend. +- **Security alerting**: When a login succeeds via a recovery code, the system attempts to send an email notification before completing the response. +- **Complete API coverage**: Includes status query, binding info generation, enable/disable, recovery code reset, and remaining count query. + ### 1. Business Management Modules - **Room Management**: Supports room status management (Vacant, Occupied, Under Maintenance, Dirty, Reserved), check-in/check-out, room transfers, and configuration (type, pricing). - **Guest Management**: Guest profile management, account registration/login, membership tier administration. @@ -181,6 +188,12 @@ docker run -d \ |AllowedOrigins__0|Allowed Domain Sites (for Development Environments)|Y|http://localhost:8080|http://localhost:8080| |AllowedOrigins__1|Allowed domain sites for production environment|Y|https://www.yourdomain.com|https://www.yourdomain.com| |SoftwareVersion|Software version number for documentation purposes|N|N/A|N/A| +|JobKeys__0|Quartz Job 1|Y|ReservationExpirationCheckJob|ReservationExpirationCheckJob| +|JobKeys__1|Quartz Job 2|Y|MailServiceCheckJob|MailServiceCheckJob| +|JobKeys__2|Quartz Job 3|Y|RedisServiceCheckJob|RedisServiceCheckJob| +|Redis__Enabled|Enable Redis|N|false|true/false| +|Redis__ConnectionString|Redis ConnectString|N|N/A|N/A| +|Redis__DefaultDatabase|Default Database of Redis|N|0|0| > ⚠️ **Security Advisory**: In production environments, do not directly pass password-like parameters in plaintext via the `-e` flag. It is recommended to utilise Docker Secrets or environment variable injection tools (such as HashiCorp Vault) for protection. diff --git a/README.md b/README.md index b18aed902ff1d30ef7b17878c93f12301fe06f1c..0f034ff2e518e5378eef451b5395c6b96b00eb95 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -

组织logo.png

+

组织logo.png

TopskyHotelManagementSystem-WebApi

star @@ -18,6 +18,13 @@ ## 核心功能特性 +### 0. 账户安全增强(TOTP 2FA) +- **支持 TOTP 双因子认证**:员工、管理员、客户三类账号均支持开启/关闭 2FA。 +- **支持恢复备用码**:当用户丢失验证器时,可使用一次性备用码完成登录。 +- **首次启用即返回备用码**:`EnableTwoFactor` 成功后会直接返回首批备用码,避免前端重复重置生成。 +- **安全告警**:检测到“备用码登录”后,系统会在登录成功前尝试发送邮件通知用户。 +- **接口能力完整**:支持状态查询、绑定信息生成、启用、关闭、备用码重置与剩余数量查询。 + ### 1. 业务管理模块 - **房间管理**:支持房间状态(空房、已住、维修、脏房、预约)管理,入住、退房、换房,房间配置(类型、价格)。 - **客户管理**:客户档案管理,客户账号注册登录,会员类型管理。 @@ -181,6 +188,12 @@ docker run -d \ |AllowedOrigins__0|允许域站点,用于开发环境|Y|http://localhost:8080|http://localhost:8080| |AllowedOrigins__1|允许域站点,用于生产环境|Y|https://www.yourdomain.com|https://www.yourdomain.com| |SoftwareVersion|软件版本号,用于标记说明|N|N/A|N/A| +|JobKeys__0|定时任务1|Y|ReservationExpirationCheckJob|ReservationExpirationCheckJob| +|JobKeys__1|定时任务2|Y|MailServiceCheckJob|MailServiceCheckJob| +|JobKeys__2|定时任务3|Y|RedisServiceCheckJob|RedisServiceCheckJob| +|Redis__Enabled|是否启用Redis服务|N|false|true/false| +|Redis__ConnectionString|Redis连接字符串|N|N/A|N/A| +|Redis__DefaultDatabase|默认数据库|N|0|0| > ⚠️ **安全提醒**:生产环境中请勿直接通过 `-e` 明文传入密码类参数,推荐使用 Docker Secrets 或环境变量注入工具(如 HashiCorp Vault)进行保护。 diff --git a/version.txt b/version.txt index f96eaa82dab2fdf85cb4dde18b9e2e6069c90a11..315de5b54a9165b27f0cb117fab775b21812a334 100644 Binary files a/version.txt and b/version.txt differ