using System.Net; using System.Net.Http.Json; using System.Reflection; using System.Text.Json; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Tiku.Api.Contracts; using Tiku.Api.Controllers; using Tiku.Domain.Identity; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; namespace Tiku.IntegrationTests.Api; public sealed class AuthMfaLifecycleTests { [Fact] public async Task Enrollment_returns_recovery_codes_once_then_subsequent_login_requires_mfa() { await using var factory = new ApiTestFactory(); var seed = await SeedBackendUserAsync(factory); using var client = factory.CreateClient(); client.DefaultRequestHeaders.Add("x-tenant-code", seed.TenantId.ToString("N")); using var login = await PostPasswordLoginAsync(client, seed); Assert.Equal("mfa_enrollment_required", login.RootElement.GetProperty("status").GetString()); var challengeToken = login.RootElement.GetProperty("challengeToken").GetString()!; var setupResponse = await client.PostAsJsonAsync( "/api/auth/mfa/totp/setup", new MfaChallengeDto { ChallengeToken = challengeToken }); setupResponse.EnsureSuccessStatusCode(); using var setup = JsonDocument.Parse(await setupResponse.Content.ReadAsStringAsync()); var sharedKey = setup.RootElement.GetProperty("sharedKey").GetString()!; var confirmRequest = new MfaChallengeDto { ChallengeToken = challengeToken, Code = AuthenticationTestClientExtensions.GenerateTotp(sharedKey) }; var confirmResponse = await client.PostAsJsonAsync("/api/auth/mfa/totp/confirm", confirmRequest); confirmResponse.EnsureSuccessStatusCode(); using var confirmation = JsonDocument.Parse(await confirmResponse.Content.ReadAsStringAsync()); Assert.Equal( "authenticated", confirmation.RootElement.GetProperty("authentication").GetProperty("status").GetString()); Assert.Equal(10, confirmation.RootElement.GetProperty("recoveryCodes").GetArrayLength()); var recoveryCode = confirmation.RootElement.GetProperty("recoveryCodes")[0].GetString()!; var replayResponse = await client.PostAsJsonAsync("/api/auth/mfa/totp/confirm", confirmRequest); Assert.Equal(HttpStatusCode.Unauthorized, replayResponse.StatusCode); using var nextLogin = await PostPasswordLoginAsync(client, seed); Assert.Equal("mfa_required", nextLogin.RootElement.GetProperty("status").GetString()); Assert.False(nextLogin.RootElement.TryGetProperty("recoveryCodes", out _)); var recoveryResponse = await client.PostAsJsonAsync( "/api/auth/mfa/totp/verify", new MfaChallengeDto { ChallengeToken = nextLogin.RootElement.GetProperty("challengeToken").GetString()!, Code = recoveryCode }); recoveryResponse.EnsureSuccessStatusCode(); using var finalLogin = await PostPasswordLoginAsync(client, seed); var replayedRecoveryResponse = await client.PostAsJsonAsync( "/api/auth/mfa/totp/verify", new MfaChallengeDto { ChallengeToken = finalLogin.RootElement.GetProperty("challengeToken").GetString()!, Code = recoveryCode }); Assert.Equal(HttpStatusCode.Unauthorized, replayedRecoveryResponse.StatusCode); using var scope = factory.CreateSystemScope("Verify recovery code audit"); var dbContext = scope.ServiceProvider.GetRequiredService(); var recoveryAudits = await dbContext.AuditLogs .Where(item => item.Action == "auth.mfa.verified") .ToArrayAsync(); var recoveryAudit = Assert.Single(recoveryAudits, item => item.Details.ToString().Contains("recovery_code", StringComparison.Ordinal)); Assert.Equal(seed.TenantId, recoveryAudit.TenantId); } [Fact] public async Task Forced_password_change_precedes_mfa_enrollment() { await using var factory = new ApiTestFactory(); var seed = await SeedBackendUserAsync(factory, forcePasswordChange: true); using var client = factory.CreateClient(); client.DefaultRequestHeaders.Add("x-tenant-code", seed.TenantId.ToString("N")); using var login = await PostPasswordLoginAsync(client, seed); Assert.Equal("password_change_required", login.RootElement.GetProperty("status").GetString()); Assert.False(string.IsNullOrWhiteSpace(login.RootElement.GetProperty("challengeToken").GetString())); } [Theory] [InlineData(nameof(AuthController.LoginWithPassword), "login/password")] [InlineData(nameof(AuthController.SendSmsCode), "sms/send")] [InlineData(nameof(AuthController.LoginWithSms), "login/sms")] [InlineData(nameof(AuthController.LoginWithWechatWeb), "oauth/wechat")] [InlineData(nameof(AuthController.LoginWithWechatMiniApp), "oauth/wechat-miniapp")] [InlineData(nameof(AuthController.SetupTotp), "mfa/totp/setup")] [InlineData(nameof(AuthController.ConfirmTotp), "mfa/totp/confirm")] [InlineData(nameof(AuthController.VerifyTotp), "mfa/totp/verify")] [InlineData(nameof(AuthController.Refresh), "refresh")] [InlineData(nameof(AuthController.Logout), "logout")] [InlineData(nameof(AuthController.LogoutAll), "logout-all")] public void Authentication_routes_match_the_v2_contract(string actionName, string route) { var action = typeof(AuthController).GetMethod(actionName, BindingFlags.Public | BindingFlags.Instance); var attribute = action?.GetCustomAttribute(); Assert.NotNull(attribute); Assert.Equal(route, attribute.Template); } private static async Task PostPasswordLoginAsync( HttpClient client, (Guid TenantId, string Phone) seed) { var response = await client.PostAsJsonAsync( "/api/auth/login/password", new PasswordLoginDto { Realm = AuthRealm.Tenant, TenantCode = seed.TenantId.ToString("N"), Identifier = seed.Phone, Password = PasswordTestUserExtensions.TestPassword }); response.EnsureSuccessStatusCode(); return JsonDocument.Parse(await response.Content.ReadAsStringAsync()); } private static async Task<(Guid TenantId, string Phone)> SeedBackendUserAsync( ApiTestFactory factory, bool forcePasswordChange = false) { var tenantId = Guid.NewGuid(); var userId = Guid.NewGuid(); const string phone = "13800000000"; await factory.SeedAsync( new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "MFA Lifecycle Tenant" }, new User { Id = userId, Phone = phone, Name = "MFA Lifecycle User", ForcePasswordChange = forcePasswordChange }.WithTestPassword(), new TenantMembership { TenantId = tenantId, UserId = userId, Role = TenantRole.TenantAdmin, Status = MembershipStatus.Active }); return (tenantId, phone); } }