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