426 lines
16 KiB
C#
426 lines
16 KiB
C#
using System.Net;
|
|
using System.Net.Http.Json;
|
|
using System.Text.Json;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Options;
|
|
using Tiku.Application.Auth;
|
|
using Tiku.Api.Contracts;
|
|
using Tiku.Domain.Identity;
|
|
using Tiku.Domain.Tenancy;
|
|
using Tiku.Infrastructure.Auth;
|
|
using Tiku.Infrastructure.Commerce;
|
|
using Tiku.Infrastructure.Persistence;
|
|
|
|
namespace Tiku.IntegrationTests.Api;
|
|
|
|
public sealed class AuthEndpointTests
|
|
{
|
|
[Fact]
|
|
public async Task Sms_send_creates_login_code_without_exposing_it_and_rejects_platform_realm()
|
|
{
|
|
var provider = new CapturingSmsProvider();
|
|
await using var factory = new ApiTestFactory(smsProvider: provider);
|
|
var tenantId = Guid.NewGuid();
|
|
await factory.SeedAsync(new Tenant
|
|
{
|
|
Id = tenantId,
|
|
Slug = tenantId.ToString("N"),
|
|
Name = "SMS Tenant"
|
|
});
|
|
using var client = factory.CreateClient();
|
|
client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N"));
|
|
|
|
var response = await client.PostAsJsonAsync(
|
|
"/api/auth/sms/send",
|
|
new SendSmsCodeDto
|
|
{
|
|
Realm = AuthRealm.Tenant,
|
|
TenantCode = tenantId.ToString("N"),
|
|
Phone = "13800000000",
|
|
DeviceId = "sms-endpoint-device"
|
|
});
|
|
var body = await response.Content.ReadAsStringAsync();
|
|
|
|
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
|
Assert.DoesNotContain(provider.Code!, body, StringComparison.Ordinal);
|
|
Assert.Matches("^[0-9]{6}$", provider.Code!);
|
|
using (var scope = factory.CreateSystemScope("Verify SMS send endpoint"))
|
|
{
|
|
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
|
var dimensions = await dbContext.SmsSendRateLimits
|
|
.Select(item => item.Dimension)
|
|
.ToArrayAsync();
|
|
Assert.Contains(SmsRateLimitDimension.Tenant, dimensions);
|
|
Assert.Contains(SmsRateLimitDimension.Phone, dimensions);
|
|
Assert.Contains(SmsRateLimitDimension.Device, dimensions);
|
|
}
|
|
|
|
client.DefaultRequestHeaders.Remove("x-tenant-code");
|
|
var platformResponse = await client.PostAsJsonAsync(
|
|
"/api/auth/sms/send",
|
|
new SendSmsCodeDto
|
|
{
|
|
Realm = AuthRealm.Platform,
|
|
Phone = "13800000000",
|
|
DeviceId = "sms-endpoint-device"
|
|
});
|
|
Assert.Equal(HttpStatusCode.BadRequest, platformResponse.StatusCode);
|
|
Assert.Equal(1, provider.SendCount);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Custom_host_rejects_jwt_from_another_tenant_and_ignores_spoofed_tenant_header()
|
|
{
|
|
await using var factory = new ApiTestFactory();
|
|
var tenantB = await SeedLoginUserAsync(factory);
|
|
var tenantAId = Guid.NewGuid();
|
|
await factory.SeedAsync(
|
|
new Tenant
|
|
{
|
|
Id = tenantAId,
|
|
Slug = "tenant-a",
|
|
Name = "Tenant A",
|
|
Status = TenantStatus.Active,
|
|
Mode = TenantMode.Saas
|
|
},
|
|
new TenantDomain
|
|
{
|
|
TenantId = tenantAId,
|
|
Host = "a.example.test",
|
|
DomainType = TenantDomainType.Custom,
|
|
Status = TenantDomainStatus.Active,
|
|
IsPrimary = true
|
|
});
|
|
using var client = factory.CreateClient();
|
|
var tokens = await client.LoginAsTenantAsync(tenantB.TenantId, tenantB.Phone);
|
|
|
|
using var jwtRequest = new HttpRequestMessage(HttpMethod.Get, "/api/me");
|
|
jwtRequest.Headers.Host = "a.example.test";
|
|
jwtRequest.Headers.Authorization = new("Bearer", tokens.AccessToken);
|
|
var jwtResponse = await client.SendAsync(jwtRequest);
|
|
|
|
using var spoofRequest = new HttpRequestMessage(HttpMethod.Post, "/api/auth/login/password");
|
|
spoofRequest.Headers.Host = "a.example.test";
|
|
spoofRequest.Headers.Add("x-tenant-code", tenantB.TenantId.ToString("N"));
|
|
spoofRequest.Content = JsonContent.Create(new PasswordLoginDto
|
|
{
|
|
Realm = AuthRealm.Tenant,
|
|
Phone = tenantB.Phone,
|
|
Password = PasswordTestUserExtensions.TestPassword
|
|
});
|
|
var spoofResponse = await client.SendAsync(spoofRequest);
|
|
|
|
Assert.Equal(HttpStatusCode.Forbidden, jwtResponse.StatusCode);
|
|
Assert.NotEqual(HttpStatusCode.OK, spoofResponse.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Platform_authentication_artifacts_are_rejected_on_an_unconfigured_host()
|
|
{
|
|
await using var factory = new ApiTestFactory(configurationOverrides: new Dictionary<string, string?>
|
|
{
|
|
["Tenancy:Resolution:ExemptPathPrefixes:3"] = "/api/auth"
|
|
});
|
|
using var client = factory.CreateClient();
|
|
var refreshToken = $"v2.p.-.{Guid.NewGuid():N}.{new string('a', 86)}";
|
|
var requests = new[]
|
|
{
|
|
new HttpRequestMessage(HttpMethod.Post, "/api/auth/login/password")
|
|
{
|
|
Content = JsonContent.Create(new PasswordLoginDto
|
|
{
|
|
Realm = AuthRealm.Platform,
|
|
Phone = "admin@example.com",
|
|
Password = PasswordTestUserExtensions.TestPassword
|
|
})
|
|
},
|
|
new HttpRequestMessage(HttpMethod.Post, "/api/auth/refresh")
|
|
{
|
|
Content = JsonContent.Create(new RefreshSessionDto { RefreshToken = refreshToken })
|
|
},
|
|
new HttpRequestMessage(HttpMethod.Post, "/api/auth/logout")
|
|
{
|
|
Content = JsonContent.Create(new RefreshSessionDto { RefreshToken = refreshToken })
|
|
}
|
|
};
|
|
|
|
foreach (var request in requests)
|
|
{
|
|
using (request)
|
|
{
|
|
request.Headers.Host = "unconfigured.example.test";
|
|
using var response = await client.SendAsync(request);
|
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
|
}
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Password_login_can_access_current_user_and_tenant()
|
|
{
|
|
await using var factory = new ApiTestFactory();
|
|
var seed = await SeedLoginUserAsync(factory);
|
|
using var client = factory.CreateClient();
|
|
|
|
var tokens = await client.LoginAsTenantAsync(seed.TenantId, seed.Phone);
|
|
client.UseAccessToken(tokens);
|
|
var meResponse = await client.GetAsync("/api/me");
|
|
var tenantResponse = await client.GetAsync("/api/tenants/current");
|
|
|
|
Assert.Equal(HttpStatusCode.OK, meResponse.StatusCode);
|
|
Assert.Equal(HttpStatusCode.OK, tenantResponse.StatusCode);
|
|
Assert.Contains(seed.UserId.ToString(), await meResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase);
|
|
Assert.Contains(seed.TenantId.ToString(), await tenantResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Sms_login_can_access_current_user()
|
|
{
|
|
await using var factory = new ApiTestFactory();
|
|
var seed = await SeedLoginUserAsync(factory);
|
|
await SeedSmsCodeAsync(factory, seed.TenantId, seed.Phone, "123456");
|
|
using var client = factory.CreateClient();
|
|
client.DefaultRequestHeaders.Add("x-tenant-code", seed.TenantId.ToString("N"));
|
|
|
|
var loginResponse = await client.PostAsJsonAsync(
|
|
"/api/auth/login/sms",
|
|
new SmsLoginDto
|
|
{
|
|
Realm = AuthRealm.Tenant,
|
|
TenantCode = seed.TenantId.ToString("N"),
|
|
Phone = seed.Phone,
|
|
Code = "123456"
|
|
});
|
|
var tokens = await client.CompleteTenantAuthenticationAsync(
|
|
loginResponse,
|
|
seed.TenantId,
|
|
seed.Phone);
|
|
client.UseAccessToken(tokens);
|
|
var meResponse = await client.GetAsync("/api/me");
|
|
|
|
Assert.Equal(HttpStatusCode.OK, loginResponse.StatusCode);
|
|
Assert.Equal(HttpStatusCode.OK, meResponse.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Logout_revokes_access_and_refresh_tokens()
|
|
{
|
|
await using var factory = new ApiTestFactory();
|
|
var seed = await SeedLoginUserAsync(factory);
|
|
using var client = factory.CreateClient();
|
|
var tokens = await client.LoginAsTenantAsync(seed.TenantId, seed.Phone);
|
|
|
|
var logoutResponse = await client.PostAsJsonAsync(
|
|
"/api/auth/logout",
|
|
new RefreshSessionDto { RefreshToken = tokens.RefreshToken });
|
|
client.UseAccessToken(tokens);
|
|
var meResponse = await client.GetAsync("/api/me");
|
|
var refreshResponse = await client.PostAsJsonAsync(
|
|
"/api/auth/refresh",
|
|
new RefreshSessionDto { RefreshToken = tokens.RefreshToken });
|
|
|
|
Assert.Equal(HttpStatusCode.NoContent, logoutResponse.StatusCode);
|
|
Assert.Equal(HttpStatusCode.Unauthorized, meResponse.StatusCode);
|
|
Assert.Equal(HttpStatusCode.Unauthorized, refreshResponse.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Wechat_miniapp_login_can_access_current_user()
|
|
{
|
|
await using var factory = new ApiTestFactory(new FakeWechatOAuthClient());
|
|
var tenantId = Guid.NewGuid();
|
|
var secretRef = "tenant_secrets:identity:wechat_miniapp:default";
|
|
var protectedSecret = ProtectTenantSecret(
|
|
tenantId,
|
|
secretRef,
|
|
JsonSerializer.SerializeToElement(new { appSecret = "wx-app-secret" }));
|
|
await factory.SeedAsync(
|
|
new Tenant
|
|
{
|
|
Id = tenantId,
|
|
Slug = tenantId.ToString("N"),
|
|
Name = "Wechat Tenant"
|
|
},
|
|
new TenantExternalProvider
|
|
{
|
|
TenantId = tenantId,
|
|
Provider = "wechat_miniapp",
|
|
Capability = TenantExternalProviderCapability.Identity,
|
|
Status = TenantExternalProviderStatus.Active,
|
|
SecretRef = secretRef,
|
|
ConfigPublic = JsonSerializer.SerializeToElement(new
|
|
{
|
|
appId = "wx-app-id"
|
|
})
|
|
},
|
|
new TenantSecret
|
|
{
|
|
TenantId = tenantId,
|
|
Purpose = "identity",
|
|
Provider = "wechat_miniapp",
|
|
SecretKey = "default",
|
|
SecretRef = secretRef,
|
|
Status = TenantSecretStatus.Active,
|
|
EncryptionKeyId = protectedSecret.KeyId,
|
|
EncryptedPayload = protectedSecret.Ciphertext,
|
|
EncryptionNonce = protectedSecret.Nonce,
|
|
EncryptionTag = protectedSecret.Tag
|
|
});
|
|
using var client = factory.CreateClient();
|
|
client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N"));
|
|
|
|
var loginResponse = await client.PostAsJsonAsync(
|
|
"/api/auth/oauth/wechat-miniapp",
|
|
new OAuthCodeDto
|
|
{
|
|
Realm = AuthRealm.Tenant,
|
|
TenantCode = tenantId.ToString("N"),
|
|
Code = "wx-code"
|
|
});
|
|
var loginJson = await ReadJsonAsync(loginResponse);
|
|
var accessToken = loginJson.RootElement
|
|
.GetProperty("user")
|
|
.GetProperty("tokens")
|
|
.GetProperty("accessToken")
|
|
.GetString();
|
|
|
|
client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
|
|
var meResponse = await client.GetAsync("/api/me");
|
|
using var scope = factory.CreateSystemScope();
|
|
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
|
|
|
Assert.Equal(HttpStatusCode.OK, loginResponse.StatusCode);
|
|
Assert.Equal(HttpStatusCode.OK, meResponse.StatusCode);
|
|
Assert.Contains(dbContext.UserIdentities, identity =>
|
|
identity.Provider == "wechat_miniapp" &&
|
|
identity.OpenId == "mini-open-id" &&
|
|
identity.UnionId == "union-id");
|
|
var persistedUser = dbContext.Users.Single(user =>
|
|
dbContext.UserIdentities.Any(identity =>
|
|
identity.UserId == user.Id && identity.Provider == "wechat_miniapp"));
|
|
Assert.DoesNotContain(
|
|
"session_key",
|
|
persistedUser.RawProfile.GetRawText(),
|
|
StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedLoginUserAsync(
|
|
ApiTestFactory factory)
|
|
{
|
|
var tenantId = Guid.NewGuid();
|
|
var userId = Guid.NewGuid();
|
|
var phone = "13800000000";
|
|
await factory.SeedAsync(
|
|
new Tenant
|
|
{
|
|
Id = tenantId,
|
|
Slug = tenantId.ToString("N"),
|
|
Name = "Test Tenant"
|
|
},
|
|
new User
|
|
{
|
|
Id = userId,
|
|
Phone = phone,
|
|
Name = "Test User"
|
|
}.WithTestPassword(),
|
|
new TenantMembership
|
|
{
|
|
TenantId = tenantId,
|
|
UserId = userId,
|
|
Role = TenantRole.TenantAdmin,
|
|
Status = MembershipStatus.Active
|
|
});
|
|
|
|
return (tenantId, userId, phone);
|
|
}
|
|
|
|
private static async Task SeedSmsCodeAsync(
|
|
ApiTestFactory factory,
|
|
Guid tenantId,
|
|
string phone,
|
|
string code)
|
|
{
|
|
using var scope = factory.CreateSystemScope();
|
|
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
|
var smsOptions = scope.ServiceProvider.GetRequiredService<IOptions<SmsSecurityOptions>>().Value;
|
|
dbContext.SmsVerificationCodes.Add(new SmsVerificationCode
|
|
{
|
|
TenantId = tenantId,
|
|
Phone = phone,
|
|
Purpose = SmsPurpose.Login,
|
|
CodeHash = SmsCodeHashing.Hash(
|
|
tenantId,
|
|
phone,
|
|
SmsPurpose.Login,
|
|
code,
|
|
smsOptions.CodePepper),
|
|
Status = SmsVerificationStatus.Sent,
|
|
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5)
|
|
});
|
|
await dbContext.SaveChangesAsync();
|
|
}
|
|
|
|
private static async Task<JsonDocument> ReadJsonAsync(HttpResponseMessage response)
|
|
{
|
|
var stream = await response.Content.ReadAsStreamAsync();
|
|
return await JsonDocument.ParseAsync(stream);
|
|
}
|
|
|
|
private static ProtectedTenantSecret ProtectTenantSecret(Guid tenantId, string secretRef, JsonElement payload)
|
|
{
|
|
var protector = new TenantSecretProtector(Options.Create(new TenantSecretEncryptionOptions
|
|
{
|
|
KeyId = "development-v1",
|
|
MasterKey = TenantSecretEncryptionOptions.DevelopmentMasterKey
|
|
}));
|
|
return protector.Protect(tenantId, secretRef, payload);
|
|
}
|
|
|
|
private sealed class FakeWechatOAuthClient : IWechatOAuthClient
|
|
{
|
|
public Task<WechatIdentity> ExchangeWebCodeAsync(
|
|
WechatProviderOptions options,
|
|
string code,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return Task.FromResult(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(new WechatIdentity(
|
|
"mini-open-id",
|
|
"union-id",
|
|
null,
|
|
null,
|
|
"session-key",
|
|
"""{"openid":"mini-open-id","unionid":"union-id","session_key":"session-key"}"""));
|
|
}
|
|
}
|
|
|
|
private sealed class CapturingSmsProvider : ISmsProvider
|
|
{
|
|
public int SendCount { get; private set; }
|
|
public string? Code { get; private set; }
|
|
|
|
public Task<SmsProviderSendResult> SendAsync(
|
|
SmsProviderSendRequest request,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
SendCount++;
|
|
Code = request.Code;
|
|
return Task.FromResult(new SmsProviderSendResult("test", "sent", "sms-message-id"));
|
|
}
|
|
}
|
|
}
|