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; namespace Tiku.UnitTests.Auth; public sealed class AuthServiceTests { [Fact] public void Identity_password_hasher_uses_current_identity_format() { var user = new User(); var hasher = new PasswordHasher(Options.Create(new PasswordHasherOptions { IterationCount = 210_000 })); var hash = hasher.HashPassword(user, "passw0rd!"); Assert.Equal(PasswordVerificationResult.Success, hasher.VerifyHashedPassword(user, hash, "passw0rd!")); Assert.Equal(PasswordVerificationResult.Failed, hasher.VerifyHashedPassword(user, hash, "wrong")); } [Theory] [InlineData("abc12345", true)] [InlineData("ABC12345", true)] [InlineData("abcdefgh", false)] [InlineData("12345678", false)] [InlineData("abc1234", false)] public async Task Default_password_policy_requires_eight_characters_letters_and_digits( string password, bool expectedSuccess) { var validator = new LetterAndDigitPasswordValidator(); var result = await validator.ValidateAsync(null!, new User(), password); Assert.Equal(expectedSuccess, result.Succeeded); } [Fact] public async Task Password_login_issues_tenant_session_for_regular_member() { await using var fixture = await AuthFixture.CreateAsync(); var result = await fixture.AuthService.LoginWithPasswordAsync(new PasswordLoginRequest( AuthRealm.Tenant, fixture.TenantId, AuthFixture.Phone, AuthFixture.Password, "127.0.0.1", "unit-test")); 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 Five_wrong_passwords_lock_the_identity_account() { await using var fixture = await AuthFixture.CreateAsync(); for (var attempt = 0; attempt < 5; attempt++) { await Assert.ThrowsAsync(() => fixture.AuthService.LoginWithPasswordAsync(new PasswordLoginRequest( AuthRealm.Tenant, fixture.TenantId, AuthFixture.Phone, "wrong-password", null, null))); } 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); } [Fact] public async Task Backend_permission_user_authenticates_without_an_additional_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.Authenticated, result.Status); Assert.NotNull(result.User); Assert.Single(await fixture.DbContext.AuthSessions.ToArrayAsync()); Assert.Empty(await fixture.DbContext.AuthChallenges.ToArrayAsync()); } 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(); UserManager = scope.ServiceProvider.GetRequiredService>(); AuthService = scope.ServiceProvider.GetRequiredService(); } public TikuDbContext DbContext { get; } public UserManager UserManager { get; } public IAuthService AuthService { get; } public Guid TenantId { get; private set; } public Guid UserId { get; private set; } public static async Task CreateAsync(bool includeBackendPermission = false) { var services = new ServiceCollection(); services.AddLogging(); services.AddDataProtection(); services.AddAuthentication(); services.AddDbContext(options => options.UseInMemoryDatabase(Guid.NewGuid().ToString("N"))); services.AddIdentityCore(options => { options.Password.RequiredLength = 8; options.Password.RequireDigit = false; options.Password.RequireLowercase = false; options.Password.RequireUppercase = false; options.Password.RequireNonAlphanumeric = false; options.Lockout.MaxFailedAccessAttempts = 5; options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15); }) .AddEntityFrameworkStores() .AddSignInManager() .AddDefaultTokenProviders() .AddPasswordValidator>(); services.Configure(options => options.IterationCount = 210_000); services.Configure(options => { options.Issuer = "tiku-unit-tests"; options.Audience = "tiku-unit-tests"; options.KeyId = "unit-test-rsa"; }); services.AddSingleton(); services.AddScoped(); services.AddScoped(); services.AddScoped(sp => sp.GetRequiredService()); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); 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 ValidationKeys { get; } public void Dispose() => rsa.Dispose(); } private sealed class RejectingSmsVerificationService : ISmsVerificationService { public Task 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 RejectingWechatClient : IWechatOAuthClient { public Task ExchangeWebCodeAsync(WechatProviderOptions options, string code, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task ExchangeMiniAppCodeAsync(WechatProviderOptions options, string code, CancellationToken cancellationToken = default) => throw new NotSupportedException(); } private sealed class RejectingProviderConfigService : ITenantExternalProviderConfigService { public Task GetActiveProviderAsync(Guid tenantId, TenantExternalProviderCapability capability, string? provider = null, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task> GetProvidersAsync(Guid tenantId, TenantExternalProviderCapability? capability = null, string? provider = null, int? limit = null, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task UpsertProviderAsync(Guid tenantId, UpsertTenantExternalProviderCommand command, CancellationToken cancellationToken = default) => throw new NotSupportedException(); } }