forked from xiongyuxing/tiku-backend.net
feat: harden SaaS authentication and authorization
This commit is contained in:
@@ -1,10 +1,14 @@
|
||||
using System.Text.Json;
|
||||
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;
|
||||
@@ -13,495 +17,264 @@ namespace Tiku.UnitTests.Auth;
|
||||
|
||||
public sealed class AuthServiceTests
|
||||
{
|
||||
private static readonly JwtOptions JwtOptions = new()
|
||||
{
|
||||
Issuer = "tiku-unit-tests",
|
||||
Audience = "tiku-api-unit-tests",
|
||||
SigningKey = "unit-test-signing-key-that-is-long-enough"
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Password_hasher_verifies_own_hash()
|
||||
public void Identity_password_hasher_uses_current_identity_format()
|
||||
{
|
||||
var hasher = new PasswordHasher();
|
||||
var user = new User();
|
||||
var hasher = new PasswordHasher<User>(Options.Create(new PasswordHasherOptions
|
||||
{
|
||||
IterationCount = 210_000
|
||||
}));
|
||||
var hash = hasher.HashPassword(user, "passw0rd!");
|
||||
|
||||
var hash = hasher.Hash("passw0rd!");
|
||||
|
||||
Assert.True(hasher.Verify("passw0rd!", hash));
|
||||
Assert.False(hasher.Verify("wrong", hash));
|
||||
Assert.Equal(PasswordVerificationResult.Success, hasher.VerifyHashedPassword(user, hash, "passw0rd!"));
|
||||
Assert.Equal(PasswordVerificationResult.Failed, hasher.VerifyHashedPassword(user, hash, "wrong"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Password_login_creates_session_and_success_event()
|
||||
public async Task Password_login_issues_tenant_session_for_regular_member()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var hasher = new PasswordHasher();
|
||||
var seed = await SeedUserAsync(context, hasher.Hash("passw0rd!"));
|
||||
var service = CreateAuthService(context);
|
||||
await using var fixture = await AuthFixture.CreateAsync();
|
||||
|
||||
var result = await service.LoginWithPasswordAsync(new PasswordLoginRequest(
|
||||
seed.TenantId,
|
||||
seed.Phone,
|
||||
"passw0rd!",
|
||||
"127.0.0.1",
|
||||
"unit-test"));
|
||||
var result = await fixture.AuthService.LoginWithPasswordAsync(new PasswordLoginRequest(
|
||||
AuthRealm.Tenant, fixture.TenantId, AuthFixture.Phone, AuthFixture.Password, "127.0.0.1", "unit-test"));
|
||||
|
||||
Assert.Equal(seed.UserId, result.UserId);
|
||||
Assert.False(string.IsNullOrWhiteSpace(result.Tokens.AccessToken));
|
||||
Assert.False(string.IsNullOrWhiteSpace(result.Tokens.RefreshToken));
|
||||
Assert.Single(context.AuthSessions);
|
||||
Assert.Contains(context.AuthLoginEvents, entity => entity.Result == AuthLoginResult.Success);
|
||||
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 Wrong_password_records_failed_event()
|
||||
public async Task Five_wrong_passwords_lock_the_identity_account()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var hasher = new PasswordHasher();
|
||||
var seed = await SeedUserAsync(context, hasher.Hash("passw0rd!"));
|
||||
var service = CreateAuthService(context);
|
||||
await using var fixture = await AuthFixture.CreateAsync();
|
||||
|
||||
await Assert.ThrowsAsync<InvalidCredentialsException>(() =>
|
||||
service.LoginWithPasswordAsync(new PasswordLoginRequest(
|
||||
seed.TenantId,
|
||||
seed.Phone,
|
||||
"wrong",
|
||||
null,
|
||||
null)));
|
||||
|
||||
Assert.Empty(context.AuthSessions);
|
||||
Assert.Contains(context.AuthLoginEvents, entity =>
|
||||
entity.Result == AuthLoginResult.Failed &&
|
||||
entity.FailureCode == "invalid_credentials");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sms_login_consumes_code_and_creates_session()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var seed = await SeedUserAsync(context, new PasswordHasher().Hash("passw0rd!"));
|
||||
context.SmsVerificationCodes.Add(new SmsVerificationCode
|
||||
for (var attempt = 0; attempt < 5; attempt++)
|
||||
{
|
||||
TenantId = seed.TenantId,
|
||||
Phone = seed.Phone,
|
||||
Purpose = SmsPurpose.Login,
|
||||
CodeHash = SmsCodeHashing.Hash(seed.TenantId, seed.Phone, SmsPurpose.Login, "123456"),
|
||||
Status = SmsVerificationStatus.Sent,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5)
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
var service = CreateAuthService(context);
|
||||
|
||||
var result = await service.LoginWithSmsAsync(new SmsLoginRequest(
|
||||
seed.TenantId,
|
||||
seed.Phone,
|
||||
"123456",
|
||||
null,
|
||||
null));
|
||||
|
||||
Assert.Equal(seed.UserId, result.UserId);
|
||||
Assert.Single(context.AuthSessions);
|
||||
Assert.Contains(context.SmsVerificationCodes, entity => entity.ConsumedAt is not null);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invalid_sms_code_records_failed_event()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var seed = await SeedUserAsync(context, new PasswordHasher().Hash("passw0rd!"));
|
||||
context.SmsVerificationCodes.Add(new SmsVerificationCode
|
||||
{
|
||||
TenantId = seed.TenantId,
|
||||
Phone = seed.Phone,
|
||||
Purpose = SmsPurpose.Login,
|
||||
CodeHash = SmsCodeHashing.Hash(seed.TenantId, seed.Phone, SmsPurpose.Login, "123456"),
|
||||
Status = SmsVerificationStatus.Sent,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5)
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
var service = CreateAuthService(context);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidCredentialsException>(() =>
|
||||
service.LoginWithSmsAsync(new SmsLoginRequest(
|
||||
seed.TenantId,
|
||||
seed.Phone,
|
||||
"999999",
|
||||
null,
|
||||
null)));
|
||||
|
||||
Assert.Empty(context.AuthSessions);
|
||||
Assert.Contains(context.AuthLoginEvents, entity =>
|
||||
entity.Result == AuthLoginResult.Failed &&
|
||||
entity.FailureCode == "invalid_sms_code");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sms_send_failure_records_failed_code_and_does_not_leave_usable_verification()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var seed = await SeedUserAsync(context, new PasswordHasher().Hash("passw0rd!"));
|
||||
var smsService = new SmsVerificationService(context, new FailingSmsProvider());
|
||||
|
||||
var exception = await Assert.ThrowsAsync<SmsProviderException>(() =>
|
||||
smsService.CreateCodeAsync(new SendSmsCodeRequest(
|
||||
seed.TenantId,
|
||||
seed.Phone,
|
||||
SmsPurpose.Login,
|
||||
null,
|
||||
null)));
|
||||
|
||||
Assert.Equal("sms_provider_send_failed", exception.Code);
|
||||
var verification = Assert.Single(context.SmsVerificationCodes);
|
||||
Assert.Equal(SmsVerificationStatus.Failed, verification.Status);
|
||||
Assert.Equal("failed", verification.Provider);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidCredentialsException>(() =>
|
||||
smsService.VerifyCodeAsync(
|
||||
seed.TenantId,
|
||||
seed.Phone,
|
||||
SmsPurpose.Login,
|
||||
"123456"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Failed_sms_code_is_never_accepted_even_when_hash_matches()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var seed = await SeedUserAsync(context, new PasswordHasher().Hash("passw0rd!"));
|
||||
context.SmsVerificationCodes.Add(new SmsVerificationCode
|
||||
{
|
||||
TenantId = seed.TenantId,
|
||||
Phone = seed.Phone,
|
||||
Purpose = SmsPurpose.Login,
|
||||
CodeHash = SmsCodeHashing.Hash(seed.TenantId, seed.Phone, SmsPurpose.Login, "123456"),
|
||||
Status = SmsVerificationStatus.Failed,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5)
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
var smsService = new SmsVerificationService(context, new FakeSmsProvider());
|
||||
|
||||
await Assert.ThrowsAsync<InvalidCredentialsException>(() =>
|
||||
smsService.VerifyCodeAsync(
|
||||
seed.TenantId,
|
||||
seed.Phone,
|
||||
SmsPurpose.Login,
|
||||
"123456"));
|
||||
}
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task Revoked_refresh_token_cannot_be_refreshed()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var hasher = new PasswordHasher();
|
||||
var seed = await SeedUserAsync(context, hasher.Hash("passw0rd!"));
|
||||
var service = CreateAuthService(context);
|
||||
var login = await service.LoginWithPasswordAsync(new PasswordLoginRequest(
|
||||
seed.TenantId,
|
||||
seed.Phone,
|
||||
"passw0rd!",
|
||||
null,
|
||||
null));
|
||||
|
||||
await service.LogoutAsync(new LogoutSessionRequest(login.Tokens.RefreshToken));
|
||||
|
||||
await Assert.ThrowsAsync<SessionRevokedException>(() =>
|
||||
service.RefreshAsync(new RefreshSessionRequest(
|
||||
login.Tokens.RefreshToken,
|
||||
null,
|
||||
null)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Wechat_miniapp_login_creates_user_identity_membership_and_session()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var tenantId = await SeedTenantWithWechatProviderAsync(context, "wechat-miniapp");
|
||||
var service = CreateAuthService(
|
||||
context,
|
||||
new FakeWechatOAuthClient(
|
||||
MiniAppIdentity: new WechatIdentity(
|
||||
"mini-open-id",
|
||||
"union-id",
|
||||
null,
|
||||
null,
|
||||
"session-key",
|
||||
"""{"openid":"mini-open-id","unionid":"union-id","session_key":"session-key"}""")));
|
||||
|
||||
var result = await service.LoginWithWechatMiniAppAsync(new WechatLoginRequest(
|
||||
tenantId,
|
||||
"wx-code",
|
||||
null,
|
||||
null));
|
||||
|
||||
Assert.Equal(tenantId, result.Tenant.TenantId);
|
||||
Assert.Single(context.Users);
|
||||
Assert.Contains(context.UserIdentities, identity =>
|
||||
identity.Provider == "wechat_miniapp" &&
|
||||
identity.ProviderSubject == "wx-app-id:mini-open-id" &&
|
||||
identity.OpenId == "mini-open-id" &&
|
||||
identity.UnionId == "union-id");
|
||||
Assert.Contains(context.TenantMemberships, membership =>
|
||||
membership.TenantId == tenantId &&
|
||||
membership.UserId == result.UserId &&
|
||||
membership.Status == MembershipStatus.Active);
|
||||
Assert.Single(context.AuthSessions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Wechat_union_id_reuses_existing_user_across_providers()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var tenantId = await SeedTenantWithWechatProviderAsync(context, "wechat-miniapp");
|
||||
context.TenantExternalProviders.Add(new TenantExternalProvider
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Provider = "wechat_web",
|
||||
Capability = TenantExternalProviderCapability.Identity,
|
||||
Status = TenantExternalProviderStatus.Active,
|
||||
SecretRef = "tenant_secrets:identity:wechat_web:default",
|
||||
ConfigPublic = WechatProviderConfig()
|
||||
});
|
||||
var user = new User
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Existing"
|
||||
};
|
||||
context.Users.Add(user);
|
||||
context.UserIdentities.Add(new UserIdentity
|
||||
{
|
||||
UserId = user.Id,
|
||||
Provider = "wechat_web",
|
||||
ProviderSubject = "wx-app-id:web-open-id",
|
||||
OpenId = "web-open-id",
|
||||
UnionId = "same-union"
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
var service = CreateAuthService(
|
||||
context,
|
||||
new FakeWechatOAuthClient(
|
||||
MiniAppIdentity: new WechatIdentity(
|
||||
"mini-open-id",
|
||||
"same-union",
|
||||
null,
|
||||
null,
|
||||
"session-key",
|
||||
"""{"openid":"mini-open-id","unionid":"same-union","session_key":"session-key"}""")));
|
||||
|
||||
var result = await service.LoginWithWechatMiniAppAsync(new WechatLoginRequest(
|
||||
tenantId,
|
||||
"wx-code",
|
||||
null,
|
||||
null));
|
||||
|
||||
Assert.Equal(user.Id, result.UserId);
|
||||
Assert.Single(context.Users);
|
||||
Assert.Equal(2, context.UserIdentities.Count());
|
||||
}
|
||||
|
||||
private static TikuDbContext CreateContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<TikuDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
|
||||
return new TikuDbContext(options);
|
||||
}
|
||||
|
||||
private static IAuthService CreateAuthService(
|
||||
TikuDbContext context,
|
||||
IWechatOAuthClient? wechatOAuthClient = null)
|
||||
{
|
||||
var tokenService = new TokenService(Options.Create(JwtOptions));
|
||||
var sessionService = new SessionService(context, tokenService, Options.Create(JwtOptions));
|
||||
var smsService = new SmsVerificationService(context, new FakeSmsProvider());
|
||||
|
||||
return new AuthService(
|
||||
context,
|
||||
new PasswordHasher(),
|
||||
smsService,
|
||||
sessionService,
|
||||
wechatOAuthClient ?? new FakeWechatOAuthClient(),
|
||||
new FakeProviderConfigService(context));
|
||||
}
|
||||
|
||||
private static async Task<Guid> SeedTenantWithWechatProviderAsync(
|
||||
TikuDbContext context,
|
||||
string provider)
|
||||
{
|
||||
var tenant = new Tenant
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Slug = Guid.NewGuid().ToString("N"),
|
||||
Name = "Wechat Tenant"
|
||||
};
|
||||
context.Tenants.Add(tenant);
|
||||
context.TenantExternalProviders.Add(new TenantExternalProvider
|
||||
{
|
||||
TenantId = tenant.Id,
|
||||
Provider = provider.Replace("-", "_", StringComparison.Ordinal),
|
||||
Capability = TenantExternalProviderCapability.Identity,
|
||||
Status = TenantExternalProviderStatus.Active,
|
||||
SecretRef = $"tenant_secrets:identity:{provider}:default",
|
||||
ConfigPublic = WechatProviderConfig()
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
return tenant.Id;
|
||||
}
|
||||
|
||||
private static JsonElement WechatProviderConfig()
|
||||
{
|
||||
return JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
appId = "wx-app-id"
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedUserAsync(
|
||||
TikuDbContext context,
|
||||
string passwordHash)
|
||||
{
|
||||
var tenant = new Tenant
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Slug = Guid.NewGuid().ToString("N"),
|
||||
Name = "Test Tenant"
|
||||
};
|
||||
var user = new User
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Phone = "13800000000",
|
||||
Name = "Test User"
|
||||
};
|
||||
var membership = new TenantMembership
|
||||
{
|
||||
TenantId = tenant.Id,
|
||||
UserId = user.Id,
|
||||
Role = TenantRole.Student,
|
||||
Status = MembershipStatus.Active
|
||||
};
|
||||
var identity = new UserIdentity
|
||||
{
|
||||
UserId = user.Id,
|
||||
Provider = "password",
|
||||
ProviderSubject = user.Phone,
|
||||
Phone = user.Phone,
|
||||
SecretPayload = CreateSecretPayload(passwordHash)
|
||||
};
|
||||
|
||||
context.Tenants.Add(tenant);
|
||||
context.Users.Add(user);
|
||||
context.TenantMemberships.Add(membership);
|
||||
context.UserIdentities.Add(identity);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
return (tenant.Id, user.Id, user.Phone);
|
||||
}
|
||||
|
||||
private static JsonElement CreateSecretPayload(string passwordHash)
|
||||
{
|
||||
using var document = JsonDocument.Parse(
|
||||
$$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}""");
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
|
||||
private sealed class FakeProviderConfigService(TikuDbContext context) : ITenantExternalProviderConfigService
|
||||
{
|
||||
public Task<TenantExternalProviderAccount> GetActiveProviderAsync(
|
||||
Guid tenantId,
|
||||
TenantExternalProviderCapability capability,
|
||||
string? provider = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedProvider = provider?.Replace("-", "_", StringComparison.Ordinal);
|
||||
var item = context.TenantExternalProviders
|
||||
.AsEnumerable()
|
||||
.Where(entity =>
|
||||
entity.TenantId == tenantId &&
|
||||
entity.Capability == capability &&
|
||||
entity.Status == TenantExternalProviderStatus.Active)
|
||||
.Where(entity => string.IsNullOrWhiteSpace(normalizedProvider) || entity.Provider == normalizedProvider)
|
||||
.OrderBy(entity => entity.Priority)
|
||||
.FirstOrDefault()
|
||||
?? throw new TenantExternalProviderException(
|
||||
"Tenant external provider is not configured.",
|
||||
"tenant_external_provider_not_configured");
|
||||
|
||||
return Task.FromResult(new TenantExternalProviderAccount(
|
||||
item.TenantId,
|
||||
item.Capability,
|
||||
item.Provider,
|
||||
item.Status,
|
||||
item.DisplayName,
|
||||
item.ConfigPublic,
|
||||
JsonSerializer.SerializeToElement(new { appSecret = "wx-app-secret" }),
|
||||
item.SecretRef,
|
||||
item.Priority,
|
||||
item.Metadata));
|
||||
await Assert.ThrowsAsync<InvalidCredentialsException>(() =>
|
||||
fixture.AuthService.LoginWithPasswordAsync(new PasswordLoginRequest(
|
||||
AuthRealm.Tenant, fixture.TenantId, AuthFixture.Phone, "wrong-password", null, null)));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyCollection<TenantExternalProviderItem>> GetProvidersAsync(
|
||||
Guid tenantId,
|
||||
TenantExternalProviderCapability? capability = null,
|
||||
string? provider = null,
|
||||
int? limit = null,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
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);
|
||||
}
|
||||
|
||||
public Task<TenantExternalProviderItem> UpsertProviderAsync(
|
||||
Guid tenantId,
|
||||
UpsertTenantExternalProviderCommand command,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
[Fact]
|
||||
public async Task Backend_permission_requires_one_time_mfa_enrollment_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.MfaEnrollmentRequired, result.Status);
|
||||
Assert.Null(result.User);
|
||||
Assert.False(string.IsNullOrWhiteSpace(result.ChallengeToken));
|
||||
Assert.Empty(await fixture.DbContext.AuthSessions.ToArrayAsync());
|
||||
Assert.Single(await fixture.DbContext.AuthChallenges.ToArrayAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Incomplete_authenticator_setup_still_requires_enrollment()
|
||||
{
|
||||
await using var fixture = await AuthFixture.CreateAsync(includeBackendPermission: true);
|
||||
var user = await fixture.UserManager.FindByIdAsync(fixture.UserId.ToString());
|
||||
Assert.True((await fixture.UserManager.ResetAuthenticatorKeyAsync(user!)).Succeeded);
|
||||
Assert.False(user!.TwoFactorEnabled);
|
||||
|
||||
var result = await fixture.AuthService.LoginWithPasswordAsync(new PasswordLoginRequest(
|
||||
AuthRealm.Tenant, fixture.TenantId, AuthFixture.Phone, AuthFixture.Password, null, null));
|
||||
|
||||
Assert.Equal(AuthenticationStatus.MfaEnrollmentRequired, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Mfa_setup_audit_captures_request_origin()
|
||||
{
|
||||
await using var fixture = await AuthFixture.CreateAsync(includeBackendPermission: true);
|
||||
var login = await fixture.AuthService.LoginWithPasswordAsync(new PasswordLoginRequest(
|
||||
AuthRealm.Tenant, fixture.TenantId, AuthFixture.Phone, AuthFixture.Password, null, null));
|
||||
|
||||
await fixture.AuthService.SetupTotpAsync(new MfaChallengeRequest(
|
||||
login.ChallengeToken!, null, "127.0.0.9", "mfa-audit-test"));
|
||||
|
||||
var audit = await fixture.DbContext.AuditLogs.SingleAsync(item =>
|
||||
item.Action == "auth.mfa.enrollment_setup");
|
||||
Assert.Equal("127.0.0.9", audit.IpAddress);
|
||||
Assert.Equal("mfa-audit-test", audit.UserAgent);
|
||||
}
|
||||
|
||||
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<TikuDbContext>();
|
||||
UserManager = scope.ServiceProvider.GetRequiredService<UserManager<User>>();
|
||||
AuthService = scope.ServiceProvider.GetRequiredService<IAuthService>();
|
||||
}
|
||||
|
||||
public TikuDbContext DbContext { get; }
|
||||
public UserManager<User> UserManager { get; }
|
||||
public IAuthService AuthService { get; }
|
||||
public Guid TenantId { get; private set; }
|
||||
public Guid UserId { get; private set; }
|
||||
|
||||
public static async Task<AuthFixture> CreateAsync(bool includeBackendPermission = false)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddDataProtection();
|
||||
services.AddAuthentication();
|
||||
services.AddDbContext<TikuDbContext>(options =>
|
||||
options.UseInMemoryDatabase(Guid.NewGuid().ToString("N")));
|
||||
services.AddIdentityCore<User>(options =>
|
||||
{
|
||||
options.Password.RequiredLength = 10;
|
||||
options.Password.RequireDigit = true;
|
||||
options.Password.RequireLowercase = true;
|
||||
options.Password.RequireUppercase = false;
|
||||
options.Password.RequireNonAlphanumeric = false;
|
||||
options.Lockout.MaxFailedAccessAttempts = 5;
|
||||
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15);
|
||||
})
|
||||
.AddEntityFrameworkStores<TikuDbContext>()
|
||||
.AddSignInManager()
|
||||
.AddDefaultTokenProviders();
|
||||
services.Configure<PasswordHasherOptions>(options => options.IterationCount = 210_000);
|
||||
services.Configure<JwtOptions>(options =>
|
||||
{
|
||||
options.Issuer = "tiku-unit-tests";
|
||||
options.Audience = "tiku-unit-tests";
|
||||
options.KeyId = "unit-test-rsa";
|
||||
});
|
||||
services.AddSingleton<IJwtKeyRing, TestJwtKeyRing>();
|
||||
services.AddScoped<ITokenService, TokenService>();
|
||||
services.AddScoped<AuthSessionStore>();
|
||||
services.AddScoped<IAuthSessionStore>(sp => sp.GetRequiredService<AuthSessionStore>());
|
||||
services.AddScoped<ISmsVerificationService, RejectingSmsVerificationService>();
|
||||
services.AddScoped<IWechatOAuthClient, RejectingWechatClient>();
|
||||
services.AddScoped<ITenantExternalProviderConfigService, RejectingProviderConfigService>();
|
||||
services.AddScoped<IAuthService, AuthService>();
|
||||
|
||||
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<SecurityKey> ValidationKeys { get; }
|
||||
public void Dispose() => rsa.Dispose();
|
||||
}
|
||||
|
||||
private sealed class RejectingSmsVerificationService : ISmsVerificationService
|
||||
{
|
||||
public Task<SmsSendResult> 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 FakeSmsProvider : ISmsProvider
|
||||
private sealed class RejectingWechatClient : IWechatOAuthClient
|
||||
{
|
||||
public Task<SmsProviderSendResult> SendAsync(
|
||||
SmsProviderSendRequest request,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(new SmsProviderSendResult("fake", "accepted"));
|
||||
public Task<WechatIdentity> ExchangeWebCodeAsync(WechatProviderOptions options, string code, CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
public Task<WechatIdentity> ExchangeMiniAppCodeAsync(WechatProviderOptions options, string code, CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
private sealed class FailingSmsProvider : ISmsProvider
|
||||
private sealed class RejectingProviderConfigService : ITenantExternalProviderConfigService
|
||||
{
|
||||
public Task<SmsProviderSendResult> SendAsync(
|
||||
SmsProviderSendRequest request,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new InvalidOperationException("provider unavailable");
|
||||
}
|
||||
|
||||
private sealed class FakeWechatOAuthClient(
|
||||
WechatIdentity? WebIdentity = null,
|
||||
WechatIdentity? MiniAppIdentity = null) : IWechatOAuthClient
|
||||
{
|
||||
public Task<WechatIdentity> ExchangeWebCodeAsync(
|
||||
WechatProviderOptions options,
|
||||
string code,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(WebIdentity ?? new WechatIdentity(
|
||||
"web-open-id",
|
||||
"union-id",
|
||||
"Wechat User",
|
||||
"https://example.test/avatar.png",
|
||||
null,
|
||||
"""{"openid":"web-open-id","unionid":"union-id"}"""));
|
||||
}
|
||||
|
||||
public Task<WechatIdentity> ExchangeMiniAppCodeAsync(
|
||||
WechatProviderOptions options,
|
||||
string code,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(MiniAppIdentity ?? new WechatIdentity(
|
||||
"mini-open-id",
|
||||
"union-id",
|
||||
null,
|
||||
null,
|
||||
"session-key",
|
||||
"""{"openid":"mini-open-id","unionid":"union-id","session_key":"session-key"}"""));
|
||||
}
|
||||
public Task<TenantExternalProviderAccount> GetActiveProviderAsync(Guid tenantId, TenantExternalProviderCapability capability, string? provider = null, CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
public Task<IReadOnlyCollection<TenantExternalProviderItem>> GetProvidersAsync(Guid tenantId, TenantExternalProviderCapability? capability = null, string? provider = null, int? limit = null, CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
public Task<TenantExternalProviderItem> UpsertProviderAsync(Guid tenantId, UpsertTenantExternalProviderCommand command, CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
|
||||
231
Tiku.UnitTests/Auth/SmsVerificationServiceTests.cs
Normal file
231
Tiku.UnitTests/Auth/SmsVerificationServiceTests.cs
Normal file
@@ -0,0 +1,231 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Auth;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.UnitTests.Auth;
|
||||
|
||||
public sealed class SmsVerificationServiceTests
|
||||
{
|
||||
private const string Pepper = "unit-test-sms-code-pepper-32-characters";
|
||||
|
||||
[Fact]
|
||||
public void Hash_uses_the_server_pepper()
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
|
||||
var first = SmsCodeHashing.Hash(tenantId, "13800000000", SmsPurpose.Login, "123456", Pepper);
|
||||
var second = SmsCodeHashing.Hash(
|
||||
tenantId,
|
||||
"13800000000",
|
||||
SmsPurpose.Login,
|
||||
"123456",
|
||||
"another-unit-test-pepper-32-characters");
|
||||
|
||||
Assert.NotEqual(first, second);
|
||||
Assert.Equal(64, first.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_code_generates_six_digits_and_consumes_all_available_rate_limit_dimensions()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var tenantId = await SeedTenantAsync(context);
|
||||
var provider = new CapturingSmsProvider();
|
||||
var service = CreateService(context, provider);
|
||||
|
||||
await service.CreateCodeAsync(new SendSmsCodeRequest(
|
||||
tenantId,
|
||||
"13800000000",
|
||||
SmsPurpose.Login,
|
||||
"127.0.0.1",
|
||||
"test-device"));
|
||||
|
||||
Assert.Matches("^[0-9]{6}$", Assert.Single(provider.Requests).Code);
|
||||
Assert.Equal(4, context.SmsSendRateLimits.Count());
|
||||
Assert.Contains(context.SmsSendRateLimits, item => item.Dimension == SmsRateLimitDimension.Tenant);
|
||||
Assert.Contains(context.SmsSendRateLimits, item => item.Dimension == SmsRateLimitDimension.Phone);
|
||||
Assert.Contains(context.SmsSendRateLimits, item => item.Dimension == SmsRateLimitDimension.Ip);
|
||||
Assert.Contains(context.SmsSendRateLimits, item => item.Dimension == SmsRateLimitDimension.Device);
|
||||
var sendEvent = Assert.Single(context.AuthLoginEvents);
|
||||
Assert.Equal(AuthLoginResult.Sent, sendEvent.Result);
|
||||
Assert.Equal("sms", sendEvent.Provider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fifth_invalid_attempt_blocks_code_and_correct_code_is_then_rejected()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var tenantId = await SeedTenantAsync(context);
|
||||
var verification = await SeedCodeAsync(context, tenantId, "123456");
|
||||
var service = CreateService(context);
|
||||
|
||||
for (var attempt = 0; attempt < 5; attempt++)
|
||||
{
|
||||
await Assert.ThrowsAsync<InvalidCredentialsException>(() =>
|
||||
service.VerifyCodeAsync(tenantId, "13800000000", SmsPurpose.Login, "999999"));
|
||||
}
|
||||
|
||||
context.ChangeTracker.Clear();
|
||||
var blocked = await context.SmsVerificationCodes.FindAsync(verification.Id);
|
||||
Assert.NotNull(blocked);
|
||||
Assert.Equal(5, blocked.Attempts);
|
||||
Assert.Equal(SmsVerificationStatus.Blocked, blocked.Status);
|
||||
await Assert.ThrowsAsync<InvalidCredentialsException>(() =>
|
||||
service.VerifyCodeAsync(tenantId, "13800000000", SmsPurpose.Login, "123456"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Expired_code_is_transitioned_to_expired()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var tenantId = await SeedTenantAsync(context);
|
||||
var verification = await SeedCodeAsync(
|
||||
context,
|
||||
tenantId,
|
||||
"123456",
|
||||
DateTimeOffset.UtcNow.AddSeconds(-1));
|
||||
var service = CreateService(context);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidCredentialsException>(() =>
|
||||
service.VerifyCodeAsync(tenantId, "13800000000", SmsPurpose.Login, "123456"));
|
||||
|
||||
context.ChangeTracker.Clear();
|
||||
Assert.Equal(
|
||||
SmsVerificationStatus.Expired,
|
||||
(await context.SmsVerificationCodes.FindAsync(verification.Id))!.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Successful_code_can_only_be_consumed_once()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var tenantId = await SeedTenantAsync(context);
|
||||
var verification = await SeedCodeAsync(context, tenantId, "123456");
|
||||
var service = CreateService(context);
|
||||
|
||||
await service.VerifyCodeAsync(tenantId, "13800000000", SmsPurpose.Login, "123456");
|
||||
await Assert.ThrowsAsync<InvalidCredentialsException>(() =>
|
||||
service.VerifyCodeAsync(tenantId, "13800000000", SmsPurpose.Login, "123456"));
|
||||
|
||||
context.ChangeTracker.Clear();
|
||||
var consumed = await context.SmsVerificationCodes.FindAsync(verification.Id);
|
||||
Assert.Equal(SmsVerificationStatus.Verified, consumed!.Status);
|
||||
Assert.NotNull(consumed.ConsumedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Phone_limit_applies_across_ip_and_device_changes()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var tenantId = await SeedTenantAsync(context);
|
||||
var options = new SmsSecurityOptions
|
||||
{
|
||||
CodePepper = Pepper,
|
||||
TenantRequestsPerHour = 100,
|
||||
PhoneRequestsPerHour = 1,
|
||||
IpRequestsPerHour = 20,
|
||||
DeviceRequestsPerHour = 10
|
||||
};
|
||||
var service = CreateService(context, options: options);
|
||||
|
||||
await service.CreateCodeAsync(new SendSmsCodeRequest(
|
||||
tenantId,
|
||||
"13800000000",
|
||||
SmsPurpose.Login,
|
||||
"127.0.0.1",
|
||||
"device-one"));
|
||||
|
||||
await Assert.ThrowsAsync<SmsRateLimitedException>(() =>
|
||||
service.CreateCodeAsync(new SendSmsCodeRequest(
|
||||
tenantId,
|
||||
"13800000000",
|
||||
SmsPurpose.Login,
|
||||
"127.0.0.2",
|
||||
"device-two")));
|
||||
}
|
||||
|
||||
private static SmsVerificationService CreateService(
|
||||
TikuDbContext context,
|
||||
ISmsProvider? provider = null,
|
||||
SmsSecurityOptions? options = null)
|
||||
{
|
||||
return new SmsVerificationService(
|
||||
context,
|
||||
provider ?? new CapturingSmsProvider(),
|
||||
Options.Create(options ?? ValidOptions()));
|
||||
}
|
||||
|
||||
private static SmsSecurityOptions ValidOptions()
|
||||
{
|
||||
return new SmsSecurityOptions
|
||||
{
|
||||
CodePepper = Pepper,
|
||||
TenantRequestsPerHour = 100,
|
||||
PhoneRequestsPerHour = 5,
|
||||
IpRequestsPerHour = 20,
|
||||
DeviceRequestsPerHour = 10
|
||||
};
|
||||
}
|
||||
|
||||
private static TikuDbContext CreateContext()
|
||||
{
|
||||
return new TikuDbContext(
|
||||
new DbContextOptionsBuilder<TikuDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options);
|
||||
}
|
||||
|
||||
private static async Task<Guid> SeedTenantAsync(TikuDbContext context)
|
||||
{
|
||||
var tenant = new Tenant
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Slug = Guid.NewGuid().ToString("N"),
|
||||
Name = "SMS Test Tenant"
|
||||
};
|
||||
context.Tenants.Add(tenant);
|
||||
await context.SaveChangesAsync();
|
||||
return tenant.Id;
|
||||
}
|
||||
|
||||
private static async Task<SmsVerificationCode> SeedCodeAsync(
|
||||
TikuDbContext context,
|
||||
Guid tenantId,
|
||||
string code,
|
||||
DateTimeOffset? expiresAt = null)
|
||||
{
|
||||
var verification = new SmsVerificationCode
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Phone = "13800000000",
|
||||
Purpose = SmsPurpose.Login,
|
||||
CodeHash = SmsCodeHashing.Hash(
|
||||
tenantId,
|
||||
"13800000000",
|
||||
SmsPurpose.Login,
|
||||
code,
|
||||
Pepper),
|
||||
Status = SmsVerificationStatus.Sent,
|
||||
ExpiresAt = expiresAt ?? DateTimeOffset.UtcNow.AddMinutes(5)
|
||||
};
|
||||
context.SmsVerificationCodes.Add(verification);
|
||||
await context.SaveChangesAsync();
|
||||
return verification;
|
||||
}
|
||||
|
||||
private sealed class CapturingSmsProvider : ISmsProvider
|
||||
{
|
||||
public List<SmsProviderSendRequest> Requests { get; } = [];
|
||||
|
||||
public Task<SmsProviderSendResult> SendAsync(
|
||||
SmsProviderSendRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Requests.Add(request);
|
||||
return Task.FromResult(new SmsProviderSendResult("test", "sent", "message-id"));
|
||||
}
|
||||
}
|
||||
}
|
||||
104
Tiku.UnitTests/Bootstrap/PlatformAdminBootstrapperTests.cs
Normal file
104
Tiku.UnitTests/Bootstrap/PlatformAdminBootstrapperTests.cs
Normal file
@@ -0,0 +1,104 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Infrastructure.Bootstrap;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.UnitTests.Bootstrap;
|
||||
|
||||
public sealed class PlatformAdminBootstrapperTests
|
||||
{
|
||||
private const string TemporaryPassword = "Temporary9Password";
|
||||
|
||||
[Fact]
|
||||
public async Task Bootstrap_creates_forced_enrollment_super_admin_and_audit()
|
||||
{
|
||||
await using var provider = CreateProvider();
|
||||
await using var scope = provider.CreateAsyncScope();
|
||||
var bootstrapper = ActivatorUtilities.CreateInstance<PlatformAdminBootstrapper>(scope.ServiceProvider);
|
||||
|
||||
var result = await bootstrapper.BootstrapAsync(new PlatformAdminBootstrapOptions(
|
||||
"admin@example.com",
|
||||
TemporaryPassword,
|
||||
"Initial Administrator"));
|
||||
|
||||
var context = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var user = await context.Users.SingleAsync(item => item.Id == result.UserId);
|
||||
Assert.True(user.ForcePasswordChange);
|
||||
Assert.False(user.TwoFactorEnabled);
|
||||
Assert.True(user.EmailConfirmed);
|
||||
Assert.Equal(UserStatus.Active, user.Status);
|
||||
var role = await context.PlatformBackendRoles.SingleAsync(item => item.Id == result.RoleId);
|
||||
Assert.Equal(PlatformAdminBootstrapper.SuperAdminRoleCode, role.Code);
|
||||
Assert.True(role.IsSystem);
|
||||
Assert.Equal(BackendPermissions.Platform.Count, await context.PlatformBackendRolePermissions.CountAsync());
|
||||
Assert.True(await context.PlatformBackendUserRoles.AnyAsync(item => item.UserId == user.Id && item.RoleId == role.Id));
|
||||
Assert.True(await context.AuditLogs.AnyAsync(item =>
|
||||
item.ActorUserId == user.Id && item.Action == "platform.bootstrap_admin.created"));
|
||||
|
||||
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<User>>();
|
||||
Assert.True(await userManager.CheckPasswordAsync(user, TemporaryPassword));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bootstrap_rejects_a_second_platform_administrator_without_mutating_data()
|
||||
{
|
||||
await using var provider = CreateProvider();
|
||||
await using var scope = provider.CreateAsyncScope();
|
||||
var bootstrapper = ActivatorUtilities.CreateInstance<PlatformAdminBootstrapper>(scope.ServiceProvider);
|
||||
await bootstrapper.BootstrapAsync(new PlatformAdminBootstrapOptions("first@example.com", TemporaryPassword));
|
||||
|
||||
var exception = await Assert.ThrowsAsync<PlatformAdminBootstrapException>(() =>
|
||||
bootstrapper.BootstrapAsync(new PlatformAdminBootstrapOptions("second@example.com", TemporaryPassword)));
|
||||
|
||||
Assert.Equal("platform_admin_already_exists", exception.Code);
|
||||
var context = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
Assert.Single(context.PlatformBackendUserRoles);
|
||||
Assert.Single(context.PlatformBackendRoles);
|
||||
Assert.Single(context.AuditLogs);
|
||||
Assert.Single(context.Users);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bootstrap_rejects_an_existing_user_email()
|
||||
{
|
||||
await using var provider = CreateProvider();
|
||||
await using var scope = provider.CreateAsyncScope();
|
||||
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<User>>();
|
||||
var createResult = await userManager.CreateAsync(new User
|
||||
{
|
||||
Email = "existing@example.com",
|
||||
UserName = "existing@example.com"
|
||||
}, TemporaryPassword);
|
||||
Assert.True(createResult.Succeeded);
|
||||
var bootstrapper = ActivatorUtilities.CreateInstance<PlatformAdminBootstrapper>(scope.ServiceProvider);
|
||||
|
||||
var exception = await Assert.ThrowsAsync<PlatformAdminBootstrapException>(() =>
|
||||
bootstrapper.BootstrapAsync(new PlatformAdminBootstrapOptions("existing@example.com", TemporaryPassword)));
|
||||
|
||||
Assert.Equal("bootstrap_user_already_exists", exception.Code);
|
||||
}
|
||||
|
||||
private static ServiceProvider CreateProvider()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddDbContext<TikuDbContext>(options =>
|
||||
options.UseInMemoryDatabase(Guid.NewGuid().ToString()));
|
||||
services.AddIdentityCore<User>(options =>
|
||||
{
|
||||
options.Password.RequiredLength = 10;
|
||||
options.Password.RequireDigit = true;
|
||||
options.Password.RequireLowercase = true;
|
||||
options.Password.RequireUppercase = false;
|
||||
options.Password.RequireNonAlphanumeric = false;
|
||||
})
|
||||
.AddEntityFrameworkStores<TikuDbContext>()
|
||||
.AddDefaultTokenProviders();
|
||||
services.AddDataProtection().UseEphemeralDataProtectionProvider();
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
}
|
||||
92
Tiku.UnitTests/Security/CurrentDataScopeTests.cs
Normal file
92
Tiku.UnitTests/Security/CurrentDataScopeTests.cs
Normal file
@@ -0,0 +1,92 @@
|
||||
using System.Text.Json;
|
||||
using Tiku.Application.Security;
|
||||
|
||||
namespace Tiku.UnitTests.Security;
|
||||
|
||||
public sealed class CurrentDataScopeTests
|
||||
{
|
||||
[Fact]
|
||||
public void Merge_EmptyOrInvalidScopes_DefaultsToSelf()
|
||||
{
|
||||
var result = CurrentDataScope.Merge(
|
||||
[
|
||||
JsonSerializer.SerializeToElement(new { }),
|
||||
JsonSerializer.SerializeToElement("invalid")
|
||||
]);
|
||||
|
||||
Assert.Equal(DataScopeMode.Self, result.Mode);
|
||||
Assert.True(result.IncludesSelf);
|
||||
Assert.Empty(result.RegionIds);
|
||||
Assert.Empty(result.ClassIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_RestrictedRoles_UnionsResourceIdsAndSelfAccess()
|
||||
{
|
||||
var firstRegion = Guid.NewGuid();
|
||||
var secondRegion = Guid.NewGuid();
|
||||
var classId = Guid.NewGuid();
|
||||
|
||||
var result = CurrentDataScope.Merge(
|
||||
[
|
||||
JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
mode = "Restricted",
|
||||
regionIds = new[] { firstRegion },
|
||||
classIds = new[] { classId }
|
||||
}),
|
||||
JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
mode = "Restricted",
|
||||
regionIds = new[] { secondRegion },
|
||||
includesSelf = true
|
||||
})
|
||||
]);
|
||||
|
||||
Assert.Equal(DataScopeMode.Restricted, result.Mode);
|
||||
Assert.True(result.IncludesSelf);
|
||||
Assert.True(result.RegionIds.SetEquals([firstRegion, secondRegion]));
|
||||
Assert.True(result.ClassIds.SetEquals([classId]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_AllScope_OverridesRestrictedScopes()
|
||||
{
|
||||
var result = CurrentDataScope.Merge(
|
||||
[
|
||||
JsonSerializer.SerializeToElement(new { mode = "Restricted", regionIds = new[] { Guid.NewGuid() } }),
|
||||
JsonSerializer.SerializeToElement(new { type = "All" })
|
||||
]);
|
||||
|
||||
Assert.Equal(DataScopeMode.All, result.Mode);
|
||||
Assert.True(result.IncludesSelf);
|
||||
Assert.Empty(result.RegionIds);
|
||||
Assert.Empty(result.ClassIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PermissionCatalog_UsesUniqueRealmScopedCodes()
|
||||
{
|
||||
Assert.All(BackendPermissions.Tenant, code => Assert.StartsWith("tenant:", code, StringComparison.Ordinal));
|
||||
Assert.All(BackendPermissions.Platform, code => Assert.StartsWith("platform:", code, StringComparison.Ordinal));
|
||||
Assert.Empty(BackendPermissions.Tenant.Intersect(BackendPermissions.Platform, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllowsResource_UsesOwnerRegionAndClassWithoutCrossScopeFallback()
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
var regionId = Guid.NewGuid();
|
||||
var classId = Guid.NewGuid();
|
||||
var scope = new CurrentDataScope(
|
||||
DataScopeMode.Restricted,
|
||||
new HashSet<Guid> { regionId },
|
||||
new HashSet<Guid> { classId },
|
||||
false);
|
||||
|
||||
Assert.True(scope.AllowsResource(userId, regionId: regionId));
|
||||
Assert.True(scope.AllowsResource(userId, classId: classId));
|
||||
Assert.False(scope.AllowsResource(userId, ownerUserId: userId));
|
||||
Assert.False(scope.AllowsResource(userId, regionId: Guid.NewGuid(), classId: Guid.NewGuid()));
|
||||
}
|
||||
}
|
||||
94
Tiku.UnitTests/Security/DataProtectionKeyRingOptionsTests.cs
Normal file
94
Tiku.UnitTests/Security/DataProtectionKeyRingOptionsTests.cs
Normal file
@@ -0,0 +1,94 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Tiku.Infrastructure.Security;
|
||||
|
||||
namespace Tiku.UnitTests.Security;
|
||||
|
||||
public sealed class DataProtectionKeyRingOptionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Development_allows_an_unencrypted_key_ring()
|
||||
{
|
||||
var options = new DataProtectionKeyRingOptions();
|
||||
|
||||
Assert.True(DataProtectionKeyRingOptions.BeValid(options, requireCertificate: false));
|
||||
Assert.Null(options.LoadCertificate(requireCertificate: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Production_requires_a_certificate_path()
|
||||
{
|
||||
var options = new DataProtectionKeyRingOptions();
|
||||
|
||||
Assert.False(DataProtectionKeyRingOptions.BeValid(options, requireCertificate: true));
|
||||
var exception = Assert.Throws<InvalidOperationException>(() =>
|
||||
options.LoadCertificate(requireCertificate: true));
|
||||
Assert.Contains("required outside Development", exception.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Application_name_is_always_required()
|
||||
{
|
||||
var options = new DataProtectionKeyRingOptions
|
||||
{
|
||||
ApplicationName = " ",
|
||||
CertificatePath = "/configured/key-ring.pfx"
|
||||
};
|
||||
|
||||
Assert.False(DataProtectionKeyRingOptions.BeValid(options, requireCertificate: false));
|
||||
Assert.False(DataProtectionKeyRingOptions.BeValid(options, requireCertificate: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Configured_certificate_file_must_be_loadable()
|
||||
{
|
||||
var options = new DataProtectionKeyRingOptions
|
||||
{
|
||||
CertificatePath = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"missing-data-protection-{Guid.NewGuid():N}.pfx")
|
||||
};
|
||||
|
||||
Assert.True(DataProtectionKeyRingOptions.BeValid(options, requireCertificate: true));
|
||||
var exception = Assert.Throws<InvalidOperationException>(() =>
|
||||
options.LoadCertificate(requireCertificate: true));
|
||||
Assert.Contains("could not be loaded", exception.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Password_protected_pkcs12_certificate_with_private_key_is_loaded()
|
||||
{
|
||||
const string password = "unit-test-certificate-password";
|
||||
var certificatePath = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"data-protection-{Guid.NewGuid():N}.pfx");
|
||||
|
||||
try
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
var request = new CertificateRequest(
|
||||
"CN=Tiku Data Protection Unit Test",
|
||||
rsa,
|
||||
HashAlgorithmName.SHA256,
|
||||
RSASignaturePadding.Pkcs1);
|
||||
using var certificate = request.CreateSelfSigned(
|
||||
DateTimeOffset.UtcNow.AddMinutes(-1),
|
||||
DateTimeOffset.UtcNow.AddDays(1));
|
||||
File.WriteAllBytes(certificatePath, certificate.Export(X509ContentType.Pfx, password));
|
||||
|
||||
var options = new DataProtectionKeyRingOptions
|
||||
{
|
||||
CertificatePath = certificatePath,
|
||||
CertificatePassword = password
|
||||
};
|
||||
|
||||
using var loaded = options.LoadCertificate(requireCertificate: true);
|
||||
Assert.NotNull(loaded);
|
||||
Assert.True(loaded.HasPrivateKey);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(certificatePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user