forked from gongxuegit/tiku-backend.net
feat: harden SaaS authentication and authorization
This commit is contained in:
169
Tiku.IntegrationTests/Api/AuthMfaLifecycleTests.cs
Normal file
169
Tiku.IntegrationTests/Api/AuthMfaLifecycleTests.cs
Normal file
@@ -0,0 +1,169 @@
|
||||
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<TikuDbContext>();
|
||||
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<HttpPostAttribute>();
|
||||
|
||||
Assert.NotNull(attribute);
|
||||
Assert.Equal(route, attribute.Template);
|
||||
}
|
||||
|
||||
private static async Task<JsonDocument> 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user