feat: strengthen P0 security and operations
This commit is contained in:
289
Tiku.IntegrationTests/Api/AuthRecoveryAndDeviceEndpointTests.cs
Normal file
289
Tiku.IntegrationTests/Api/AuthRecoveryAndDeviceEndpointTests.cs
Normal file
@@ -0,0 +1,289 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.IntegrationTests.Api;
|
||||
|
||||
public sealed class AuthRecoveryAndDeviceEndpointTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Password_reset_send_does_not_reveal_account_existence()
|
||||
{
|
||||
var provider = new CapturingSmsProvider();
|
||||
await using var factory = new ApiTestFactory(smsProvider: provider);
|
||||
var seed = await SeedUserAsync(factory);
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("x-tenant-code", seed.TenantId.ToString("N"));
|
||||
|
||||
var existing = await client.PostAsJsonAsync(
|
||||
"/api/auth/password/reset/sms/send",
|
||||
new PasswordResetSmsSendDto
|
||||
{
|
||||
TenantCode = seed.TenantId.ToString("N"),
|
||||
Phone = seed.Phone,
|
||||
DeviceId = "known-device"
|
||||
});
|
||||
var missing = await client.PostAsJsonAsync(
|
||||
"/api/auth/password/reset/sms/send",
|
||||
new PasswordResetSmsSendDto
|
||||
{
|
||||
TenantCode = seed.TenantId.ToString("N"),
|
||||
Phone = "13999999999",
|
||||
DeviceId = "unknown-device"
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.Accepted, existing.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Accepted, missing.StatusCode);
|
||||
Assert.Equal(1, provider.SendCount);
|
||||
Assert.DoesNotContain(provider.Code!, await existing.Content.ReadAsStringAsync(), StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(provider.Code!, await missing.Content.ReadAsStringAsync(), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Password_reset_consumes_reset_code_and_revokes_existing_sessions()
|
||||
{
|
||||
var provider = new CapturingSmsProvider();
|
||||
await using var factory = new ApiTestFactory(smsProvider: provider);
|
||||
var seed = await SeedUserAsync(factory);
|
||||
using var client = factory.CreateClient();
|
||||
var oldTokens = await client.LoginAsTenantAsync(seed.TenantId, seed.Phone);
|
||||
|
||||
var send = await client.PostAsJsonAsync(
|
||||
"/api/auth/password/reset/sms/send",
|
||||
new PasswordResetSmsSendDto
|
||||
{
|
||||
TenantCode = seed.TenantId.ToString("N"),
|
||||
Phone = seed.Phone,
|
||||
DeviceId = "reset-device"
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.Accepted, send.StatusCode);
|
||||
Assert.NotNull(provider.Code);
|
||||
|
||||
var resetRequest = new PasswordResetDto
|
||||
{
|
||||
TenantCode = seed.TenantId.ToString("N"),
|
||||
Phone = seed.Phone,
|
||||
Code = provider.Code!,
|
||||
NewPassword = "ResetPassword2026"
|
||||
};
|
||||
var reset = await client.PostAsJsonAsync("/api/auth/password/reset", resetRequest);
|
||||
Assert.Equal(HttpStatusCode.NoContent, reset.StatusCode);
|
||||
|
||||
client.UseAccessToken(oldTokens);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, (await client.GetAsync("/api/me")).StatusCode);
|
||||
Assert.Equal(
|
||||
HttpStatusCode.Unauthorized,
|
||||
(await PostPasswordLoginAsync(client, seed, PasswordTestUserExtensions.TestPassword)).StatusCode);
|
||||
Assert.Equal(
|
||||
HttpStatusCode.OK,
|
||||
(await PostPasswordLoginAsync(client, seed, resetRequest.NewPassword)).StatusCode);
|
||||
Assert.Equal(
|
||||
HttpStatusCode.Unauthorized,
|
||||
(await client.PostAsJsonAsync("/api/auth/password/reset", resetRequest)).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Authenticated_password_change_rotates_to_a_new_session()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var seed = await SeedUserAsync(factory);
|
||||
using var client = factory.CreateClient();
|
||||
var oldTokens = await client.LoginAsTenantAsync(seed.TenantId, seed.Phone);
|
||||
client.UseAccessToken(oldTokens);
|
||||
|
||||
var changed = await client.PostAsJsonAsync(
|
||||
"/api/auth/password/change",
|
||||
new AuthenticatedPasswordChangeDto
|
||||
{
|
||||
CurrentPassword = PasswordTestUserExtensions.TestPassword,
|
||||
NewPassword = "ChangedPassword2026"
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.OK, changed.StatusCode);
|
||||
using var body = JsonDocument.Parse(await changed.Content.ReadAsStringAsync());
|
||||
var accessToken = body.RootElement.GetProperty("user").GetProperty("tokens").GetProperty("accessToken").GetString();
|
||||
var refreshToken = body.RootElement.GetProperty("user").GetProperty("tokens").GetProperty("refreshToken").GetString();
|
||||
Assert.False(string.IsNullOrWhiteSpace(accessToken));
|
||||
Assert.False(string.IsNullOrWhiteSpace(refreshToken));
|
||||
|
||||
client.UseAccessToken(oldTokens);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, (await client.GetAsync("/api/me")).StatusCode);
|
||||
client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
|
||||
Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/api/me")).StatusCode);
|
||||
Assert.Equal(
|
||||
HttpStatusCode.Unauthorized,
|
||||
(await PostPasswordLoginAsync(client, seed, PasswordTestUserExtensions.TestPassword)).StatusCode);
|
||||
Assert.Equal(
|
||||
HttpStatusCode.OK,
|
||||
(await PostPasswordLoginAsync(client, seed, "ChangedPassword2026")).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Device_sessions_are_scoped_and_only_other_owned_families_can_be_revoked()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var seed = await SeedUserAsync(factory);
|
||||
var other = await SeedUserAsync(factory);
|
||||
using var firstClient = factory.CreateClient();
|
||||
using var secondClient = factory.CreateClient();
|
||||
using var otherClient = factory.CreateClient();
|
||||
_ = await firstClient.LoginAsTenantAsync(seed.TenantId, seed.Phone);
|
||||
var secondTokens = await secondClient.LoginAsTenantAsync(seed.TenantId, seed.Phone);
|
||||
var otherTokens = await otherClient.LoginAsTenantAsync(other.TenantId, other.Phone);
|
||||
secondClient.UseAccessToken(secondTokens);
|
||||
otherClient.UseAccessToken(otherTokens);
|
||||
|
||||
using var sessions = JsonDocument.Parse(await (await secondClient.GetAsync("/api/me/sessions")).Content.ReadAsStringAsync());
|
||||
var items = sessions.RootElement.EnumerateArray().ToArray();
|
||||
Assert.Equal(2, items.Length);
|
||||
var currentFamily = items.Single(item => item.GetProperty("isCurrent").GetBoolean())
|
||||
.GetProperty("sessionFamilyId").GetGuid();
|
||||
var otherOwnedFamily = items.Single(item => !item.GetProperty("isCurrent").GetBoolean())
|
||||
.GetProperty("sessionFamilyId").GetGuid();
|
||||
|
||||
Assert.Equal(
|
||||
HttpStatusCode.Conflict,
|
||||
(await secondClient.DeleteAsync($"/api/me/sessions/{currentFamily}")).StatusCode);
|
||||
Assert.Equal(
|
||||
HttpStatusCode.NotFound,
|
||||
(await otherClient.DeleteAsync($"/api/me/sessions/{otherOwnedFamily}")).StatusCode);
|
||||
Assert.Equal(
|
||||
HttpStatusCode.NoContent,
|
||||
(await secondClient.DeleteAsync($"/api/me/sessions/{otherOwnedFamily}")).StatusCode);
|
||||
using var remaining = JsonDocument.Parse(await (await secondClient.GetAsync("/api/me/sessions")).Content.ReadAsStringAsync());
|
||||
Assert.Single(remaining.RootElement.EnumerateArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tenant_administrator_reset_requires_same_tenant_and_forces_password_change()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var admin = await SeedUserAsync(factory);
|
||||
var target = await SeedMemberAsync(factory, admin.TenantId, TenantRole.Teacher);
|
||||
var crossTenantTarget = await SeedUserAsync(factory);
|
||||
using var targetClient = factory.CreateClient();
|
||||
var targetTokens = await targetClient.LoginAsTenantAsync(target.TenantId, target.Phone);
|
||||
using var adminClient = factory.CreateClient();
|
||||
adminClient.UseAccessToken(await adminClient.LoginAsTenantAsync(admin.TenantId, admin.Phone));
|
||||
|
||||
var reset = await adminClient.PostAsJsonAsync(
|
||||
$"/api/tenant-admin/members/{target.UserId}/password-reset",
|
||||
new AdministrativePasswordResetDto
|
||||
{
|
||||
TemporaryPassword = "TemporaryPassword2026",
|
||||
Reason = "Account recovery verification"
|
||||
});
|
||||
var crossTenant = await adminClient.PostAsJsonAsync(
|
||||
$"/api/tenant-admin/members/{crossTenantTarget.UserId}/password-reset",
|
||||
new AdministrativePasswordResetDto
|
||||
{
|
||||
TemporaryPassword = "TemporaryPassword2026",
|
||||
Reason = "Must not cross tenant boundary"
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.NoContent, reset.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, crossTenant.StatusCode);
|
||||
targetClient.UseAccessToken(targetTokens);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, (await targetClient.GetAsync("/api/me")).StatusCode);
|
||||
var temporaryLogin = await PostPasswordLoginAsync(targetClient, target, "TemporaryPassword2026");
|
||||
Assert.Equal(HttpStatusCode.OK, temporaryLogin.StatusCode);
|
||||
Assert.Contains("password_change_required", await temporaryLogin.Content.ReadAsStringAsync(), StringComparison.Ordinal);
|
||||
|
||||
using var scope = factory.CreateSystemScope("Verify administrative password reset");
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
Assert.True(await dbContext.Users.Where(item => item.Id == target.UserId).Select(item => item.ForcePasswordChange).SingleAsync());
|
||||
Assert.True(await dbContext.AuditLogs.AnyAsync(item =>
|
||||
item.TenantId == admin.TenantId &&
|
||||
item.ActorUserId == admin.UserId &&
|
||||
item.Action == "auth.password.reset_by_administrator" &&
|
||||
item.TargetId == target.UserId.ToString()));
|
||||
}
|
||||
|
||||
private static Task<HttpResponseMessage> PostPasswordLoginAsync(
|
||||
HttpClient client,
|
||||
UserSeed seed,
|
||||
string password) =>
|
||||
client.PostAsJsonAsync(
|
||||
"/api/auth/login/password",
|
||||
new PasswordLoginDto
|
||||
{
|
||||
Realm = AuthRealm.Tenant,
|
||||
TenantCode = seed.TenantId.ToString("N"),
|
||||
Identifier = seed.Phone,
|
||||
Password = password
|
||||
});
|
||||
|
||||
private static async Task<UserSeed> SeedUserAsync(ApiTestFactory factory)
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
var user = new User
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Phone = $"13{Random.Shared.NextInt64(100_000_000, 1_000_000_000)}",
|
||||
Name = "Recovery test user"
|
||||
}.WithTestPassword();
|
||||
await factory.SeedAsync(
|
||||
new Tenant
|
||||
{
|
||||
Id = tenantId,
|
||||
Slug = tenantId.ToString("N"),
|
||||
Name = "Recovery test tenant",
|
||||
Status = TenantStatus.Active
|
||||
},
|
||||
user,
|
||||
new TenantMembership
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = user.Id,
|
||||
Role = TenantRole.TenantAdmin,
|
||||
Status = MembershipStatus.Active
|
||||
});
|
||||
return new UserSeed(tenantId, user.Id, user.Phone!);
|
||||
}
|
||||
|
||||
private static async Task<UserSeed> SeedMemberAsync(
|
||||
ApiTestFactory factory,
|
||||
Guid tenantId,
|
||||
TenantRole role)
|
||||
{
|
||||
var user = new User
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Phone = $"13{Random.Shared.NextInt64(100_000_000, 1_000_000_000)}",
|
||||
Name = "Administrative reset target"
|
||||
}.WithTestPassword();
|
||||
await factory.SeedAsync(
|
||||
user,
|
||||
new TenantMembership
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = user.Id,
|
||||
Role = role,
|
||||
Status = MembershipStatus.Active
|
||||
});
|
||||
return new UserSeed(tenantId, user.Id, user.Phone!);
|
||||
}
|
||||
|
||||
private sealed record UserSeed(Guid TenantId, Guid UserId, string Phone);
|
||||
|
||||
private sealed class CapturingSmsProvider : ISmsProvider
|
||||
{
|
||||
public int SendCount { get; private set; }
|
||||
public string? Code { get; private set; }
|
||||
|
||||
public Task<SmsProviderSendResult> SendAsync(
|
||||
SmsProviderSendRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
SendCount++;
|
||||
Code = request.Code;
|
||||
return Task.FromResult(new SmsProviderSendResult("test", "sent", "reset-message-id"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user