自主注册、登录、退出、当前用户与密码修改
兼容现有 PBKDF2 密码和 hz_session Cookie TOTP 绑定、二步登录、防重放、恢复码与 AES-GCM 密钥 与 Node 完全一致的 Redis 键格式,可跨运行时共享会话 生产环境开启原生认证时强制要求 Redis 默认保持兼容代理;设置 AUTH_NATIVE_ENABLED=true 即可切换
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user