From 558b2a4ea86ae971cbacf0898796ed7d1be5a355 Mon Sep 17 00:00:00 2001 From: xiong Date: Mon, 3 Aug 2026 14:34:23 +0800 Subject: [PATCH] =?UTF-8?q?fix=EF=BC=9A=E4=BF=AE=E5=A4=8Dopenapi=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ApiPresentationExtensions.cs | 3 +- Tiku.Api/Controllers/AuthController.cs | 18 +++++++++- Tiku.Api/Controllers/BrowserAuthController.cs | 13 ++++++- .../AuthenticationOperationTagsTransformer.cs | 34 +++++++++++++++++++ .../Api/AuthEndpointTests.cs | 29 +++++++++++++--- .../Api/AuthenticationTestClientExtensions.cs | 4 +-- .../Api/OpenApiDocumentationTests.cs | 29 +++++++++++++++- .../Api/PlatformAdminEndpointTests.cs | 4 +-- 8 files changed, 121 insertions(+), 13 deletions(-) create mode 100644 Tiku.Api/OpenApi/AuthenticationOperationTagsTransformer.cs diff --git a/Tiku.Api/Configuration/ApiPresentationExtensions.cs b/Tiku.Api/Configuration/ApiPresentationExtensions.cs index d2d74fd..b6d766a 100644 --- a/Tiku.Api/Configuration/ApiPresentationExtensions.cs +++ b/Tiku.Api/Configuration/ApiPresentationExtensions.cs @@ -18,9 +18,10 @@ internal static class ApiPresentationExtensions { options.AddDocumentTransformer(); options.AddOperationTransformer(); + options.AddOperationTransformer(); }); services.AddProblemDetails(); return services; } -} \ No newline at end of file +} diff --git a/Tiku.Api/Controllers/AuthController.cs b/Tiku.Api/Controllers/AuthController.cs index cad0059..b1699cb 100644 --- a/Tiku.Api/Controllers/AuthController.cs +++ b/Tiku.Api/Controllers/AuthController.cs @@ -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."); } -} \ No newline at end of file + + 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."); + } +} diff --git a/Tiku.Api/Controllers/BrowserAuthController.cs b/Tiku.Api/Controllers/BrowserAuthController.cs index 749c1a0..9554bcb 100644 --- a/Tiku.Api/Controllers/BrowserAuthController.cs +++ b/Tiku.Api/Controllers/BrowserAuthController.cs @@ -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."); \ No newline at end of file +public sealed class BrowserOriginException() : Exception("Browser authentication requires a same-origin request."); diff --git a/Tiku.Api/OpenApi/AuthenticationOperationTagsTransformer.cs b/Tiku.Api/OpenApi/AuthenticationOperationTagsTransformer.cs new file mode 100644 index 0000000..c15379e --- /dev/null +++ b/Tiku.Api/OpenApi/AuthenticationOperationTagsTransformer.cs @@ -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(); + operation.Tags.Clear(); + operation.Tags.Add(new OpenApiTagReference(tag, null!)); + return Task.CompletedTask; + } +} diff --git a/Tiku.IntegrationTests/Api/AuthEndpointTests.cs b/Tiku.IntegrationTests/Api/AuthEndpointTests.cs index d4cfbf2..b95da7e 100644 --- a/Tiku.IntegrationTests/Api/AuthEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/AuthEndpointTests.cs @@ -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 { - ["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")); } } -} \ No newline at end of file +} diff --git a/Tiku.IntegrationTests/Api/AuthenticationTestClientExtensions.cs b/Tiku.IntegrationTests/Api/AuthenticationTestClientExtensions.cs index 4592f4c..f21ee3b 100644 --- a/Tiku.IntegrationTests/Api/AuthenticationTestClientExtensions.cs +++ b/Tiku.IntegrationTests/Api/AuthenticationTestClientExtensions.cs @@ -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); } -} \ No newline at end of file +} diff --git a/Tiku.IntegrationTests/Api/OpenApiDocumentationTests.cs b/Tiku.IntegrationTests/Api/OpenApiDocumentationTests.cs index 986248d..d3e7fd2 100644 --- a/Tiku.IntegrationTests/Api/OpenApiDocumentationTests.cs +++ b/Tiku.IntegrationTests/Api/OpenApiDocumentationTests.cs @@ -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)); } -} \ No newline at end of file + + private static string? GetOnlyTag(JsonElement paths, string path) + { + return paths.GetProperty(path) + .GetProperty("post") + .GetProperty("tags") + .EnumerateArray() + .Single() + .GetString(); + } +} diff --git a/Tiku.IntegrationTests/Api/PlatformAdminEndpointTests.cs b/Tiku.IntegrationTests/Api/PlatformAdminEndpointTests.cs index 24ed233..0c51447 100644 --- a/Tiku.IntegrationTests/Api/PlatformAdminEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/PlatformAdminEndpointTests.cs @@ -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); } -} \ No newline at end of file +}