feat: harden SaaS authentication and authorization

This commit is contained in:
2026-07-28 12:15:51 +08:00
parent f22f329d33
commit 5d2248efee
123 changed files with 9090 additions and 2822 deletions

View File

@@ -1,10 +1,14 @@
using System.Text.Json;
using System.Security.Cryptography;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using Tiku.Application.Auth;
using Tiku.Application.Security;
using Tiku.Application.Tenancy;
using Tiku.Domain.Identity;
using Tiku.Domain.Operations;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Auth;
using Tiku.Infrastructure.Persistence;
@@ -13,495 +17,264 @@ namespace Tiku.UnitTests.Auth;
public sealed class AuthServiceTests
{
private static readonly JwtOptions JwtOptions = new()
{
Issuer = "tiku-unit-tests",
Audience = "tiku-api-unit-tests",
SigningKey = "unit-test-signing-key-that-is-long-enough"
};
[Fact]
public void Password_hasher_verifies_own_hash()
public void Identity_password_hasher_uses_current_identity_format()
{
var hasher = new PasswordHasher();
var user = new User();
var hasher = new PasswordHasher<User>(Options.Create(new PasswordHasherOptions
{
IterationCount = 210_000
}));
var hash = hasher.HashPassword(user, "passw0rd!");
var hash = hasher.Hash("passw0rd!");
Assert.True(hasher.Verify("passw0rd!", hash));
Assert.False(hasher.Verify("wrong", hash));
Assert.Equal(PasswordVerificationResult.Success, hasher.VerifyHashedPassword(user, hash, "passw0rd!"));
Assert.Equal(PasswordVerificationResult.Failed, hasher.VerifyHashedPassword(user, hash, "wrong"));
}
[Fact]
public async Task Password_login_creates_session_and_success_event()
public async Task Password_login_issues_tenant_session_for_regular_member()
{
await using var context = CreateContext();
var hasher = new PasswordHasher();
var seed = await SeedUserAsync(context, hasher.Hash("passw0rd!"));
var service = CreateAuthService(context);
await using var fixture = await AuthFixture.CreateAsync();
var result = await service.LoginWithPasswordAsync(new PasswordLoginRequest(
seed.TenantId,
seed.Phone,
"passw0rd!",
"127.0.0.1",
"unit-test"));
var result = await fixture.AuthService.LoginWithPasswordAsync(new PasswordLoginRequest(
AuthRealm.Tenant, fixture.TenantId, AuthFixture.Phone, AuthFixture.Password, "127.0.0.1", "unit-test"));
Assert.Equal(seed.UserId, result.UserId);
Assert.False(string.IsNullOrWhiteSpace(result.Tokens.AccessToken));
Assert.False(string.IsNullOrWhiteSpace(result.Tokens.RefreshToken));
Assert.Single(context.AuthSessions);
Assert.Contains(context.AuthLoginEvents, entity => entity.Result == AuthLoginResult.Success);
Assert.Equal(AuthenticationStatus.Authenticated, result.Status);
Assert.Equal(fixture.UserId, result.User!.UserId);
Assert.Equal(AuthRealm.Tenant, result.User.Realm);
Assert.False(string.IsNullOrWhiteSpace(result.User.Tokens.AccessToken));
Assert.Single(await fixture.DbContext.AuthSessions.ToArrayAsync());
}
[Fact]
public async Task Wrong_password_records_failed_event()
public async Task Five_wrong_passwords_lock_the_identity_account()
{
await using var context = CreateContext();
var hasher = new PasswordHasher();
var seed = await SeedUserAsync(context, hasher.Hash("passw0rd!"));
var service = CreateAuthService(context);
await using var fixture = await AuthFixture.CreateAsync();
await Assert.ThrowsAsync<InvalidCredentialsException>(() =>
service.LoginWithPasswordAsync(new PasswordLoginRequest(
seed.TenantId,
seed.Phone,
"wrong",
null,
null)));
Assert.Empty(context.AuthSessions);
Assert.Contains(context.AuthLoginEvents, entity =>
entity.Result == AuthLoginResult.Failed &&
entity.FailureCode == "invalid_credentials");
}
[Fact]
public async Task Sms_login_consumes_code_and_creates_session()
{
await using var context = CreateContext();
var seed = await SeedUserAsync(context, new PasswordHasher().Hash("passw0rd!"));
context.SmsVerificationCodes.Add(new SmsVerificationCode
for (var attempt = 0; attempt < 5; attempt++)
{
TenantId = seed.TenantId,
Phone = seed.Phone,
Purpose = SmsPurpose.Login,
CodeHash = SmsCodeHashing.Hash(seed.TenantId, seed.Phone, SmsPurpose.Login, "123456"),
Status = SmsVerificationStatus.Sent,
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5)
});
await context.SaveChangesAsync();
var service = CreateAuthService(context);
var result = await service.LoginWithSmsAsync(new SmsLoginRequest(
seed.TenantId,
seed.Phone,
"123456",
null,
null));
Assert.Equal(seed.UserId, result.UserId);
Assert.Single(context.AuthSessions);
Assert.Contains(context.SmsVerificationCodes, entity => entity.ConsumedAt is not null);
}
[Fact]
public async Task Invalid_sms_code_records_failed_event()
{
await using var context = CreateContext();
var seed = await SeedUserAsync(context, new PasswordHasher().Hash("passw0rd!"));
context.SmsVerificationCodes.Add(new SmsVerificationCode
{
TenantId = seed.TenantId,
Phone = seed.Phone,
Purpose = SmsPurpose.Login,
CodeHash = SmsCodeHashing.Hash(seed.TenantId, seed.Phone, SmsPurpose.Login, "123456"),
Status = SmsVerificationStatus.Sent,
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5)
});
await context.SaveChangesAsync();
var service = CreateAuthService(context);
await Assert.ThrowsAsync<InvalidCredentialsException>(() =>
service.LoginWithSmsAsync(new SmsLoginRequest(
seed.TenantId,
seed.Phone,
"999999",
null,
null)));
Assert.Empty(context.AuthSessions);
Assert.Contains(context.AuthLoginEvents, entity =>
entity.Result == AuthLoginResult.Failed &&
entity.FailureCode == "invalid_sms_code");
}
[Fact]
public async Task Sms_send_failure_records_failed_code_and_does_not_leave_usable_verification()
{
await using var context = CreateContext();
var seed = await SeedUserAsync(context, new PasswordHasher().Hash("passw0rd!"));
var smsService = new SmsVerificationService(context, new FailingSmsProvider());
var exception = await Assert.ThrowsAsync<SmsProviderException>(() =>
smsService.CreateCodeAsync(new SendSmsCodeRequest(
seed.TenantId,
seed.Phone,
SmsPurpose.Login,
null,
null)));
Assert.Equal("sms_provider_send_failed", exception.Code);
var verification = Assert.Single(context.SmsVerificationCodes);
Assert.Equal(SmsVerificationStatus.Failed, verification.Status);
Assert.Equal("failed", verification.Provider);
await Assert.ThrowsAsync<InvalidCredentialsException>(() =>
smsService.VerifyCodeAsync(
seed.TenantId,
seed.Phone,
SmsPurpose.Login,
"123456"));
}
[Fact]
public async Task Failed_sms_code_is_never_accepted_even_when_hash_matches()
{
await using var context = CreateContext();
var seed = await SeedUserAsync(context, new PasswordHasher().Hash("passw0rd!"));
context.SmsVerificationCodes.Add(new SmsVerificationCode
{
TenantId = seed.TenantId,
Phone = seed.Phone,
Purpose = SmsPurpose.Login,
CodeHash = SmsCodeHashing.Hash(seed.TenantId, seed.Phone, SmsPurpose.Login, "123456"),
Status = SmsVerificationStatus.Failed,
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5)
});
await context.SaveChangesAsync();
var smsService = new SmsVerificationService(context, new FakeSmsProvider());
await Assert.ThrowsAsync<InvalidCredentialsException>(() =>
smsService.VerifyCodeAsync(
seed.TenantId,
seed.Phone,
SmsPurpose.Login,
"123456"));
}
[Fact]
public async Task Revoked_refresh_token_cannot_be_refreshed()
{
await using var context = CreateContext();
var hasher = new PasswordHasher();
var seed = await SeedUserAsync(context, hasher.Hash("passw0rd!"));
var service = CreateAuthService(context);
var login = await service.LoginWithPasswordAsync(new PasswordLoginRequest(
seed.TenantId,
seed.Phone,
"passw0rd!",
null,
null));
await service.LogoutAsync(new LogoutSessionRequest(login.Tokens.RefreshToken));
await Assert.ThrowsAsync<SessionRevokedException>(() =>
service.RefreshAsync(new RefreshSessionRequest(
login.Tokens.RefreshToken,
null,
null)));
}
[Fact]
public async Task Wechat_miniapp_login_creates_user_identity_membership_and_session()
{
await using var context = CreateContext();
var tenantId = await SeedTenantWithWechatProviderAsync(context, "wechat-miniapp");
var service = CreateAuthService(
context,
new FakeWechatOAuthClient(
MiniAppIdentity: new WechatIdentity(
"mini-open-id",
"union-id",
null,
null,
"session-key",
"""{"openid":"mini-open-id","unionid":"union-id","session_key":"session-key"}""")));
var result = await service.LoginWithWechatMiniAppAsync(new WechatLoginRequest(
tenantId,
"wx-code",
null,
null));
Assert.Equal(tenantId, result.Tenant.TenantId);
Assert.Single(context.Users);
Assert.Contains(context.UserIdentities, identity =>
identity.Provider == "wechat_miniapp" &&
identity.ProviderSubject == "wx-app-id:mini-open-id" &&
identity.OpenId == "mini-open-id" &&
identity.UnionId == "union-id");
Assert.Contains(context.TenantMemberships, membership =>
membership.TenantId == tenantId &&
membership.UserId == result.UserId &&
membership.Status == MembershipStatus.Active);
Assert.Single(context.AuthSessions);
}
[Fact]
public async Task Wechat_union_id_reuses_existing_user_across_providers()
{
await using var context = CreateContext();
var tenantId = await SeedTenantWithWechatProviderAsync(context, "wechat-miniapp");
context.TenantExternalProviders.Add(new TenantExternalProvider
{
TenantId = tenantId,
Provider = "wechat_web",
Capability = TenantExternalProviderCapability.Identity,
Status = TenantExternalProviderStatus.Active,
SecretRef = "tenant_secrets:identity:wechat_web:default",
ConfigPublic = WechatProviderConfig()
});
var user = new User
{
Id = Guid.NewGuid(),
Name = "Existing"
};
context.Users.Add(user);
context.UserIdentities.Add(new UserIdentity
{
UserId = user.Id,
Provider = "wechat_web",
ProviderSubject = "wx-app-id:web-open-id",
OpenId = "web-open-id",
UnionId = "same-union"
});
await context.SaveChangesAsync();
var service = CreateAuthService(
context,
new FakeWechatOAuthClient(
MiniAppIdentity: new WechatIdentity(
"mini-open-id",
"same-union",
null,
null,
"session-key",
"""{"openid":"mini-open-id","unionid":"same-union","session_key":"session-key"}""")));
var result = await service.LoginWithWechatMiniAppAsync(new WechatLoginRequest(
tenantId,
"wx-code",
null,
null));
Assert.Equal(user.Id, result.UserId);
Assert.Single(context.Users);
Assert.Equal(2, context.UserIdentities.Count());
}
private static TikuDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<TikuDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new TikuDbContext(options);
}
private static IAuthService CreateAuthService(
TikuDbContext context,
IWechatOAuthClient? wechatOAuthClient = null)
{
var tokenService = new TokenService(Options.Create(JwtOptions));
var sessionService = new SessionService(context, tokenService, Options.Create(JwtOptions));
var smsService = new SmsVerificationService(context, new FakeSmsProvider());
return new AuthService(
context,
new PasswordHasher(),
smsService,
sessionService,
wechatOAuthClient ?? new FakeWechatOAuthClient(),
new FakeProviderConfigService(context));
}
private static async Task<Guid> SeedTenantWithWechatProviderAsync(
TikuDbContext context,
string provider)
{
var tenant = new Tenant
{
Id = Guid.NewGuid(),
Slug = Guid.NewGuid().ToString("N"),
Name = "Wechat Tenant"
};
context.Tenants.Add(tenant);
context.TenantExternalProviders.Add(new TenantExternalProvider
{
TenantId = tenant.Id,
Provider = provider.Replace("-", "_", StringComparison.Ordinal),
Capability = TenantExternalProviderCapability.Identity,
Status = TenantExternalProviderStatus.Active,
SecretRef = $"tenant_secrets:identity:{provider}:default",
ConfigPublic = WechatProviderConfig()
});
await context.SaveChangesAsync();
return tenant.Id;
}
private static JsonElement WechatProviderConfig()
{
return JsonSerializer.SerializeToElement(new
{
appId = "wx-app-id"
});
}
private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedUserAsync(
TikuDbContext context,
string passwordHash)
{
var tenant = new Tenant
{
Id = Guid.NewGuid(),
Slug = Guid.NewGuid().ToString("N"),
Name = "Test Tenant"
};
var user = new User
{
Id = Guid.NewGuid(),
Phone = "13800000000",
Name = "Test User"
};
var membership = new TenantMembership
{
TenantId = tenant.Id,
UserId = user.Id,
Role = TenantRole.Student,
Status = MembershipStatus.Active
};
var identity = new UserIdentity
{
UserId = user.Id,
Provider = "password",
ProviderSubject = user.Phone,
Phone = user.Phone,
SecretPayload = CreateSecretPayload(passwordHash)
};
context.Tenants.Add(tenant);
context.Users.Add(user);
context.TenantMemberships.Add(membership);
context.UserIdentities.Add(identity);
await context.SaveChangesAsync();
return (tenant.Id, user.Id, user.Phone);
}
private static JsonElement CreateSecretPayload(string passwordHash)
{
using var document = JsonDocument.Parse(
$$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}""");
return document.RootElement.Clone();
}
private sealed class FakeProviderConfigService(TikuDbContext context) : ITenantExternalProviderConfigService
{
public Task<TenantExternalProviderAccount> GetActiveProviderAsync(
Guid tenantId,
TenantExternalProviderCapability capability,
string? provider = null,
CancellationToken cancellationToken = default)
{
var normalizedProvider = provider?.Replace("-", "_", StringComparison.Ordinal);
var item = context.TenantExternalProviders
.AsEnumerable()
.Where(entity =>
entity.TenantId == tenantId &&
entity.Capability == capability &&
entity.Status == TenantExternalProviderStatus.Active)
.Where(entity => string.IsNullOrWhiteSpace(normalizedProvider) || entity.Provider == normalizedProvider)
.OrderBy(entity => entity.Priority)
.FirstOrDefault()
?? throw new TenantExternalProviderException(
"Tenant external provider is not configured.",
"tenant_external_provider_not_configured");
return Task.FromResult(new TenantExternalProviderAccount(
item.TenantId,
item.Capability,
item.Provider,
item.Status,
item.DisplayName,
item.ConfigPublic,
JsonSerializer.SerializeToElement(new { appSecret = "wx-app-secret" }),
item.SecretRef,
item.Priority,
item.Metadata));
await Assert.ThrowsAsync<InvalidCredentialsException>(() =>
fixture.AuthService.LoginWithPasswordAsync(new PasswordLoginRequest(
AuthRealm.Tenant, fixture.TenantId, AuthFixture.Phone, "wrong-password", null, null)));
}
public Task<IReadOnlyCollection<TenantExternalProviderItem>> GetProvidersAsync(
Guid tenantId,
TenantExternalProviderCapability? capability = null,
string? provider = null,
int? limit = null,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
var user = await fixture.UserManager.FindByIdAsync(fixture.UserId.ToString());
Assert.True(await fixture.UserManager.IsLockedOutAsync(user!));
Assert.NotNull(user!.LockoutEnd);
var events = await fixture.DbContext.AuthLoginEvents
.OrderBy(item => item.CreatedAt)
.ToArrayAsync();
Assert.Equal(5, events.Length);
Assert.Equal(AuthLoginResult.Blocked, events[^1].Result);
Assert.Equal("account_locked", events[^1].FailureCode);
}
public Task<TenantExternalProviderItem> UpsertProviderAsync(
Guid tenantId,
UpsertTenantExternalProviderCommand command,
CancellationToken cancellationToken = default) =>
[Fact]
public async Task Backend_permission_requires_one_time_mfa_enrollment_challenge()
{
await using var fixture = await AuthFixture.CreateAsync(includeBackendPermission: true);
var result = await fixture.AuthService.LoginWithPasswordAsync(new PasswordLoginRequest(
AuthRealm.Tenant, fixture.TenantId, AuthFixture.Phone, AuthFixture.Password, null, null));
Assert.Equal(AuthenticationStatus.MfaEnrollmentRequired, result.Status);
Assert.Null(result.User);
Assert.False(string.IsNullOrWhiteSpace(result.ChallengeToken));
Assert.Empty(await fixture.DbContext.AuthSessions.ToArrayAsync());
Assert.Single(await fixture.DbContext.AuthChallenges.ToArrayAsync());
}
[Fact]
public async Task Incomplete_authenticator_setup_still_requires_enrollment()
{
await using var fixture = await AuthFixture.CreateAsync(includeBackendPermission: true);
var user = await fixture.UserManager.FindByIdAsync(fixture.UserId.ToString());
Assert.True((await fixture.UserManager.ResetAuthenticatorKeyAsync(user!)).Succeeded);
Assert.False(user!.TwoFactorEnabled);
var result = await fixture.AuthService.LoginWithPasswordAsync(new PasswordLoginRequest(
AuthRealm.Tenant, fixture.TenantId, AuthFixture.Phone, AuthFixture.Password, null, null));
Assert.Equal(AuthenticationStatus.MfaEnrollmentRequired, result.Status);
}
[Fact]
public async Task Mfa_setup_audit_captures_request_origin()
{
await using var fixture = await AuthFixture.CreateAsync(includeBackendPermission: true);
var login = await fixture.AuthService.LoginWithPasswordAsync(new PasswordLoginRequest(
AuthRealm.Tenant, fixture.TenantId, AuthFixture.Phone, AuthFixture.Password, null, null));
await fixture.AuthService.SetupTotpAsync(new MfaChallengeRequest(
login.ChallengeToken!, null, "127.0.0.9", "mfa-audit-test"));
var audit = await fixture.DbContext.AuditLogs.SingleAsync(item =>
item.Action == "auth.mfa.enrollment_setup");
Assert.Equal("127.0.0.9", audit.IpAddress);
Assert.Equal("mfa-audit-test", audit.UserAgent);
}
private sealed class AuthFixture : IAsyncDisposable
{
public const string Password = "passw0rd!123";
public const string Phone = "13800000000";
private readonly ServiceProvider provider;
private readonly AsyncServiceScope scope;
private AuthFixture(ServiceProvider provider, AsyncServiceScope scope)
{
this.provider = provider;
this.scope = scope;
DbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
UserManager = scope.ServiceProvider.GetRequiredService<UserManager<User>>();
AuthService = scope.ServiceProvider.GetRequiredService<IAuthService>();
}
public TikuDbContext DbContext { get; }
public UserManager<User> UserManager { get; }
public IAuthService AuthService { get; }
public Guid TenantId { get; private set; }
public Guid UserId { get; private set; }
public static async Task<AuthFixture> CreateAsync(bool includeBackendPermission = false)
{
var services = new ServiceCollection();
services.AddLogging();
services.AddDataProtection();
services.AddAuthentication();
services.AddDbContext<TikuDbContext>(options =>
options.UseInMemoryDatabase(Guid.NewGuid().ToString("N")));
services.AddIdentityCore<User>(options =>
{
options.Password.RequiredLength = 10;
options.Password.RequireDigit = true;
options.Password.RequireLowercase = true;
options.Password.RequireUppercase = false;
options.Password.RequireNonAlphanumeric = false;
options.Lockout.MaxFailedAccessAttempts = 5;
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15);
})
.AddEntityFrameworkStores<TikuDbContext>()
.AddSignInManager()
.AddDefaultTokenProviders();
services.Configure<PasswordHasherOptions>(options => options.IterationCount = 210_000);
services.Configure<JwtOptions>(options =>
{
options.Issuer = "tiku-unit-tests";
options.Audience = "tiku-unit-tests";
options.KeyId = "unit-test-rsa";
});
services.AddSingleton<IJwtKeyRing, TestJwtKeyRing>();
services.AddScoped<ITokenService, TokenService>();
services.AddScoped<AuthSessionStore>();
services.AddScoped<IAuthSessionStore>(sp => sp.GetRequiredService<AuthSessionStore>());
services.AddScoped<ISmsVerificationService, RejectingSmsVerificationService>();
services.AddScoped<IWechatOAuthClient, RejectingWechatClient>();
services.AddScoped<ITenantExternalProviderConfigService, RejectingProviderConfigService>();
services.AddScoped<IAuthService, AuthService>();
var provider = services.BuildServiceProvider();
var scope = provider.CreateAsyncScope();
var fixture = new AuthFixture(provider, scope);
await fixture.SeedAsync(includeBackendPermission);
return fixture;
}
private async Task SeedAsync(bool includeBackendPermission)
{
var tenant = new Tenant { Id = Guid.NewGuid(), Slug = Guid.NewGuid().ToString("N"), Name = "Test" };
var user = new User { UserName = Phone, Phone = Phone, PhoneNumber = Phone, Name = "Test User" };
Assert.True((await UserManager.CreateAsync(user, Password)).Succeeded);
TenantId = tenant.Id;
UserId = user.Id;
DbContext.Tenants.Add(tenant);
DbContext.TenantMemberships.Add(new TenantMembership
{
TenantId = tenant.Id,
UserId = user.Id,
Role = TenantRole.Student,
Status = MembershipStatus.Active
});
if (includeBackendPermission)
{
var role = new TenantBackendRole
{
TenantId = tenant.Id,
Code = "teacher",
Name = "Teacher",
Status = BackendRoleStatus.Active
};
DbContext.TenantBackendRoles.Add(role);
DbContext.BackendPermissions.Add(new BackendPermission
{
Code = BackendPermissions.TenantDashboardView,
Name = "Dashboard",
Area = BackendPermissionArea.Tenant,
Module = "dashboard"
});
DbContext.TenantBackendUserRoles.Add(new TenantBackendUserRole
{
TenantId = tenant.Id,
UserId = user.Id,
RoleId = role.Id
});
DbContext.TenantBackendRolePermissions.Add(new TenantBackendRolePermission
{
TenantId = tenant.Id,
RoleId = role.Id,
PermissionCode = BackendPermissions.TenantDashboardView
});
}
await DbContext.SaveChangesAsync();
}
public async ValueTask DisposeAsync()
{
await scope.DisposeAsync();
await provider.DisposeAsync();
}
}
private sealed class TestJwtKeyRing : IJwtKeyRing, IDisposable
{
private readonly RSA rsa = RSA.Create(2048);
public TestJwtKeyRing()
{
var key = new RsaSecurityKey(rsa) { KeyId = "unit-test-rsa" };
SigningCredentials = new SigningCredentials(key, SecurityAlgorithms.RsaSha256);
ValidationKeys = [key];
}
public SigningCredentials SigningCredentials { get; }
public IReadOnlyCollection<SecurityKey> ValidationKeys { get; }
public void Dispose() => rsa.Dispose();
}
private sealed class RejectingSmsVerificationService : ISmsVerificationService
{
public Task<SmsSendResult> CreateCodeAsync(SendSmsCodeRequest request, CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public Task VerifyCodeAsync(Guid tenantId, string phone, SmsPurpose purpose, string code, CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
}
private sealed class FakeSmsProvider : ISmsProvider
private sealed class RejectingWechatClient : IWechatOAuthClient
{
public Task<SmsProviderSendResult> SendAsync(
SmsProviderSendRequest request,
CancellationToken cancellationToken = default) =>
Task.FromResult(new SmsProviderSendResult("fake", "accepted"));
public Task<WechatIdentity> ExchangeWebCodeAsync(WechatProviderOptions options, string code, CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public Task<WechatIdentity> ExchangeMiniAppCodeAsync(WechatProviderOptions options, string code, CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
}
private sealed class FailingSmsProvider : ISmsProvider
private sealed class RejectingProviderConfigService : ITenantExternalProviderConfigService
{
public Task<SmsProviderSendResult> SendAsync(
SmsProviderSendRequest request,
CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("provider unavailable");
}
private sealed class FakeWechatOAuthClient(
WechatIdentity? WebIdentity = null,
WechatIdentity? MiniAppIdentity = null) : IWechatOAuthClient
{
public Task<WechatIdentity> ExchangeWebCodeAsync(
WechatProviderOptions options,
string code,
CancellationToken cancellationToken = default)
{
return Task.FromResult(WebIdentity ?? new WechatIdentity(
"web-open-id",
"union-id",
"Wechat User",
"https://example.test/avatar.png",
null,
"""{"openid":"web-open-id","unionid":"union-id"}"""));
}
public Task<WechatIdentity> ExchangeMiniAppCodeAsync(
WechatProviderOptions options,
string code,
CancellationToken cancellationToken = default)
{
return Task.FromResult(MiniAppIdentity ?? new WechatIdentity(
"mini-open-id",
"union-id",
null,
null,
"session-key",
"""{"openid":"mini-open-id","unionid":"union-id","session_key":"session-key"}"""));
}
public Task<TenantExternalProviderAccount> GetActiveProviderAsync(Guid tenantId, TenantExternalProviderCapability capability, string? provider = null, CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public Task<IReadOnlyCollection<TenantExternalProviderItem>> GetProvidersAsync(Guid tenantId, TenantExternalProviderCapability? capability = null, string? provider = null, int? limit = null, CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public Task<TenantExternalProviderItem> UpsertProviderAsync(Guid tenantId, UpsertTenantExternalProviderCommand command, CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
}
}

View File

@@ -0,0 +1,231 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Tiku.Application.Auth;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Auth;
using Tiku.Infrastructure.Persistence;
namespace Tiku.UnitTests.Auth;
public sealed class SmsVerificationServiceTests
{
private const string Pepper = "unit-test-sms-code-pepper-32-characters";
[Fact]
public void Hash_uses_the_server_pepper()
{
var tenantId = Guid.NewGuid();
var first = SmsCodeHashing.Hash(tenantId, "13800000000", SmsPurpose.Login, "123456", Pepper);
var second = SmsCodeHashing.Hash(
tenantId,
"13800000000",
SmsPurpose.Login,
"123456",
"another-unit-test-pepper-32-characters");
Assert.NotEqual(first, second);
Assert.Equal(64, first.Length);
}
[Fact]
public async Task Create_code_generates_six_digits_and_consumes_all_available_rate_limit_dimensions()
{
await using var context = CreateContext();
var tenantId = await SeedTenantAsync(context);
var provider = new CapturingSmsProvider();
var service = CreateService(context, provider);
await service.CreateCodeAsync(new SendSmsCodeRequest(
tenantId,
"13800000000",
SmsPurpose.Login,
"127.0.0.1",
"test-device"));
Assert.Matches("^[0-9]{6}$", Assert.Single(provider.Requests).Code);
Assert.Equal(4, context.SmsSendRateLimits.Count());
Assert.Contains(context.SmsSendRateLimits, item => item.Dimension == SmsRateLimitDimension.Tenant);
Assert.Contains(context.SmsSendRateLimits, item => item.Dimension == SmsRateLimitDimension.Phone);
Assert.Contains(context.SmsSendRateLimits, item => item.Dimension == SmsRateLimitDimension.Ip);
Assert.Contains(context.SmsSendRateLimits, item => item.Dimension == SmsRateLimitDimension.Device);
var sendEvent = Assert.Single(context.AuthLoginEvents);
Assert.Equal(AuthLoginResult.Sent, sendEvent.Result);
Assert.Equal("sms", sendEvent.Provider);
}
[Fact]
public async Task Fifth_invalid_attempt_blocks_code_and_correct_code_is_then_rejected()
{
await using var context = CreateContext();
var tenantId = await SeedTenantAsync(context);
var verification = await SeedCodeAsync(context, tenantId, "123456");
var service = CreateService(context);
for (var attempt = 0; attempt < 5; attempt++)
{
await Assert.ThrowsAsync<InvalidCredentialsException>(() =>
service.VerifyCodeAsync(tenantId, "13800000000", SmsPurpose.Login, "999999"));
}
context.ChangeTracker.Clear();
var blocked = await context.SmsVerificationCodes.FindAsync(verification.Id);
Assert.NotNull(blocked);
Assert.Equal(5, blocked.Attempts);
Assert.Equal(SmsVerificationStatus.Blocked, blocked.Status);
await Assert.ThrowsAsync<InvalidCredentialsException>(() =>
service.VerifyCodeAsync(tenantId, "13800000000", SmsPurpose.Login, "123456"));
}
[Fact]
public async Task Expired_code_is_transitioned_to_expired()
{
await using var context = CreateContext();
var tenantId = await SeedTenantAsync(context);
var verification = await SeedCodeAsync(
context,
tenantId,
"123456",
DateTimeOffset.UtcNow.AddSeconds(-1));
var service = CreateService(context);
await Assert.ThrowsAsync<InvalidCredentialsException>(() =>
service.VerifyCodeAsync(tenantId, "13800000000", SmsPurpose.Login, "123456"));
context.ChangeTracker.Clear();
Assert.Equal(
SmsVerificationStatus.Expired,
(await context.SmsVerificationCodes.FindAsync(verification.Id))!.Status);
}
[Fact]
public async Task Successful_code_can_only_be_consumed_once()
{
await using var context = CreateContext();
var tenantId = await SeedTenantAsync(context);
var verification = await SeedCodeAsync(context, tenantId, "123456");
var service = CreateService(context);
await service.VerifyCodeAsync(tenantId, "13800000000", SmsPurpose.Login, "123456");
await Assert.ThrowsAsync<InvalidCredentialsException>(() =>
service.VerifyCodeAsync(tenantId, "13800000000", SmsPurpose.Login, "123456"));
context.ChangeTracker.Clear();
var consumed = await context.SmsVerificationCodes.FindAsync(verification.Id);
Assert.Equal(SmsVerificationStatus.Verified, consumed!.Status);
Assert.NotNull(consumed.ConsumedAt);
}
[Fact]
public async Task Phone_limit_applies_across_ip_and_device_changes()
{
await using var context = CreateContext();
var tenantId = await SeedTenantAsync(context);
var options = new SmsSecurityOptions
{
CodePepper = Pepper,
TenantRequestsPerHour = 100,
PhoneRequestsPerHour = 1,
IpRequestsPerHour = 20,
DeviceRequestsPerHour = 10
};
var service = CreateService(context, options: options);
await service.CreateCodeAsync(new SendSmsCodeRequest(
tenantId,
"13800000000",
SmsPurpose.Login,
"127.0.0.1",
"device-one"));
await Assert.ThrowsAsync<SmsRateLimitedException>(() =>
service.CreateCodeAsync(new SendSmsCodeRequest(
tenantId,
"13800000000",
SmsPurpose.Login,
"127.0.0.2",
"device-two")));
}
private static SmsVerificationService CreateService(
TikuDbContext context,
ISmsProvider? provider = null,
SmsSecurityOptions? options = null)
{
return new SmsVerificationService(
context,
provider ?? new CapturingSmsProvider(),
Options.Create(options ?? ValidOptions()));
}
private static SmsSecurityOptions ValidOptions()
{
return new SmsSecurityOptions
{
CodePepper = Pepper,
TenantRequestsPerHour = 100,
PhoneRequestsPerHour = 5,
IpRequestsPerHour = 20,
DeviceRequestsPerHour = 10
};
}
private static TikuDbContext CreateContext()
{
return new TikuDbContext(
new DbContextOptionsBuilder<TikuDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options);
}
private static async Task<Guid> SeedTenantAsync(TikuDbContext context)
{
var tenant = new Tenant
{
Id = Guid.NewGuid(),
Slug = Guid.NewGuid().ToString("N"),
Name = "SMS Test Tenant"
};
context.Tenants.Add(tenant);
await context.SaveChangesAsync();
return tenant.Id;
}
private static async Task<SmsVerificationCode> SeedCodeAsync(
TikuDbContext context,
Guid tenantId,
string code,
DateTimeOffset? expiresAt = null)
{
var verification = new SmsVerificationCode
{
TenantId = tenantId,
Phone = "13800000000",
Purpose = SmsPurpose.Login,
CodeHash = SmsCodeHashing.Hash(
tenantId,
"13800000000",
SmsPurpose.Login,
code,
Pepper),
Status = SmsVerificationStatus.Sent,
ExpiresAt = expiresAt ?? DateTimeOffset.UtcNow.AddMinutes(5)
};
context.SmsVerificationCodes.Add(verification);
await context.SaveChangesAsync();
return verification;
}
private sealed class CapturingSmsProvider : ISmsProvider
{
public List<SmsProviderSendRequest> Requests { get; } = [];
public Task<SmsProviderSendResult> SendAsync(
SmsProviderSendRequest request,
CancellationToken cancellationToken = default)
{
Requests.Add(request);
return Task.FromResult(new SmsProviderSendResult("test", "sent", "message-id"));
}
}
}