Files
tiku-backend.net/Tiku.UnitTests/Auth/AuthServiceTests.cs

309 lines
14 KiB
C#

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;
namespace Tiku.UnitTests.Auth;
public sealed class AuthServiceTests
{
[Fact]
public void Identity_password_hasher_uses_current_identity_format()
{
var user = new User();
var hasher = new PasswordHasher<User>(Options.Create(new PasswordHasherOptions
{
IterationCount = 210_000
}));
var hash = hasher.HashPassword(user, "passw0rd!");
Assert.Equal(PasswordVerificationResult.Success, hasher.VerifyHashedPassword(user, hash, "passw0rd!"));
Assert.Equal(PasswordVerificationResult.Failed, hasher.VerifyHashedPassword(user, hash, "wrong"));
}
[Theory]
[InlineData("abc12345", true)]
[InlineData("ABC12345", true)]
[InlineData("abcdefgh", false)]
[InlineData("12345678", false)]
[InlineData("abc1234", false)]
public async Task Default_password_policy_requires_eight_characters_letters_and_digits(
string password,
bool expectedSuccess)
{
var validator = new LetterAndDigitPasswordValidator<User>();
var result = await validator.ValidateAsync(null!, new User(), password);
Assert.Equal(expectedSuccess, result.Succeeded);
}
[Fact]
public async Task Password_login_issues_tenant_session_for_regular_member()
{
await using var fixture = await AuthFixture.CreateAsync();
var result = await fixture.AuthService.LoginWithPasswordAsync(new PasswordLoginRequest(
AuthRealm.Tenant, fixture.TenantId, AuthFixture.Phone, AuthFixture.Password, "127.0.0.1", "unit-test"));
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 Five_wrong_passwords_lock_the_identity_account()
{
await using var fixture = await AuthFixture.CreateAsync();
for (var attempt = 0; attempt < 5; attempt++)
{
await Assert.ThrowsAsync<InvalidCredentialsException>(() =>
fixture.AuthService.LoginWithPasswordAsync(new PasswordLoginRequest(
AuthRealm.Tenant, fixture.TenantId, AuthFixture.Phone, "wrong-password", null, null)));
}
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);
}
[Fact]
public async Task Backend_permission_user_authenticates_without_an_additional_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.Authenticated, result.Status);
Assert.NotNull(result.User);
Assert.Single(await fixture.DbContext.AuthSessions.ToArrayAsync());
Assert.Empty(await fixture.DbContext.AuthChallenges.ToArrayAsync());
}
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 = 8;
options.Password.RequireDigit = false;
options.Password.RequireLowercase = false;
options.Password.RequireUppercase = false;
options.Password.RequireNonAlphanumeric = false;
options.Lockout.MaxFailedAccessAttempts = 5;
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15);
})
.AddEntityFrameworkStores<TikuDbContext>()
.AddSignInManager()
.AddDefaultTokenProviders()
.AddPasswordValidator<LetterAndDigitPasswordValidator<User>>();
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<IFeatureAccessService, UnlimitedFeatureAccessService>();
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 sealed class UnlimitedFeatureAccessService : IFeatureAccessService
{
public Task<FeatureAccessDecision> EvaluateAsync(
Guid tenantId,
string featureCode,
FeatureAccessOperation operation,
CancellationToken cancellationToken = default) =>
Task.FromResult(new FeatureAccessDecision(true, null, featureCode, operation));
public Task<IReadOnlySet<string>> GetEnabledFeaturesAsync(
Guid tenantId,
FeatureAccessOperation operation = FeatureAccessOperation.Read,
CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlySet<string>>(new HashSet<string>(SaasFeatureCatalog.All, StringComparer.Ordinal));
public Task<IReadOnlySet<string>> FilterPermissionCodesAsync(
Guid tenantId,
IEnumerable<string> permissionCodes,
FeatureAccessOperation operation = FeatureAccessOperation.Read,
CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlySet<string>>(permissionCodes.ToHashSet(StringComparer.Ordinal));
public Task<IReadOnlyCollection<FeatureQuotaSnapshot>> GetQuotaSummaryAsync(
Guid tenantId,
CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlyCollection<FeatureQuotaSnapshot>>([]);
public Task<bool> TryConsumeQuotaAsync(
Guid tenantId,
string metricCode,
long amount,
CancellationToken cancellationToken = default) => Task.FromResult(true);
public Task ReleaseQuotaAsync(
Guid tenantId,
string metricCode,
long amount,
CancellationToken cancellationToken = default) => Task.CompletedTask;
}
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,
PermissionModuleCode = "tenant_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 RejectingWechatClient : IWechatOAuthClient
{
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 RejectingProviderConfigService : ITenantExternalProviderConfigService
{
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();
}
}