forked from xiongyuxing/tiku-backend.net
383 lines
13 KiB
C#
383 lines
13 KiB
C#
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)));
|
|
}
|
|
|
|
[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.TenantAuthProviders.Add(new TenantAuthProvider
|
|
{
|
|
TenantId = tenantId,
|
|
Provider = "wechat_web",
|
|
Status = TenantAuthProviderStatus.Testing,
|
|
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);
|
|
|
|
return new AuthService(
|
|
context,
|
|
new PasswordHasher(),
|
|
smsService,
|
|
sessionService,
|
|
wechatOAuthClient ?? new FakeWechatOAuthClient());
|
|
}
|
|
|
|
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.TenantAuthProviders.Add(new TenantAuthProvider
|
|
{
|
|
TenantId = tenant.Id,
|
|
Provider = provider,
|
|
Status = TenantAuthProviderStatus.Testing,
|
|
ConfigPublic = WechatProviderConfig()
|
|
});
|
|
await context.SaveChangesAsync();
|
|
|
|
return tenant.Id;
|
|
}
|
|
|
|
private static JsonElement WechatProviderConfig()
|
|
{
|
|
return JsonSerializer.SerializeToElement(new
|
|
{
|
|
appId = "wx-app-id",
|
|
appSecret = "wx-app-secret"
|
|
});
|
|
}
|
|
|
|
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 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"}"""));
|
|
}
|
|
}
|
|
}
|