feat: strengthen P0 security and operations

This commit is contained in:
2026-08-01 11:20:02 +08:00
parent f776056834
commit 84c2b0b21d
77 changed files with 24185 additions and 355 deletions

View 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"));
}
}
}