feat: add local authentication
This commit is contained in:
229
Tiku.UnitTests/Auth/AuthServiceTests.cs
Normal file
229
Tiku.UnitTests/Auth/AuthServiceTests.cs
Normal file
@@ -0,0 +1,229 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Auth;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
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()
|
||||
{
|
||||
var hasher = new PasswordHasher();
|
||||
|
||||
var hash = hasher.Hash("passw0rd!");
|
||||
|
||||
Assert.True(hasher.Verify("passw0rd!", hash));
|
||||
Assert.False(hasher.Verify("wrong", hash));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Password_login_creates_session_and_success_event()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var hasher = new PasswordHasher();
|
||||
var seed = await SeedUserAsync(context, hasher.Hash("passw0rd!"));
|
||||
var service = CreateAuthService(context);
|
||||
|
||||
var result = await service.LoginWithPasswordAsync(new PasswordLoginRequest(
|
||||
seed.TenantId,
|
||||
seed.Phone,
|
||||
"passw0rd!",
|
||||
"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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Wrong_password_records_failed_event()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var hasher = new PasswordHasher();
|
||||
var seed = await SeedUserAsync(context, hasher.Hash("passw0rd!"));
|
||||
var service = CreateAuthService(context);
|
||||
|
||||
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
|
||||
{
|
||||
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 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)));
|
||||
}
|
||||
|
||||
private static TikuDbContext CreateContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<TikuDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
|
||||
return new TikuDbContext(options);
|
||||
}
|
||||
|
||||
private static IAuthService CreateAuthService(TikuDbContext context)
|
||||
{
|
||||
var tokenService = new TokenService(Options.Create(JwtOptions));
|
||||
var sessionService = new SessionService(context, tokenService, Options.Create(JwtOptions));
|
||||
var smsService = new SmsVerificationService(context);
|
||||
|
||||
return new AuthService(
|
||||
context,
|
||||
new PasswordHasher(),
|
||||
smsService,
|
||||
sessionService);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,12 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Tiku.Domain\Tiku.Domain.csproj" />
|
||||
<ProjectReference Include="..\Tiku.Application\Tiku.Application.csproj" />
|
||||
<ProjectReference Include="..\Tiku.Infrastructure\Tiku.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -29,4 +35,4 @@
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user