自主注册、登录、退出、当前用户与密码修改

兼容现有 PBKDF2 密码和 hz_session Cookie
TOTP 绑定、二步登录、防重放、恢复码与 AES-GCM 密钥
与 Node 完全一致的 Redis 键格式,可跨运行时共享会话
生产环境开启原生认证时强制要求 Redis
默认保持兼容代理;设置 AUTH_NATIVE_ENABLED=true 即可切换
This commit is contained in:
2026-07-22 19:34:11 +08:00 Unverified
parent 017cacc6f9
commit ae472aabb0
26 changed files with 2569 additions and 21 deletions
@@ -0,0 +1,48 @@
using Eis.Infrastructure.Authentication;
namespace Eis.Infrastructure.Tests.Authentication;
public sealed class AuthenticationStateStoreTests
{
[Fact]
public async Task MemoryStoreHandlesSessionsChallengesAndTotpSetups()
{
var options = AuthenticationOptions.CreateForTests("test-only-totp-encryption-key-32-characters");
var state = new MemoryAuthenticationStateStore(options);
await state.CreateSessionAsync("session-1", "user-1");
await state.CreateSessionAsync("session-2", "user-1");
Assert.Equal("user-1", await state.GetSessionUserIdAsync("session-1"));
await state.CreateLoginChallengeAsync("challenge-1", "user-1");
for (var attempt = 1; attempt <= 5; attempt++)
{
var failure = await state.RecordLoginChallengeFailureAsync("challenge-1", 5);
Assert.Equal(attempt, failure?.Attempts);
}
Assert.Null(await state.GetLoginChallengeAsync("challenge-1"));
await state.CreateTotpSetupAsync("session-1", "user-1", "SECRET");
Assert.Equal(new TotpSetup("user-1", "SECRET"), await state.GetTotpSetupAsync("session-1"));
await state.DeleteTotpSetupAsync("session-1");
Assert.Null(await state.GetTotpSetupAsync("session-1"));
Assert.Equal(2, await state.DeleteUserSessionsAsync("user-1"));
Assert.Null(await state.GetSessionUserIdAsync("session-1"));
Assert.Null(await state.GetSessionUserIdAsync("session-2"));
}
[Fact]
public void RedisUrlMapsCredentialsTlsAndEndpoint()
{
var configuration = RedisAuthenticationStateStore.BuildConfiguration(
"rediss://session-user:p%40ss@example.test:6380/4");
var endpoint = Assert.Single(configuration.EndPoints);
Assert.Equal("Unspecified/example.test:6380", endpoint.ToString());
Assert.True(configuration.Ssl);
Assert.Equal("example.test", configuration.SslHost);
Assert.Equal("session-user", configuration.User);
Assert.Equal("p@ss", configuration.Password);
}
}
@@ -0,0 +1,29 @@
using Eis.Infrastructure.Authentication;
namespace Eis.Infrastructure.Tests.Authentication;
public sealed class PasswordCompatibilityServiceTests
{
[Fact]
public void VerifiesNodePbkdf2PasswordHash()
{
const string stored = "00112233445566778899aabbccddeeff:7a69c21675902559aa0cae041a3b4ebb3bc1402bb70a753eff44f5b32543c270";
var service = new PasswordCompatibilityService();
Assert.True(service.Verify("兼容Password123!", stored));
Assert.False(service.Verify("wrong-password", stored));
}
[Fact]
public void CreatesHashUsingLegacySaltAndDigestShape()
{
var service = new PasswordCompatibilityService();
var stored = service.Hash("Password123!");
var parts = stored.Split(':');
Assert.Equal(2, parts.Length);
Assert.Equal(32, parts[0].Length);
Assert.Equal(64, parts[1].Length);
Assert.True(service.Verify("Password123!", stored));
}
}
@@ -0,0 +1,47 @@
using Eis.Infrastructure.Authentication;
namespace Eis.Infrastructure.Tests.Authentication;
public sealed class TotpCompatibilityServiceTests
{
private const string EncryptionKey = "test-only-totp-encryption-key-32-characters";
private readonly TotpCompatibilityService _service = new(AuthenticationOptions.CreateForTests(EncryptionKey));
[Fact]
public void MatchesRfc6238NodeVector()
{
Assert.Equal("287082", _service.AtStep("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ", 1));
}
[Fact]
public void DecryptsNodeAesGcmPayloadAndRoundTripsDotnetPayload()
{
const string nodePayload = "v1.BW3b8OSqNLprCfpr.UUw1AKfqGvmo11YQUhHuPQ.GbPra-HNEUS3uWPJqwVpw2utLYbs89rlPOwhuNl7QYk";
const string secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ";
Assert.Equal(secret, _service.DecryptSecret(nodePayload));
Assert.Equal(secret, _service.DecryptSecret(_service.EncryptSecret(secret)));
}
[Fact]
public void MatchesNodeRecoveryHashAndOtpAuthUri()
{
Assert.Equal(
"662bf005217b529934526e0c8755c0c48d4dab012dfe96191080a6675334559c",
_service.HashRecoveryCode("ABCDE-23456"));
Assert.Equal(
"otpauth://totp/%E6%B5%B7%E5%B7%9E%20%E8%80%83%E8%AF%95%E4%B8%AD%E5%BF%83%3A2026%200001?secret=ABCDEF234567&issuer=%E6%B5%B7%E5%B7%9E+%E8%80%83%E8%AF%95%E4%B8%AD%E5%BF%83&algorithm=SHA1&digits=6&period=30",
_service.BuildOtpAuthUri("ABCDEF234567", "2026 0001", "海州 考试中心"));
}
[Fact]
public void RecoveryCodeCanOnlyBeConsumedOnce()
{
var hashes = new[] { _service.HashRecoveryCode("ABCDE-23456"), _service.HashRecoveryCode("FGHJK-78923") };
var remaining = _service.ConsumeRecoveryCode("abcde 23456", hashes);
Assert.NotNull(remaining);
Assert.Single(remaining);
Assert.Null(_service.ConsumeRecoveryCode("ABCDE-23456", remaining));
}
}