test(architecture): enforce capability boundaries
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Api.Options;
|
||||
using Tiku.Application.Auth;
|
||||
@@ -22,12 +21,8 @@ public sealed class AuthController(
|
||||
IAuthService authService,
|
||||
IOwnerActivationService ownerActivationService,
|
||||
ISmsVerificationService smsVerificationService,
|
||||
IAuthSessionStore sessionStore,
|
||||
ITenantContext tenantContext,
|
||||
ITenantContextInitializer tenantContextInitializer,
|
||||
ITenantDirectory tenantDirectory,
|
||||
ICurrentUser currentUser,
|
||||
IOptions<TenantResolutionOptions> tenantResolutionOptions) : ControllerBase
|
||||
AuthRequestContextResolver requestContextResolver,
|
||||
ICurrentUser currentUser) : ControllerBase
|
||||
{
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting(AuthRateLimitPolicies.Password)]
|
||||
@@ -58,11 +53,12 @@ public sealed class AuthController(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var realm = request.Realm!.Value;
|
||||
EnsureRouteRealm(realm);
|
||||
requestContextResolver.EnsureRouteRealm(realm, Request);
|
||||
if (realm != AuthRealm.Tenant)
|
||||
throw new RequiredFieldException("SMS authentication is only available in the tenant realm.");
|
||||
|
||||
var tenantId = await ResolveRealmTenantIdAsync(realm, request.TenantCode, cancellationToken)
|
||||
var tenantId = await requestContextResolver.ResolveRealmTenantIdAsync(realm, request.TenantCode, Request,
|
||||
cancellationToken)
|
||||
?? throw new RequiredFieldException("tenantCode is required for SMS authentication.");
|
||||
var result = await smsVerificationService.CreateCodeAsync(
|
||||
new SendSmsCodeRequest(
|
||||
@@ -88,13 +84,14 @@ public sealed class AuthController(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var realm = request.Realm!.Value;
|
||||
EnsureRouteRealm(realm);
|
||||
requestContextResolver.EnsureRouteRealm(realm, Request);
|
||||
var identifier = request.Identifier ?? request.Phone;
|
||||
if (string.IsNullOrWhiteSpace(identifier)) throw new RequiredFieldException("identifier is required.");
|
||||
var result = await authService.LoginWithPasswordAsync(
|
||||
new PasswordLoginRequest(
|
||||
realm,
|
||||
await ResolveRealmTenantIdAsync(realm, request.TenantCode, cancellationToken),
|
||||
await requestContextResolver.ResolveRealmTenantIdAsync(realm, request.TenantCode, Request,
|
||||
cancellationToken),
|
||||
identifier,
|
||||
request.Password,
|
||||
GetIpAddress(),
|
||||
@@ -116,11 +113,12 @@ public sealed class AuthController(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var realm = request.Realm!.Value;
|
||||
EnsureRouteRealm(realm);
|
||||
requestContextResolver.EnsureRouteRealm(realm, Request);
|
||||
var result = await authService.LoginWithSmsAsync(
|
||||
new SmsLoginRequest(
|
||||
realm,
|
||||
await ResolveRealmTenantIdAsync(realm, request.TenantCode, cancellationToken),
|
||||
await requestContextResolver.ResolveRealmTenantIdAsync(realm, request.TenantCode, Request,
|
||||
cancellationToken),
|
||||
request.Phone,
|
||||
request.Code,
|
||||
GetIpAddress(),
|
||||
@@ -142,11 +140,12 @@ public sealed class AuthController(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var realm = request.Realm!.Value;
|
||||
EnsureRouteRealm(realm);
|
||||
requestContextResolver.EnsureRouteRealm(realm, Request);
|
||||
var result = await authService.LoginWithWechatWebAsync(
|
||||
new WechatLoginRequest(
|
||||
realm,
|
||||
await ResolveRealmTenantIdAsync(realm, request.TenantCode, cancellationToken),
|
||||
await requestContextResolver.ResolveRealmTenantIdAsync(realm, request.TenantCode, Request,
|
||||
cancellationToken),
|
||||
request.Code,
|
||||
GetIpAddress(),
|
||||
Request.Headers.UserAgent.ToString()),
|
||||
@@ -167,11 +166,12 @@ public sealed class AuthController(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var realm = request.Realm!.Value;
|
||||
EnsureRouteRealm(realm);
|
||||
requestContextResolver.EnsureRouteRealm(realm, Request);
|
||||
var result = await authService.LoginWithWechatMiniAppAsync(
|
||||
new WechatLoginRequest(
|
||||
realm,
|
||||
await ResolveRealmTenantIdAsync(realm, request.TenantCode, cancellationToken),
|
||||
await requestContextResolver.ResolveRealmTenantIdAsync(realm, request.TenantCode, Request,
|
||||
cancellationToken),
|
||||
request.Code,
|
||||
GetIpAddress(),
|
||||
Request.Headers.UserAgent.ToString()),
|
||||
@@ -188,7 +188,7 @@ public sealed class AuthController(
|
||||
[FromBody] RefreshSessionDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ResolveRefreshTokenTenant(request.RefreshToken);
|
||||
requestContextResolver.ResolveRefreshTokenTenant(request.RefreshToken, Request);
|
||||
var result = await authService.RefreshAsync(
|
||||
new RefreshSessionRequest(
|
||||
request.RefreshToken,
|
||||
@@ -208,7 +208,7 @@ public sealed class AuthController(
|
||||
[FromBody] RefreshSessionDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ResolveRefreshTokenTenant(request.RefreshToken);
|
||||
requestContextResolver.ResolveRefreshTokenTenant(request.RefreshToken, Request);
|
||||
await authService.LogoutAsync(
|
||||
new LogoutSessionRequest(request.RefreshToken),
|
||||
cancellationToken);
|
||||
@@ -238,7 +238,7 @@ public sealed class AuthController(
|
||||
[FromBody] RequiredPasswordChangeDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ResolveAuthChallengeTenant(request.ChallengeToken);
|
||||
requestContextResolver.ResolveAuthChallengeTenant(request.ChallengeToken, Request);
|
||||
var result = await authService.ChangeRequiredPasswordAsync(
|
||||
new PasswordChangeChallengeRequest(
|
||||
request.ChallengeToken, request.NewPassword, GetIpAddress(), Request.Headers.UserAgent.ToString()),
|
||||
@@ -256,7 +256,8 @@ public sealed class AuthController(
|
||||
PasswordResetSmsSendDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = await ResolveRealmTenantIdAsync(AuthRealm.Tenant, request.TenantCode, cancellationToken)
|
||||
var tenantId = await requestContextResolver.ResolveRealmTenantIdAsync(AuthRealm.Tenant, request.TenantCode,
|
||||
Request, cancellationToken)
|
||||
?? throw new RequiredFieldException("tenantCode is required for password reset.");
|
||||
var result = await authService.RequestPasswordResetAsync(
|
||||
new PasswordResetCodeRequest(
|
||||
@@ -278,7 +279,8 @@ public sealed class AuthController(
|
||||
PasswordResetDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = await ResolveRealmTenantIdAsync(AuthRealm.Tenant, request.TenantCode, cancellationToken)
|
||||
var tenantId = await requestContextResolver.ResolveRealmTenantIdAsync(AuthRealm.Tenant, request.TenantCode,
|
||||
Request, cancellationToken)
|
||||
?? throw new RequiredFieldException("tenantCode is required for password reset.");
|
||||
await authService.ResetPasswordAsync(
|
||||
new PasswordResetRequest(
|
||||
@@ -315,111 +317,9 @@ public sealed class AuthController(
|
||||
return Ok(AuthenticationResultDto.FromApplication(result));
|
||||
}
|
||||
|
||||
private void ResolveRefreshTokenTenant(string refreshToken)
|
||||
{
|
||||
if (!sessionStore.TryParseRefreshToken(refreshToken, out var locator)) return;
|
||||
|
||||
if (locator.Realm == AuthRealm.Platform)
|
||||
{
|
||||
EnsurePlatformHost();
|
||||
if (tenantContext.IsResolved)
|
||||
throw new TenantContextConflictException(tenantContext.TenantId!.Value, Guid.Empty);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!tenantContext.IsResolved)
|
||||
throw new RequiredFieldException(
|
||||
"tenant refresh/logout requires a tenant host or x-tenant-code matching the refresh token.");
|
||||
|
||||
tenantContextInitializer.Initialize(locator.TenantId!.Value, null, TenantResolutionSource.RefreshToken);
|
||||
}
|
||||
|
||||
private void ResolveAuthChallengeTenant(string challengeToken)
|
||||
{
|
||||
var parts = challengeToken.Split('.', 4);
|
||||
if (parts.Length != 4 || parts[0] != "c1") return;
|
||||
|
||||
if (parts[1] == "p" && parts[2] == "-")
|
||||
{
|
||||
EnsurePlatformHost();
|
||||
if (tenantContext.IsResolved)
|
||||
throw new TenantContextConflictException(tenantContext.TenantId!.Value, Guid.Empty);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parts[1] != "t" || !Guid.TryParseExact(parts[2], "N", out var tenantId) || !tenantContext.IsResolved)
|
||||
throw new RequiredFieldException(
|
||||
"tenant authentication challenge requires a tenant host or x-tenant-code.");
|
||||
|
||||
tenantContextInitializer.Initialize(tenantId, null, TenantResolutionSource.RefreshToken);
|
||||
}
|
||||
|
||||
private string? GetIpAddress()
|
||||
{
|
||||
return HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||
}
|
||||
|
||||
private async Task<Guid?> ResolveRealmTenantIdAsync(
|
||||
AuthRealm realm,
|
||||
string? tenantCode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (realm == AuthRealm.Platform)
|
||||
{
|
||||
EnsurePlatformHost();
|
||||
if (tenantContext.IsResolved || !string.IsNullOrWhiteSpace(tenantCode))
|
||||
throw new RequiredFieldException(
|
||||
"platform realm does not accept tenantCode and must use a platform host.");
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (tenantContext.TenantId.HasValue)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(tenantCode) &&
|
||||
!string.Equals(tenantContext.TenantCode, tenantCode.Trim(), StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var supplied = await tenantDirectory.FindByCodeAsync(tenantCode.Trim(), cancellationToken);
|
||||
if (supplied?.TenantId != tenantContext.TenantId.Value)
|
||||
throw new TenantContextConflictException(
|
||||
tenantContext.TenantId.Value,
|
||||
supplied?.TenantId ?? Guid.Empty);
|
||||
}
|
||||
|
||||
return tenantContext.TenantId.Value;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tenantCode))
|
||||
throw new RequiredFieldException("tenantCode is required when the request host does not resolve a tenant.");
|
||||
|
||||
var tenant = await tenantDirectory.FindByCodeAsync(tenantCode.Trim(), cancellationToken)
|
||||
?? throw new TenantNotFoundException();
|
||||
tenantContextInitializer.Initialize(
|
||||
tenant.TenantId,
|
||||
tenant.TenantCode,
|
||||
TenantResolutionSource.TenantCode);
|
||||
return tenant.TenantId;
|
||||
}
|
||||
|
||||
private void EnsurePlatformHost()
|
||||
{
|
||||
var requestHost = Request.Host.Host.Trim().TrimEnd('.');
|
||||
if (!tenantResolutionOptions.Value.PlatformHosts.Any(host =>
|
||||
string.Equals(
|
||||
host.Trim().TrimEnd('.'),
|
||||
requestHost,
|
||||
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.");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user