feat: add local authentication

This commit is contained in:
xiong
2026-07-26 12:51:56 +08:00
parent e8b03c57bc
commit 2b02bbef7b
21 changed files with 1014 additions and 15 deletions

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

View File

@@ -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>