From 5f81dc1d42652476cd40f15deaeb6907cf826622 Mon Sep 17 00:00:00 2001 From: ck_yeun9 Date: Sun, 15 Feb 2026 20:48:57 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=BB=9F=E4=B8=802FA?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=94=AF=E6=8C=81TOTP=E4=B8=8E?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E7=A0=81=E7=99=BB=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现了员工/管理员/客户账号的两步验证(2FA),支持TOTP及恢复备用码登录与管理。包括数据库表TwoFactorAuth/TwoFactorRecoveryCode、核心工具类TwoFactorHelper、配置与服务注入、API接口、权限点等全套功能。登录时如用恢复码会自动邮件提醒。修正了部分注释乱码,优化了DTO结构和参数校验,完善了.gitignore及相关文档。 --- .gitignore | 5 +- .../.config/dotnet-tools.json | 12 - .../Customer/CustomerAccountController.cs | 65 ++ .../Employee/EmployeeController.cs | 72 +- .../Administrator/AdminController.cs | 70 ++ .../Extensions/AutofacConfigExtensions.cs | 1 + .../Extensions/ServiceExtensions.cs | 1 + .../appsettings.Services.json | 12 +- EOM.TSHotelManagement.API/appsettings.json | 51 +- .../Enums/TwoFactorUserType.cs | 9 + .../Helper/DataProtectionHelper.cs | 8 + .../Helper/EntityMapper.cs | 18 +- .../Helper/LocalizationHelper.cs | 12 +- .../Helper/TwoFactorHelper.cs | 356 ++++++++++ .../Templates/EmailTemplate.cs | 23 + .../NavBar/Dto/CreateNavBarInputDto.cs | 4 +- .../Dto/CustoType/CreateCustoTypeInputDto.cs | 6 +- .../ReadCustomerAccountInputDto.cs | 6 +- .../ReadCustomerAccountOutputDto.cs | 12 +- .../Dto/TwoFactor/TwoFactorCodeInputDto.cs | 13 + .../TwoFactorRecoveryCodesOutputDto.cs | 18 + .../Dto/TwoFactor/TwoFactorSetupOutputDto.cs | 38 ++ .../Dto/TwoFactor/TwoFactorStatusOutputDto.cs | 28 + .../Employee/Dto/Employee/EmployeeLoginDto.cs | 1 + .../Dto/Employee/ReadEmployeeOutputDto.cs | 6 + .../ReadAdministratorInputDto.cs | 1 + .../ReadAdministratorOutputDto.cs | 7 + .../Dto/Position/CreatePositionInputDto.cs | 4 +- .../DatabaseInitializer.cs | 60 +- .../SystemManagement/TwoFactorAuth.cs | 35 + .../SystemManagement/TwoFactorRecoveryCode.cs | 28 + .../Config/TwoFactorConfig.cs | 48 ++ .../Factory/TwoFactorConfigFactory.cs | 40 ++ .../EntityBuilder.cs | 33 +- .../Account/CustomerAccountService.cs | 153 ++++- .../Account/ICustomerAccountService.cs | 38 ++ .../Employee/EmployeeService.cs | 138 +++- .../Employee/IEmployeeService.cs | 40 +- .../Security/ITwoFactorAuthService.cs | 72 ++ .../Security/TwoFactorAuthService.cs | 634 ++++++++++++++++++ .../Administrator/AdminService.cs | 157 ++++- .../Administrator/IAdminService.cs | 40 +- README.en.md | 6 + README.md | 6 + version.txt | Bin 18 -> 10 bytes 45 files changed, 2273 insertions(+), 114 deletions(-) delete mode 100644 EOM.TSHotelManagement.API/.config/dotnet-tools.json create mode 100644 EOM.TSHotelManagement.Common/Enums/TwoFactorUserType.cs create mode 100644 EOM.TSHotelManagement.Common/Helper/TwoFactorHelper.cs create mode 100644 EOM.TSHotelManagement.Contract/Common/Dto/TwoFactor/TwoFactorCodeInputDto.cs create mode 100644 EOM.TSHotelManagement.Contract/Common/Dto/TwoFactor/TwoFactorRecoveryCodesOutputDto.cs create mode 100644 EOM.TSHotelManagement.Contract/Common/Dto/TwoFactor/TwoFactorSetupOutputDto.cs create mode 100644 EOM.TSHotelManagement.Contract/Common/Dto/TwoFactor/TwoFactorStatusOutputDto.cs create mode 100644 EOM.TSHotelManagement.Domain/SystemManagement/TwoFactorAuth.cs create mode 100644 EOM.TSHotelManagement.Domain/SystemManagement/TwoFactorRecoveryCode.cs create mode 100644 EOM.TSHotelManagement.Infrastructure/Config/TwoFactorConfig.cs create mode 100644 EOM.TSHotelManagement.Infrastructure/Factory/TwoFactorConfigFactory.cs create mode 100644 EOM.TSHotelManagement.Service/Security/ITwoFactorAuthService.cs create mode 100644 EOM.TSHotelManagement.Service/Security/TwoFactorAuthService.cs diff --git a/.gitignore b/.gitignore index cc917a0..c502942 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 43a4368..0000000 --- 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 50fd840..15b12f0 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 64a0bad..85007ae 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 6965c36..15e0112 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 6c75f14..cc3ded2 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 2e9b9da..a753ece 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 7c2ba5c..98919c7 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 e1c9e6f..0db3279 100644 --- a/EOM.TSHotelManagement.API/appsettings.json +++ b/EOM.TSHotelManagement.API/appsettings.json @@ -1,52 +1,3 @@ { - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft": "Warning", - "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": "" - } + } diff --git a/EOM.TSHotelManagement.Common/Enums/TwoFactorUserType.cs b/EOM.TSHotelManagement.Common/Enums/TwoFactorUserType.cs new file mode 100644 index 0000000..42996b7 --- /dev/null +++ b/EOM.TSHotelManagement.Common/Enums/TwoFactorUserType.cs @@ -0,0 +1,9 @@ +namespace EOM.TSHotelManagement.Common +{ + public enum TwoFactorUserType + { + Employee = 1, + Administrator = 2, + Customer = 3 + } +} diff --git a/EOM.TSHotelManagement.Common/Helper/DataProtectionHelper.cs b/EOM.TSHotelManagement.Common/Helper/DataProtectionHelper.cs index 499a564..efbff01 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 3a464f8..08f9a86 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 376da2a..ff2f82b 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 0000000..6e0cd1b --- /dev/null +++ b/EOM.TSHotelManagement.Common/Helper/TwoFactorHelper.cs @@ -0,0 +1,356 @@ +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); + var bytes = new byte[config.RecoveryCodeLength]; + + for (var i = 0; i < config.RecoveryCodeCount; i++) + { + RandomNumberGenerator.Fill(bytes); + var chars = new char[config.RecoveryCodeLength]; + for (var j = 0; j < bytes.Length; j++) + { + chars[j] = RecoveryCodeAlphabet[bytes[j] % 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) + { + 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)) + 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 b503a4a..5778df4 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 a856515..5874b2b 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 3c3c321..737cf68 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 74d08ca..8e82bcb 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 3e5b69b..13f30ab 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 0000000..169ed6b --- /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 0000000..7b9e974 --- /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 0000000..1ef3711 --- /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 0000000..b83b49b --- /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 e679724..ec6edfa 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 7ec59a5..66b0108 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 8a5f0ec..86dd7f4 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 3bbfad6..6421a0c 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 4542ca2..f1c85c4 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 48455e1..8c73666 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,6 +97,7 @@ namespace EOM.TSHotelManagement.Data .ToArray(); db.CodeFirst.InitTables(needCreateTableTypes); + EnsureTwoFactorForeignKeys(db, dbSettings.DbType); Console.WriteLine("Database schema initialized"); @@ -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", "custoemr_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/SystemManagement/TwoFactorAuth.cs b/EOM.TSHotelManagement.Domain/SystemManagement/TwoFactorAuth.cs new file mode 100644 index 0000000..53554a2 --- /dev/null +++ b/EOM.TSHotelManagement.Domain/SystemManagement/TwoFactorAuth.cs @@ -0,0 +1,35 @@ +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->custoemr_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; } + } +} diff --git a/EOM.TSHotelManagement.Domain/SystemManagement/TwoFactorRecoveryCode.cs b/EOM.TSHotelManagement.Domain/SystemManagement/TwoFactorRecoveryCode.cs new file mode 100644 index 0000000..108125a --- /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 0000000..02ea824 --- /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 0000000..7014471 --- /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 19fcea0..58c5066 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,41 @@ 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; + } + + try + { + var template = EmailTemplate.GetTwoFactorRecoveryCodeLoginAlertTemplate( + customerName ?? "Customer", + account ?? string.Empty, + DateTime.Now); + mailHelper.SendMail(new List { emailAddress }, template.Subject, template.Body); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to send recovery-code login alert email for customer {Account}.", account ?? string.Empty); + } + } + /// /// 注册 /// @@ -245,12 +330,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 107d876..eee0de8 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 987d7ea..9fd91fd 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,42 @@ 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; + } + + try + { + var template = EmailTemplate.GetTwoFactorRecoveryCodeLoginAlertTemplate( + employeeName ?? "Employee", + employeeId ?? string.Empty, + DateTime.Now); + mailHelper.SendMail(new List { emailAddress }, template.Subject, template.Body); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to send recovery-code login alert email for employee {EmployeeId}.", employeeId ?? string.Empty); + } } /// @@ -582,5 +659,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 0caeb10..1069f90 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 0000000..bebd177 --- /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 0000000..852efe6 --- /dev/null +++ b/EOM.TSHotelManagement.Service/Security/TwoFactorAuthService.cs @@ -0,0 +1,634 @@ +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; + + var secret = _dataProtectionHelper.SafeDecryptTwoFactorData(auth.SecretKey); + if (!_twoFactorHelper.VerifyCode(secret, code)) + { + if (!TryConsumeRecoveryCode(auth.Id, code)) + { + return false; + } + + usedRecoveryCode = true; + } + + auth.LastVerifiedAt = DateTime.Now; + _twoFactorRepository.Update(auth); + return true; + } + + /// + /// 获取账号 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 + }; + 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; + _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 + }; + } + + var secret = _dataProtectionHelper.SafeDecryptTwoFactorData(auth.SecretKey); + if (!_twoFactorHelper.VerifyCode(secret, verificationCode)) + { + return new SingleOutputDto + { + Code = BusinessStatusCode.Unauthorized, + Message = LocalizationHelper.GetLocalizedString("Invalid 2FA code", "2FA验证码错误"), + Data = null + }; + } + + auth.IsEnabled = 1; + auth.EnabledAt = DateTime.Now; + auth.LastVerifiedAt = DateTime.Now; + _twoFactorRepository.Update(auth); + + // 启用时自动生成一组恢复备用码(仅保存哈希,明文只在本次响应返回) + 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; + _twoFactorRepository.Update(auth); + + 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); + auth.LastVerifiedAt = DateTime.Now; + _twoFactorRepository.Update(auth); + + 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; + } + + var secret = _dataProtectionHelper.SafeDecryptTwoFactorData(auth.SecretKey); + if (_twoFactorHelper.VerifyCode(secret, code)) + { + return true; + } + + return allowRecoveryCode && TryConsumeRecoveryCode(auth.Id, code); + } + + /// + /// 获取剩余可用恢复备用码数量 + /// + /// + /// + private int GetRemainingRecoveryCodeCount(int twoFactorAuthId) + { + return _recoveryCodeRepository + .GetList(a => a.TwoFactorAuthPk == twoFactorAuthId && a.IsDelete != 1 && a.IsUsed != 1) + .Count; + } + + /// + /// 清理指定 2FA 的全部恢复备用码(硬删除) + /// + /// + private void ClearRecoveryCodes(int twoFactorAuthId) + { + var existing = _recoveryCodeRepository + .GetList(a => a.TwoFactorAuthPk == twoFactorAuthId); + + if (existing.Count == 0) + { + return; + } + + _recoveryCodeRepository.Delete(existing); + } + + /// + /// 重新生成恢复备用码(会清理旧数据) + /// + /// + /// 新备用码明文(仅返回一次) + 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 1e43c01..91cdfa2 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,30 @@ 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; + } + + try + { + var template = EmailTemplate.GetTwoFactorRecoveryCodeLoginAlertTemplate(displayName ?? "Administrator", emailCandidate, DateTime.Now); + mailHelper.SendMail(new List { emailCandidate }, template.Subject, template.Body); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to send recovery-code login alert email for admin {AdminName}.", displayName ?? string.Empty); + } + } + /// /// 获取所有管理员列表 /// @@ -879,5 +973,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 ddb36d5..a88714a 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 0c69549..3b030c8 100644 --- a/README.en.md +++ b/README.en.md @@ -181,6 +181,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 b18aed9..ce24979 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,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 GIT binary patch literal 10 RcmaFAd%uyMk)ELm7XTX+1DOB- literal 18 WcmezW&xk>f0fY@1Oc;0>xEKI0t^+y% -- Gitee From b74c04ebcc821e2e729da8f40e90a32fe3ba9b6d Mon Sep 17 00:00:00 2001 From: ck_yeun9 Date: Sun, 15 Feb 2026 21:06:41 +0800 Subject: [PATCH 2/4] update md. --- README.en.md | 7 +++++++ README.md | 9 ++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/README.en.md b/README.en.md index 3b030c8..1f8b43f 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. diff --git a/README.md b/README.md index ce24979..0f034ff 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. 业务管理模块 - **房间管理**:支持房间状态(空房、已住、维修、脏房、预约)管理,入住、退房、换房,房间配置(类型、价格)。 - **客户管理**:客户档案管理,客户账号注册登录,会员类型管理。 -- Gitee From 70d3859db200adcb74b167ab3e75fcf84a1bd023 Mon Sep 17 00:00:00 2001 From: ck_yeun9 Date: Sun, 15 Feb 2026 21:11:20 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20.editorconfig=20?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E4=BB=A5=E7=BB=9F=E4=B8=80=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F=E8=AE=BE=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .editorconfig | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..9552eb6 --- /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 -- Gitee From e8c21db2de8d9b936c32e55b7a6db5555f179918 Mon Sep 17 00:00:00 2001 From: ck_yeun9 Date: Mon, 16 Feb 2026 16:57:16 +0800 Subject: [PATCH 4/4] =?UTF-8?q?2FA=E9=98=B2=E9=87=8D=E6=94=BE=E6=9C=BA?= =?UTF-8?q?=E5=88=B6=E4=B8=8E=E8=A1=A8=E7=BB=93=E6=9E=84=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=EF=BC=8C=E5=A2=9E=E5=BC=BA=E5=AE=89=E5=85=A8=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本次提交主要包括以下内容: - 增加TOTP防重放机制,TwoFactorAuth表新增LastValidatedCounter字段,后端校验TOTP时防止验证码重用或倒退。 - TwoFactorHelper新增TryVerifyCode方法,TwoFactorAuthService相关流程调整,所有TOTP校验均防重放。 - 启用、禁用、重置2FA时同步清空LastValidatedCounter,相关操作增加数据库结果判断,提升健壮性。 - 优化恢复码(recovery code)生成与清理逻辑。 - 管理员、员工、客户恢复码登录邮件通知增加重试机制,失败有详细日志,邮件发送异步处理。 - 修正customer_account表名拼写错误,相关实体、外键描述及数据库初始化逻辑同步修正。 - 新增.env和appsettings.json基础配置,便于部署和环境管理。 - TwoFactorUserType枚举增加Unknown=0,提升类型安全。 - 其他代码风格、异常处理、日志等细节优化。 整体提升了2FA安全性、系统健壮性和可维护性。 --- .env | 57 ++++++++ EOM.TSHotelManagement.API/appsettings.json | 9 +- .../Enums/TwoFactorUserType.cs | 1 + .../Helper/TwoFactorHelper.cs | 24 +++- .../DatabaseInitializer.cs | 4 +- .../Business/Customer/CustomerAccount.cs | 4 +- .../SystemManagement/TwoFactorAuth.cs | 5 +- .../Account/CustomerAccountService.cs | 41 ++++-- .../Employee/EmployeeService.cs | 41 ++++-- .../Security/TwoFactorAuthService.cs | 132 +++++++++++++----- .../Administrator/AdminService.cs | 37 ++++- 11 files changed, 288 insertions(+), 67 deletions(-) create mode 100644 .env diff --git a/.env b/.env new file mode 100644 index 0000000..73af504 --- /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/EOM.TSHotelManagement.API/appsettings.json b/EOM.TSHotelManagement.API/appsettings.json index 0db3279..d9d9a9b 100644 --- a/EOM.TSHotelManagement.API/appsettings.json +++ b/EOM.TSHotelManagement.API/appsettings.json @@ -1,3 +1,10 @@ { - + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "AllowedHosts": "*" } diff --git a/EOM.TSHotelManagement.Common/Enums/TwoFactorUserType.cs b/EOM.TSHotelManagement.Common/Enums/TwoFactorUserType.cs index 42996b7..f2376a9 100644 --- a/EOM.TSHotelManagement.Common/Enums/TwoFactorUserType.cs +++ b/EOM.TSHotelManagement.Common/Enums/TwoFactorUserType.cs @@ -2,6 +2,7 @@ namespace EOM.TSHotelManagement.Common { public enum TwoFactorUserType { + Unknown = 0, Employee = 1, Administrator = 2, Customer = 3 diff --git a/EOM.TSHotelManagement.Common/Helper/TwoFactorHelper.cs b/EOM.TSHotelManagement.Common/Helper/TwoFactorHelper.cs index 6e0cd1b..5134c82 100644 --- a/EOM.TSHotelManagement.Common/Helper/TwoFactorHelper.cs +++ b/EOM.TSHotelManagement.Common/Helper/TwoFactorHelper.cs @@ -42,15 +42,13 @@ namespace EOM.TSHotelManagement.Common { var config = GetConfig(); var result = new List(config.RecoveryCodeCount); - var bytes = new byte[config.RecoveryCodeLength]; for (var i = 0; i < config.RecoveryCodeCount; i++) { - RandomNumberGenerator.Fill(bytes); var chars = new char[config.RecoveryCodeLength]; - for (var j = 0; j < bytes.Length; j++) + for (var j = 0; j < chars.Length; j++) { - chars[j] = RecoveryCodeAlphabet[bytes[j] % RecoveryCodeAlphabet.Length]; + chars[j] = RecoveryCodeAlphabet[RandomNumberGenerator.GetInt32(0, RecoveryCodeAlphabet.Length)]; } var raw = new string(chars); @@ -154,6 +152,21 @@ namespace EOM.TSHotelManagement.Common /// 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; @@ -176,7 +189,10 @@ namespace EOM.TSHotelManagement.Common var expected = ComputeTotp(key, currentCounter, config.CodeDigits); if (FixedTimeEquals(expected, normalizedCode)) + { + validatedCounter = currentCounter; return true; + } } return false; diff --git a/EOM.TSHotelManagement.Data/DatabaseInitializer/DatabaseInitializer.cs b/EOM.TSHotelManagement.Data/DatabaseInitializer/DatabaseInitializer.cs index 8c73666..6367387 100644 --- a/EOM.TSHotelManagement.Data/DatabaseInitializer/DatabaseInitializer.cs +++ b/EOM.TSHotelManagement.Data/DatabaseInitializer/DatabaseInitializer.cs @@ -98,7 +98,7 @@ namespace EOM.TSHotelManagement.Data db.CodeFirst.InitTables(needCreateTableTypes); EnsureTwoFactorForeignKeys(db, dbSettings.DbType); - + Console.WriteLine("Database schema initialized"); SeedInitialData(db); @@ -262,7 +262,7 @@ namespace EOM.TSHotelManagement.Data { 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", "custoemr_account", "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"); } } diff --git a/EOM.TSHotelManagement.Domain/Business/Customer/CustomerAccount.cs b/EOM.TSHotelManagement.Domain/Business/Customer/CustomerAccount.cs index 1235449..957fe62 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 index 53554a2..a87512d 100644 --- a/EOM.TSHotelManagement.Domain/SystemManagement/TwoFactorAuth.cs +++ b/EOM.TSHotelManagement.Domain/SystemManagement/TwoFactorAuth.cs @@ -17,7 +17,7 @@ namespace EOM.TSHotelManagement.Domain [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->custoemr_account.id)")] + [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密钥(加密存储)")] @@ -31,5 +31,8 @@ namespace EOM.TSHotelManagement.Domain [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.Service/Business/Customer/Account/CustomerAccountService.cs b/EOM.TSHotelManagement.Service/Business/Customer/Account/CustomerAccountService.cs index b49382f..80cb10a 100644 --- a/EOM.TSHotelManagement.Service/Business/Customer/Account/CustomerAccountService.cs +++ b/EOM.TSHotelManagement.Service/Business/Customer/Account/CustomerAccountService.cs @@ -194,18 +194,41 @@ namespace EOM.TSHotelManagement.Service return; } - try + var recipient = emailAddress; + var name = customerName ?? "Customer"; + var identity = account ?? string.Empty; + _ = Task.Run(async () => { - var template = EmailTemplate.GetTwoFactorRecoveryCodeLoginAlertTemplate( - customerName ?? "Customer", - account ?? string.Empty, - DateTime.Now); - mailHelper.SendMail(new List { emailAddress }, template.Subject, template.Body); - } - catch (Exception ex) + 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++) { - logger.LogWarning(ex, "Failed to send recovery-code login alert email for customer {Account}.", account ?? string.Empty); + 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); } /// diff --git a/EOM.TSHotelManagement.Service/Employee/EmployeeService.cs b/EOM.TSHotelManagement.Service/Employee/EmployeeService.cs index 9fd91fd..6355b7f 100644 --- a/EOM.TSHotelManagement.Service/Employee/EmployeeService.cs +++ b/EOM.TSHotelManagement.Service/Employee/EmployeeService.cs @@ -535,18 +535,41 @@ namespace EOM.TSHotelManagement.Service return; } - try + var recipient = emailAddress; + var name = employeeName ?? "Employee"; + var identity = employeeId ?? string.Empty; + _ = Task.Run(async () => { - var template = EmailTemplate.GetTwoFactorRecoveryCodeLoginAlertTemplate( - employeeName ?? "Employee", - employeeId ?? string.Empty, - DateTime.Now); - mailHelper.SendMail(new List { emailAddress }, template.Subject, template.Body); - } - catch (Exception ex) + 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++) { - logger.LogWarning(ex, "Failed to send recovery-code login alert email for employee {EmployeeId}.", employeeId ?? string.Empty); + 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); } /// diff --git a/EOM.TSHotelManagement.Service/Security/TwoFactorAuthService.cs b/EOM.TSHotelManagement.Service/Security/TwoFactorAuthService.cs index 852efe6..9d504e5 100644 --- a/EOM.TSHotelManagement.Service/Security/TwoFactorAuthService.cs +++ b/EOM.TSHotelManagement.Service/Security/TwoFactorAuthService.cs @@ -1,4 +1,4 @@ -using EOM.TSHotelManagement.Common; +using EOM.TSHotelManagement.Common; using EOM.TSHotelManagement.Contract; using EOM.TSHotelManagement.Data; using EOM.TSHotelManagement.Domain; @@ -77,20 +77,18 @@ namespace EOM.TSHotelManagement.Service if (auth == null || auth.IsDelete == 1 || auth.IsEnabled != 1 || string.IsNullOrWhiteSpace(auth.SecretKey)) return false; - var secret = _dataProtectionHelper.SafeDecryptTwoFactorData(auth.SecretKey); - if (!_twoFactorHelper.VerifyCode(secret, code)) + if (TryVerifyTotp(auth, code, out var validatedCounter)) { - if (!TryConsumeRecoveryCode(auth.Id, code)) - { - return false; - } + return TryMarkTotpValidated(auth, validatedCounter); + } - usedRecoveryCode = true; + if (!TryConsumeRecoveryCode(auth.Id, code)) + { + return false; } - auth.LastVerifiedAt = DateTime.Now; - _twoFactorRepository.Update(auth); - return true; + usedRecoveryCode = true; + return TouchLastVerifiedAt(auth.Id); } /// @@ -159,7 +157,8 @@ namespace EOM.TSHotelManagement.Service IsEnabled = 0, SecretKey = encryptedSecret, EnabledAt = null, - LastVerifiedAt = null + LastVerifiedAt = null, + LastValidatedCounter = null }; AttachUserForeignKey(auth, userType, resolved.UserPrimaryKey); _twoFactorRepository.Insert(auth); @@ -171,6 +170,7 @@ namespace EOM.TSHotelManagement.Service auth.IsEnabled = 0; auth.EnabledAt = null; auth.LastVerifiedAt = null; + auth.LastValidatedCounter = null; _twoFactorRepository.Update(auth); } @@ -249,8 +249,7 @@ namespace EOM.TSHotelManagement.Service }; } - var secret = _dataProtectionHelper.SafeDecryptTwoFactorData(auth.SecretKey); - if (!_twoFactorHelper.VerifyCode(secret, verificationCode)) + if (!TryVerifyTotp(auth, verificationCode, out var validatedCounter)) { return new SingleOutputDto { @@ -260,10 +259,27 @@ namespace EOM.TSHotelManagement.Service }; } + 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; - auth.LastVerifiedAt = DateTime.Now; - _twoFactorRepository.Update(auth); + if (!_twoFactorRepository.Update(auth)) + { + return new SingleOutputDto + { + Code = BusinessStatusCode.InternalServerError, + Message = LocalizationHelper.GetLocalizedString("Enable 2FA failed", "鍚敤2FA澶辫触"), + Data = null + }; + } // 启用时自动生成一组恢复备用码(仅保存哈希,明文只在本次响应返回) var codes = ReplaceRecoveryCodes(auth.Id); @@ -328,7 +344,11 @@ namespace EOM.TSHotelManagement.Service auth.SecretKey = null; auth.EnabledAt = null; auth.LastVerifiedAt = DateTime.Now; - _twoFactorRepository.Update(auth); + auth.LastValidatedCounter = null; + if (!_twoFactorRepository.Update(auth)) + { + return new BaseResponse(BusinessStatusCode.InternalServerError, LocalizationHelper.GetLocalizedString("Disable 2FA failed", "鍏抽棴2FA澶辫触")); + } ClearRecoveryCodes(auth.Id); @@ -395,8 +415,15 @@ namespace EOM.TSHotelManagement.Service } var codes = ReplaceRecoveryCodes(auth.Id); - auth.LastVerifiedAt = DateTime.Now; - _twoFactorRepository.Update(auth); + if (!TouchLastVerifiedAt(auth.Id)) + { + return new SingleOutputDto + { + Code = BusinessStatusCode.InternalServerError, + Message = LocalizationHelper.GetLocalizedString("Recovery code regenerate failed", "备用码生成失败"), + Data = null + }; + } return new SingleOutputDto { @@ -474,15 +501,63 @@ namespace EOM.TSHotelManagement.Service return false; } - var secret = _dataProtectionHelper.SafeDecryptTwoFactorData(auth.SecretKey); - if (_twoFactorHelper.VerifyCode(secret, code)) + if (TryVerifyTotp(auth, code, out var validatedCounter)) { - return true; + 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; + } + /// /// 获取剩余可用恢复备用码数量 /// @@ -491,8 +566,7 @@ namespace EOM.TSHotelManagement.Service private int GetRemainingRecoveryCodeCount(int twoFactorAuthId) { return _recoveryCodeRepository - .GetList(a => a.TwoFactorAuthPk == twoFactorAuthId && a.IsDelete != 1 && a.IsUsed != 1) - .Count; + .Count(a => a.TwoFactorAuthPk == twoFactorAuthId && a.IsDelete != 1 && a.IsUsed != 1); } /// @@ -501,15 +575,7 @@ namespace EOM.TSHotelManagement.Service /// private void ClearRecoveryCodes(int twoFactorAuthId) { - var existing = _recoveryCodeRepository - .GetList(a => a.TwoFactorAuthPk == twoFactorAuthId); - - if (existing.Count == 0) - { - return; - } - - _recoveryCodeRepository.Delete(existing); + _recoveryCodeRepository.Delete(a => a.TwoFactorAuthPk == twoFactorAuthId); } /// diff --git a/EOM.TSHotelManagement.Service/SystemManagement/Administrator/AdminService.cs b/EOM.TSHotelManagement.Service/SystemManagement/Administrator/AdminService.cs index 91cdfa2..c494ca8 100644 --- a/EOM.TSHotelManagement.Service/SystemManagement/Administrator/AdminService.cs +++ b/EOM.TSHotelManagement.Service/SystemManagement/Administrator/AdminService.cs @@ -224,15 +224,40 @@ namespace EOM.TSHotelManagement.Service return; } - try + var recipient = emailCandidate; + var name = displayName ?? "Administrator"; + _ = Task.Run(async () => { - var template = EmailTemplate.GetTwoFactorRecoveryCodeLoginAlertTemplate(displayName ?? "Administrator", emailCandidate, DateTime.Now); - mailHelper.SendMail(new List { emailCandidate }, template.Subject, template.Body); - } - catch (Exception ex) + 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++) { - logger.LogWarning(ex, "Failed to send recovery-code login alert email for admin {AdminName}.", displayName ?? string.Empty); + 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); } /// -- Gitee