forked from xiongyuxing/tiku-backend.net
281 lines
13 KiB
C#
281 lines
13 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"));
|
|
}
|
|
|
|
[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_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 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();
|
|
}
|
|
}
|