This commit is contained in:
@@ -18,9 +18,10 @@ internal static class ApiPresentationExtensions
|
||||
{
|
||||
options.AddDocumentTransformer<BearerSecuritySchemeTransformer>();
|
||||
options.AddOperationTransformer<PlatformOperationMetadataTransformer>();
|
||||
options.AddOperationTransformer<AuthenticationOperationTagsTransformer>();
|
||||
});
|
||||
services.AddProblemDetails();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ public sealed class AuthController(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var realm = request.Realm!.Value;
|
||||
EnsureRouteRealm(realm);
|
||||
if (realm != AuthRealm.Tenant)
|
||||
throw new RequiredFieldException("SMS authentication is only available in the tenant realm.");
|
||||
|
||||
@@ -87,6 +88,7 @@ public sealed class AuthController(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var realm = request.Realm!.Value;
|
||||
EnsureRouteRealm(realm);
|
||||
var identifier = request.Identifier ?? request.Phone;
|
||||
if (string.IsNullOrWhiteSpace(identifier)) throw new RequiredFieldException("identifier is required.");
|
||||
var result = await authService.LoginWithPasswordAsync(
|
||||
@@ -114,6 +116,7 @@ public sealed class AuthController(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var realm = request.Realm!.Value;
|
||||
EnsureRouteRealm(realm);
|
||||
var result = await authService.LoginWithSmsAsync(
|
||||
new SmsLoginRequest(
|
||||
realm,
|
||||
@@ -139,6 +142,7 @@ public sealed class AuthController(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var realm = request.Realm!.Value;
|
||||
EnsureRouteRealm(realm);
|
||||
var result = await authService.LoginWithWechatWebAsync(
|
||||
new WechatLoginRequest(
|
||||
realm,
|
||||
@@ -163,6 +167,7 @@ public sealed class AuthController(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var realm = request.Realm!.Value;
|
||||
EnsureRouteRealm(realm);
|
||||
var result = await authService.LoginWithWechatMiniAppAsync(
|
||||
new WechatLoginRequest(
|
||||
realm,
|
||||
@@ -406,4 +411,15 @@ public sealed class AuthController(
|
||||
StringComparison.OrdinalIgnoreCase)))
|
||||
throw new RequiredFieldException("platform realm is only available on a configured platform host.");
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureRouteRealm(AuthRealm realm)
|
||||
{
|
||||
var path = Request.Path.Value ?? string.Empty;
|
||||
var expectedRealm = path.StartsWith("/api/platform/auth/", StringComparison.OrdinalIgnoreCase)
|
||||
? AuthRealm.Platform
|
||||
: AuthRealm.Tenant;
|
||||
if (realm != expectedRealm)
|
||||
throw new RequiredFieldException(
|
||||
$"{realm.ToString().ToLowerInvariant()} realm must use the {expectedRealm.ToString().ToLowerInvariant()} authentication route.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ public sealed class BrowserAuthController(
|
||||
{
|
||||
EnsureTrustedOrigin();
|
||||
var realm = request.Realm!.Value;
|
||||
EnsureTenantRealm(realm);
|
||||
if (realm != AuthRealm.Tenant)
|
||||
throw new RequiredFieldException("SMS authentication is only available in the tenant realm.");
|
||||
var tenantId = await ResolveTenantIdAsync(realm, request.TenantCode, cancellationToken)
|
||||
@@ -80,6 +81,7 @@ public sealed class BrowserAuthController(
|
||||
{
|
||||
EnsureTrustedOrigin();
|
||||
var realm = request.Realm!.Value;
|
||||
EnsureTenantRealm(realm);
|
||||
var identifier = request.Identifier ?? request.Phone;
|
||||
if (string.IsNullOrWhiteSpace(identifier)) throw new RequiredFieldException("identifier is required.");
|
||||
var result = await authService.LoginWithPasswordAsync(new PasswordLoginRequest(
|
||||
@@ -102,6 +104,7 @@ public sealed class BrowserAuthController(
|
||||
{
|
||||
EnsureTrustedOrigin();
|
||||
var realm = request.Realm!.Value;
|
||||
EnsureTenantRealm(realm);
|
||||
var result = await authService.LoginWithSmsAsync(new SmsLoginRequest(
|
||||
realm,
|
||||
await ResolveTenantIdAsync(realm, request.TenantCode, cancellationToken),
|
||||
@@ -121,6 +124,7 @@ public sealed class BrowserAuthController(
|
||||
{
|
||||
EnsureTrustedOrigin();
|
||||
var realm = request.Realm!.Value;
|
||||
EnsureTenantRealm(realm);
|
||||
var result = await authService.LoginWithWechatWebAsync(new WechatLoginRequest(
|
||||
realm,
|
||||
await ResolveTenantIdAsync(realm, request.TenantCode, cancellationToken),
|
||||
@@ -139,6 +143,7 @@ public sealed class BrowserAuthController(
|
||||
{
|
||||
EnsureTrustedOrigin();
|
||||
var realm = request.Realm!.Value;
|
||||
EnsureTenantRealm(realm);
|
||||
var result = await authService.LoginWithWechatMiniAppAsync(new WechatLoginRequest(
|
||||
realm,
|
||||
await ResolveTenantIdAsync(realm, request.TenantCode, cancellationToken),
|
||||
@@ -344,6 +349,12 @@ public sealed class BrowserAuthController(
|
||||
tenantContextInitializer.Initialize(tenant.TenantId, tenant.TenantCode, TenantResolutionSource.TenantCode);
|
||||
return tenant.TenantId;
|
||||
}
|
||||
|
||||
private static void EnsureTenantRealm(AuthRealm realm)
|
||||
{
|
||||
if (realm != AuthRealm.Tenant)
|
||||
throw new RequiredFieldException("Browser tenant authentication only accepts the tenant realm.");
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class BrowserOriginException() : Exception("Browser authentication requires a same-origin request.");
|
||||
public sealed class BrowserOriginException() : Exception("Browser authentication requires a same-origin request.");
|
||||
|
||||
34
Tiku.Api/OpenApi/AuthenticationOperationTagsTransformer.cs
Normal file
34
Tiku.Api/OpenApi/AuthenticationOperationTagsTransformer.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using Microsoft.AspNetCore.OpenApi;
|
||||
using Microsoft.OpenApi;
|
||||
|
||||
namespace Tiku.Api.OpenApi;
|
||||
|
||||
internal sealed class AuthenticationOperationTagsTransformer : IOpenApiOperationTransformer
|
||||
{
|
||||
private static readonly (string Prefix, string Tag)[] AuthenticationTags =
|
||||
[
|
||||
("api/platform/auth/", "平台端-认证"),
|
||||
("api/tenant/auth/browser/", "租户端-浏览器认证"),
|
||||
("api/tenant/auth/", "租户端-认证"),
|
||||
("api/student/auth/", "学生端-认证")
|
||||
];
|
||||
|
||||
public Task TransformAsync(
|
||||
OpenApiOperation operation,
|
||||
OpenApiOperationTransformerContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var relativePath = context.Description.RelativePath;
|
||||
if (relativePath is null) return Task.CompletedTask;
|
||||
|
||||
var tag = AuthenticationTags
|
||||
.FirstOrDefault(candidate => relativePath.StartsWith(candidate.Prefix, StringComparison.OrdinalIgnoreCase))
|
||||
.Tag;
|
||||
if (tag is null) return Task.CompletedTask;
|
||||
|
||||
operation.Tags ??= new HashSet<OpenApiTagReference>();
|
||||
operation.Tags.Clear();
|
||||
operation.Tags.Add(new OpenApiTagReference(tag, null!));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,25 @@ namespace Tiku.IntegrationTests.Api;
|
||||
|
||||
public sealed class AuthEndpointTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("/api/platform/auth/login/password", AuthRealm.Tenant)]
|
||||
[InlineData("/api/tenant/auth/login/password", AuthRealm.Platform)]
|
||||
[InlineData("/api/student/auth/login/password", AuthRealm.Platform)]
|
||||
public async Task Password_login_route_rejects_a_mismatched_realm(string path, AuthRealm realm)
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using var response = await client.PostAsJsonAsync(path, new PasswordLoginDto
|
||||
{
|
||||
Realm = realm,
|
||||
Identifier = "invalid@example.test",
|
||||
Password = PasswordTestUserExtensions.TestPassword
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sms_send_creates_login_code_without_exposing_it_and_rejects_platform_realm()
|
||||
{
|
||||
@@ -121,13 +140,13 @@ public sealed class AuthEndpointTests
|
||||
{
|
||||
await using var factory = new ApiTestFactory(configurationOverrides: new Dictionary<string, string?>
|
||||
{
|
||||
["Tenancy:Resolution:ExemptPathPrefixes:3"] = "/api/tenant/auth"
|
||||
["Tenancy:Resolution:ExemptPathPrefixes:3"] = "/api/platform/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/tenant/auth/login/password")
|
||||
new HttpRequestMessage(HttpMethod.Post, "/api/platform/auth/login/password")
|
||||
{
|
||||
Content = JsonContent.Create(new PasswordLoginDto
|
||||
{
|
||||
@@ -136,11 +155,11 @@ public sealed class AuthEndpointTests
|
||||
Password = PasswordTestUserExtensions.TestPassword
|
||||
})
|
||||
},
|
||||
new HttpRequestMessage(HttpMethod.Post, "/api/tenant/auth/refresh")
|
||||
new HttpRequestMessage(HttpMethod.Post, "/api/platform/auth/refresh")
|
||||
{
|
||||
Content = JsonContent.Create(new RefreshSessionDto { RefreshToken = refreshToken })
|
||||
},
|
||||
new HttpRequestMessage(HttpMethod.Post, "/api/tenant/auth/logout")
|
||||
new HttpRequestMessage(HttpMethod.Post, "/api/platform/auth/logout")
|
||||
{
|
||||
Content = JsonContent.Create(new RefreshSessionDto { RefreshToken = refreshToken })
|
||||
}
|
||||
@@ -540,4 +559,4 @@ public sealed class AuthEndpointTests
|
||||
return Task.FromResult(new SmsProviderSendResult("test", "sent", "sms-message-id"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ internal static class AuthenticationTestClientExtensions
|
||||
{
|
||||
client.DefaultRequestHeaders.Remove("x-tenant-code");
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/api/tenant/auth/login/password",
|
||||
"/api/platform/auth/login/password",
|
||||
new PasswordLoginDto
|
||||
{
|
||||
Realm = AuthRealm.Platform,
|
||||
@@ -110,4 +110,4 @@ internal static class AuthenticationTestClientExtensions
|
||||
"Authentication response did not contain a refresh token.");
|
||||
return new TestAuthenticationTokens(accessToken, refreshToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,23 @@ public sealed class OpenApiDocumentationTests
|
||||
Assert.True(document.RootElement.GetProperty("paths").TryGetProperty("/api/public/catalog/regions", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Authentication_operations_are_grouped_by_client_boundary()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using var response = await client.GetAsync("/openapi/v1.json");
|
||||
using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
var paths = document.RootElement.GetProperty("paths");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal("平台端-认证", GetOnlyTag(paths, "/api/platform/auth/login/password"));
|
||||
Assert.Equal("租户端-认证", GetOnlyTag(paths, "/api/tenant/auth/login/password"));
|
||||
Assert.Equal("学生端-认证", GetOnlyTag(paths, "/api/student/auth/login/password"));
|
||||
Assert.Equal("租户端-浏览器认证", GetOnlyTag(paths, "/api/tenant/auth/browser/login/password"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Platform_operation_metadata_prefers_action_permission_and_exposes_risk()
|
||||
{
|
||||
@@ -61,4 +78,14 @@ public sealed class OpenApiDocumentationTests
|
||||
path.StartsWith("/api/tenant-content", StringComparison.Ordinal) ||
|
||||
path.StartsWith("/api/tenant-commerce", StringComparison.Ordinal));
|
||||
}
|
||||
}
|
||||
|
||||
private static string? GetOnlyTag(JsonElement paths, string path)
|
||||
{
|
||||
return paths.GetProperty(path)
|
||||
.GetProperty("post")
|
||||
.GetProperty("tags")
|
||||
.EnumerateArray()
|
||||
.Single()
|
||||
.GetString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ public sealed class PlatformAdminEndpointTests
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, (await targetClient.GetAsync("/api/tenant/me")).StatusCode);
|
||||
targetClient.DefaultRequestHeaders.Authorization = null;
|
||||
var login = await targetClient.PostAsJsonAsync(
|
||||
"/api/tenant/auth/login/password",
|
||||
"/api/platform/auth/login/password",
|
||||
new PasswordLoginDto
|
||||
{
|
||||
Realm = AuthRealm.Platform,
|
||||
@@ -1041,4 +1041,4 @@ public sealed class PlatformAdminEndpointTests
|
||||
]);
|
||||
return (userId, email);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user