feat: harden SaaS authentication and authorization

This commit is contained in:
2026-07-28 12:15:51 +08:00
parent f22f329d33
commit 5d2248efee
123 changed files with 9090 additions and 2822 deletions

View File

@@ -1,6 +1,7 @@
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;
@@ -15,6 +16,59 @@ 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()
{
@@ -39,20 +93,11 @@ public sealed class AuthEndpointTests
IsPrimary = true
});
using var client = factory.CreateClient();
var loginResponse = await client.PostAsJsonAsync(
"/api/auth/login/password",
new PasswordLoginDto
{
TenantCode = tenantB.TenantId.ToString("N"),
Phone = tenantB.Phone,
Password = "passw0rd!"
});
var loginJson = await ReadJsonAsync(loginResponse);
var accessToken = loginJson.RootElement.GetProperty("tokens").GetProperty("accessToken").GetString();
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", accessToken);
jwtRequest.Headers.Authorization = new("Bearer", tokens.AccessToken);
var jwtResponse = await client.SendAsync(jwtRequest);
using var spoofRequest = new HttpRequestMessage(HttpMethod.Post, "/api/auth/login/password");
@@ -60,8 +105,9 @@ public sealed class AuthEndpointTests
spoofRequest.Headers.Add("x-tenant-code", tenantB.TenantId.ToString("N"));
spoofRequest.Content = JsonContent.Create(new PasswordLoginDto
{
Realm = AuthRealm.Tenant,
Phone = tenantB.Phone,
Password = "passw0rd!"
Password = PasswordTestUserExtensions.TestPassword
});
var spoofResponse = await client.SendAsync(spoofRequest);
@@ -69,6 +115,54 @@ public sealed class AuthEndpointTests
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 })
},
new HttpRequestMessage(HttpMethod.Post, "/api/auth/mfa/totp/setup")
{
Content = JsonContent.Create(new MfaChallengeDto
{
ChallengeToken = $"c1.p.-.{new string('b', 86)}"
})
}
};
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()
{
@@ -76,25 +170,11 @@ public sealed class AuthEndpointTests
var seed = await SeedLoginUserAsync(factory);
using var client = factory.CreateClient();
var loginResponse = await client.PostAsJsonAsync(
"/api/auth/login/password",
new PasswordLoginDto
{
TenantCode = seed.TenantId.ToString("N"),
Phone = seed.Phone,
Password = "passw0rd!"
});
var loginJson = await ReadJsonAsync(loginResponse);
var accessToken = loginJson.RootElement
.GetProperty("tokens")
.GetProperty("accessToken")
.GetString();
client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
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, loginResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, meResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, tenantResponse.StatusCode);
Assert.Contains(seed.UserId.ToString(), await meResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase);
@@ -108,22 +188,22 @@ public sealed class AuthEndpointTests
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 loginJson = await ReadJsonAsync(loginResponse);
var accessToken = loginJson.RootElement
.GetProperty("tokens")
.GetProperty("accessToken")
.GetString();
client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
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);
@@ -136,27 +216,16 @@ public sealed class AuthEndpointTests
await using var factory = new ApiTestFactory();
var seed = await SeedLoginUserAsync(factory);
using var client = factory.CreateClient();
var loginResponse = await client.PostAsJsonAsync(
"/api/auth/login/password",
new PasswordLoginDto
{
TenantCode = seed.TenantId.ToString("N"),
Phone = seed.Phone,
Password = "passw0rd!"
});
var loginJson = await ReadJsonAsync(loginResponse);
var tokens = loginJson.RootElement.GetProperty("tokens");
var accessToken = tokens.GetProperty("accessToken").GetString();
var refreshToken = tokens.GetProperty("refreshToken").GetString();
var tokens = await client.LoginAsTenantAsync(seed.TenantId, seed.Phone);
var logoutResponse = await client.PostAsJsonAsync(
"/api/auth/logout",
new RefreshSessionDto { RefreshToken = refreshToken! });
client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
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 = refreshToken! });
new RefreshSessionDto { RefreshToken = tokens.RefreshToken });
Assert.Equal(HttpStatusCode.NoContent, logoutResponse.StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, meResponse.StatusCode);
@@ -206,16 +275,19 @@ public sealed class AuthEndpointTests
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();
@@ -231,6 +303,13 @@ public sealed class AuthEndpointTests
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(
@@ -239,8 +318,6 @@ public sealed class AuthEndpointTests
var tenantId = Guid.NewGuid();
var userId = Guid.NewGuid();
var phone = "13800000000";
var passwordHash = new PasswordHasher().Hash("passw0rd!");
await factory.SeedAsync(
new Tenant
{
@@ -253,21 +330,13 @@ public sealed class AuthEndpointTests
Id = userId,
Phone = phone,
Name = "Test User"
},
}.WithTestPassword(),
new TenantMembership
{
TenantId = tenantId,
UserId = userId,
Role = TenantRole.TenantAdmin,
Status = MembershipStatus.Active
},
new UserIdentity
{
UserId = userId,
Provider = "password",
ProviderSubject = phone,
Phone = phone,
SecretPayload = CreateSecretPayload(passwordHash)
});
return (tenantId, userId, phone);
@@ -281,12 +350,18 @@ public sealed class AuthEndpointTests
{
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),
CodeHash = SmsCodeHashing.Hash(
tenantId,
phone,
SmsPurpose.Login,
code,
smsOptions.CodePepper),
Status = SmsVerificationStatus.Sent,
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5)
});
@@ -299,13 +374,6 @@ public sealed class AuthEndpointTests
return await JsonDocument.ParseAsync(stream);
}
private static JsonElement CreateSecretPayload(string passwordHash)
{
using var document = JsonDocument.Parse(
$$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}""");
return document.RootElement.Clone();
}
private static ProtectedTenantSecret ProtectTenantSecret(Guid tenantId, string secretRef, JsonElement payload)
{
var protector = new TenantSecretProtector(Options.Create(new TenantSecretEncryptionOptions
@@ -346,4 +414,19 @@ public sealed class AuthEndpointTests
"""{"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"));
}
}
}