forked from gongxuegit/tiku-backend.net
feat: enforce tenant isolation and shared question bank
This commit is contained in:
@@ -10,11 +10,11 @@ namespace Tiku.Api.Contracts;
|
||||
public sealed class PasswordLoginDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户 ID。登录阶段允许客户端指定租户,登录后的业务接口以 JWT/session 解析出的当前租户为准。
|
||||
/// 平台控制域名登录时使用的租户代码;自定义域名登录可省略。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Description("租户 ID。登录阶段允许客户端指定租户,登录后的业务接口以 JWT/session 解析出的当前租户为准。")]
|
||||
public Guid TenantId { get; set; }
|
||||
[StringLength(100)]
|
||||
[Description("平台控制域名登录时使用的租户代码;自定义域名登录可省略。")]
|
||||
public string? TenantCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 手机号,建议前端提交规范化后的中国大陆手机号。
|
||||
@@ -39,11 +39,11 @@ public sealed class PasswordLoginDto
|
||||
public sealed class SmsLoginDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户 ID。
|
||||
/// 平台控制域名登录时使用的租户代码;自定义域名登录可省略。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Description("租户 ID。")]
|
||||
public Guid TenantId { get; set; }
|
||||
[StringLength(100)]
|
||||
[Description("平台控制域名登录时使用的租户代码;自定义域名登录可省略。")]
|
||||
public string? TenantCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 中国大陆手机号。
|
||||
@@ -68,11 +68,11 @@ public sealed class SmsLoginDto
|
||||
public sealed class OAuthCodeDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户 ID。
|
||||
/// 平台控制域名登录时使用的租户代码;自定义域名登录可省略。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Description("租户 ID。")]
|
||||
public Guid TenantId { get; set; }
|
||||
[StringLength(100)]
|
||||
[Description("平台控制域名登录时使用的租户代码;自定义域名登录可省略。")]
|
||||
public string? TenantCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// OAuth 平台返回的一次性授权 code。
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
@@ -252,6 +254,9 @@ public sealed class CollectionQuestionDto
|
||||
[Required]
|
||||
public Guid QuestionId { get; set; }
|
||||
|
||||
[Required]
|
||||
public QuestionSource Source { get; set; } = QuestionSource.Tenant;
|
||||
|
||||
[StringLength(100)]
|
||||
public string? SectionKey { get; set; }
|
||||
|
||||
@@ -265,7 +270,13 @@ public sealed class CollectionQuestionDto
|
||||
|
||||
public CollectionQuestionCommand ToCommand()
|
||||
{
|
||||
return new CollectionQuestionCommand(QuestionId, SectionKey, Order, Score, Required, Metadata);
|
||||
return new CollectionQuestionCommand(
|
||||
new QuestionLocator(Source, QuestionId),
|
||||
SectionKey,
|
||||
Order,
|
||||
Score,
|
||||
Required,
|
||||
Metadata);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json;
|
||||
using Tiku.Application.Learning;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
@@ -107,9 +109,7 @@ public sealed class SubmitPracticeSessionDto
|
||||
public sealed class SubmitAnswerDto
|
||||
{
|
||||
[Required]
|
||||
public Guid QuestionId { get; set; }
|
||||
|
||||
public Guid? PracticeSessionId { get; set; }
|
||||
public Guid SessionQuestionId { get; set; }
|
||||
|
||||
public IReadOnlyCollection<string>? SelectedOptions { get; set; }
|
||||
|
||||
@@ -121,8 +121,7 @@ public sealed class SubmitAnswerDto
|
||||
public SubmitAnswerCommand ToCommand()
|
||||
{
|
||||
return new SubmitAnswerCommand(
|
||||
QuestionId,
|
||||
PracticeSessionId,
|
||||
SessionQuestionId,
|
||||
SelectedOptions,
|
||||
AnswerText,
|
||||
SelfJudgedCorrect);
|
||||
@@ -134,11 +133,14 @@ public sealed class QuestionActionDto
|
||||
[Required]
|
||||
public Guid QuestionId { get; set; }
|
||||
|
||||
[Required]
|
||||
public QuestionSource Source { get; set; } = QuestionSource.Tenant;
|
||||
|
||||
public bool? Favorite { get; set; }
|
||||
|
||||
public QuestionActionCommand ToCommand()
|
||||
{
|
||||
return new QuestionActionCommand(QuestionId, Favorite);
|
||||
return new QuestionActionCommand(new QuestionLocator(Source, QuestionId), Favorite);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
using Tiku.Domain.Content;
|
||||
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
@@ -24,6 +25,8 @@ public sealed class QuestionBankQueryDto
|
||||
|
||||
public Guid? CollectionId { get; set; }
|
||||
|
||||
public QuestionSource? Source { get; set; }
|
||||
|
||||
[StringLength(50)]
|
||||
public string? Type { get; set; }
|
||||
|
||||
@@ -54,7 +57,8 @@ public sealed class QuestionBankQueryDto
|
||||
ParseQuestionIds(),
|
||||
Type,
|
||||
Keyword,
|
||||
Limit);
|
||||
Limit,
|
||||
Source);
|
||||
}
|
||||
|
||||
private Guid[] ParseQuestionIds()
|
||||
|
||||
31
Tiku.Api/Contracts/TaxonomyDtos.cs
Normal file
31
Tiku.Api/Contracts/TaxonomyDtos.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
public sealed class CreateTaxonomyNodeDto
|
||||
{
|
||||
public Guid? ParentId { get; set; }
|
||||
public QuestionSource? ParentSource { get; set; }
|
||||
[Required]
|
||||
public TaxonomyNodeType NodeType { get; set; }
|
||||
[Required, StringLength(100)]
|
||||
public string Code { get; set; } = string.Empty;
|
||||
[Required, StringLength(300)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public int SortOrder { get; set; }
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public CreateTaxonomyNodeCommand ToCommand() => new(
|
||||
ParentId,
|
||||
ParentSource,
|
||||
NodeType,
|
||||
Code,
|
||||
Name,
|
||||
SortOrder,
|
||||
Metadata);
|
||||
}
|
||||
28
Tiku.Api/Contracts/TenantFrontendConfigDtos.cs
Normal file
28
Tiku.Api/Contracts/TenantFrontendConfigDtos.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Common;
|
||||
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
public sealed class SaveTenantFrontendConfigDraftDto
|
||||
{
|
||||
public JsonElement Branding { get; set; } = JsonDefaults.Object();
|
||||
public JsonElement Theme { get; set; } = JsonDefaults.Object();
|
||||
public JsonElement Features { get; set; } = JsonDefaults.Object();
|
||||
public JsonElement Navigation { get; set; } = JsonDefaults.Array();
|
||||
public JsonElement HomeModules { get; set; } = JsonDefaults.Array();
|
||||
|
||||
public TenantFrontendConfigDraft ToDraft() => new(
|
||||
Branding,
|
||||
Theme,
|
||||
Features,
|
||||
Navigation,
|
||||
HomeModules);
|
||||
}
|
||||
|
||||
public sealed class PublishTenantFrontendConfigDto
|
||||
{
|
||||
[Range(1, int.MaxValue)]
|
||||
public int ExpectedVersion { get; set; }
|
||||
}
|
||||
@@ -15,7 +15,7 @@ namespace Tiku.Api.Controllers;
|
||||
[Route("api/assets")]
|
||||
public sealed class AssetsController(
|
||||
IAssetAccessService assetAccessService,
|
||||
ICurrentTenant currentTenant,
|
||||
ITenantContext currentTenant,
|
||||
ICurrentUser currentUser,
|
||||
TikuDbContext dbContext) : ControllerBase
|
||||
{
|
||||
|
||||
@@ -2,13 +2,21 @@ using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Infrastructure.Content;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/auth")]
|
||||
[Produces("application/json")]
|
||||
public sealed class AuthController(IAuthService authService) : ControllerBase
|
||||
public sealed class AuthController(
|
||||
IAuthService authService,
|
||||
ISessionService sessionService,
|
||||
ITenantContext tenantContext,
|
||||
ITenantContextInitializer tenantContextInitializer,
|
||||
ITenantDirectory tenantDirectory) : ControllerBase
|
||||
{
|
||||
[AllowAnonymous]
|
||||
[HttpPost("login/password")]
|
||||
@@ -22,7 +30,7 @@ public sealed class AuthController(IAuthService authService) : ControllerBase
|
||||
{
|
||||
var result = await authService.LoginWithPasswordAsync(
|
||||
new PasswordLoginRequest(
|
||||
request.TenantId,
|
||||
await ResolveTenantIdAsync(request.TenantCode, cancellationToken),
|
||||
request.Phone,
|
||||
request.Password,
|
||||
GetIpAddress(),
|
||||
@@ -44,7 +52,7 @@ public sealed class AuthController(IAuthService authService) : ControllerBase
|
||||
{
|
||||
var result = await authService.LoginWithSmsAsync(
|
||||
new SmsLoginRequest(
|
||||
request.TenantId,
|
||||
await ResolveTenantIdAsync(request.TenantCode, cancellationToken),
|
||||
request.Phone,
|
||||
request.Code,
|
||||
GetIpAddress(),
|
||||
@@ -67,7 +75,7 @@ public sealed class AuthController(IAuthService authService) : ControllerBase
|
||||
{
|
||||
var result = await authService.LoginWithWechatWebAsync(
|
||||
new WechatLoginRequest(
|
||||
request.TenantId,
|
||||
await ResolveTenantIdAsync(request.TenantCode, cancellationToken),
|
||||
request.Code,
|
||||
GetIpAddress(),
|
||||
Request.Headers.UserAgent.ToString()),
|
||||
@@ -89,7 +97,7 @@ public sealed class AuthController(IAuthService authService) : ControllerBase
|
||||
{
|
||||
var result = await authService.LoginWithWechatMiniAppAsync(
|
||||
new WechatLoginRequest(
|
||||
request.TenantId,
|
||||
await ResolveTenantIdAsync(request.TenantCode, cancellationToken),
|
||||
request.Code,
|
||||
GetIpAddress(),
|
||||
Request.Headers.UserAgent.ToString()),
|
||||
@@ -106,6 +114,7 @@ public sealed class AuthController(IAuthService authService) : ControllerBase
|
||||
[FromBody] RefreshSessionDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ResolveRefreshTokenTenant(request.RefreshToken);
|
||||
var result = await authService.RefreshAsync(
|
||||
new RefreshSessionRequest(
|
||||
request.RefreshToken,
|
||||
@@ -125,6 +134,7 @@ public sealed class AuthController(IAuthService authService) : ControllerBase
|
||||
[FromBody] RefreshSessionDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ResolveRefreshTokenTenant(request.RefreshToken);
|
||||
await authService.LogoutAsync(
|
||||
new LogoutSessionRequest(request.RefreshToken),
|
||||
cancellationToken);
|
||||
@@ -132,8 +142,51 @@ public sealed class AuthController(IAuthService authService) : ControllerBase
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
private void ResolveRefreshTokenTenant(string refreshToken)
|
||||
{
|
||||
if (!sessionService.TryParseRefreshToken(refreshToken, out var locator))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
tenantContextInitializer.Initialize(locator.TenantId, null, TenantResolutionSource.RefreshToken);
|
||||
}
|
||||
|
||||
private string? GetIpAddress()
|
||||
{
|
||||
return HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||
}
|
||||
|
||||
private async Task<Guid> ResolveTenantIdAsync(string? tenantCode, CancellationToken cancellationToken)
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ public sealed class CatalogController(
|
||||
IQuestionBankQueryService questionBankQueryService,
|
||||
IStudyContentQueryService studyContentQueryService,
|
||||
IAssetQueryService assetQueryService,
|
||||
ICurrentTenant currentTenant,
|
||||
ITenantContext currentTenant,
|
||||
TikuDbContext dbContext) : ControllerBase
|
||||
{
|
||||
[HttpGet("regions")]
|
||||
|
||||
@@ -6,6 +6,7 @@ using System.Text.Json;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Application.Commerce;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
@@ -16,7 +17,9 @@ namespace Tiku.Api.Controllers;
|
||||
public sealed class CommerceController(
|
||||
ICommerceService commerceService,
|
||||
ICurrentUser currentUser,
|
||||
ICurrentTenant currentTenant) : ControllerBase
|
||||
ITenantContext currentTenant,
|
||||
ITenantContextInitializer tenantInitializer,
|
||||
ITenantDirectory tenantDirectory) : ControllerBase
|
||||
{
|
||||
[HttpPost("orders")]
|
||||
[EndpointSummary("创建学生端订单")]
|
||||
@@ -125,10 +128,13 @@ public sealed class CommerceController(
|
||||
[EndpointSummary("微信支付回调")]
|
||||
[ProducesResponseType<PaymentNotificationProcessResult>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PaymentNotificationProcessResult>> WechatPayNotify(
|
||||
[FromQuery] Guid tenantId,
|
||||
[FromQuery] string? tenantCode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await ProcessNotificationAsync(tenantId, PaymentProviders.WechatPay, cancellationToken));
|
||||
return Ok(await ProcessNotificationAsync(
|
||||
await ResolveNotificationTenantAsync(tenantCode, cancellationToken),
|
||||
PaymentProviders.WechatPay,
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
@@ -136,10 +142,33 @@ public sealed class CommerceController(
|
||||
[EndpointSummary("支付宝支付回调")]
|
||||
[ProducesResponseType<PaymentNotificationProcessResult>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PaymentNotificationProcessResult>> AlipayNotify(
|
||||
[FromQuery] Guid tenantId,
|
||||
[FromQuery] string? tenantCode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await ProcessNotificationAsync(tenantId, PaymentProviders.Alipay, cancellationToken));
|
||||
return Ok(await ProcessNotificationAsync(
|
||||
await ResolveNotificationTenantAsync(tenantCode, cancellationToken),
|
||||
PaymentProviders.Alipay,
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
private async Task<Guid> ResolveNotificationTenantAsync(
|
||||
string? tenantCode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentTenant.TenantId.HasValue)
|
||||
{
|
||||
return currentTenant.TenantId.Value;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tenantCode))
|
||||
{
|
||||
throw new CommerceException("Tenant code is required for payment notification.", "tenant_required");
|
||||
}
|
||||
|
||||
var tenant = await tenantDirectory.FindByCodeAsync(tenantCode.Trim(), cancellationToken)
|
||||
?? throw new CommerceException("Tenant was not found.", "tenant_not_found");
|
||||
tenantInitializer.Initialize(tenant.TenantId, tenant.TenantCode, TenantResolutionSource.TenantCode);
|
||||
return tenant.TenantId;
|
||||
}
|
||||
|
||||
private CommerceActor ResolveActor()
|
||||
|
||||
@@ -13,7 +13,7 @@ namespace Tiku.Api.Controllers;
|
||||
public sealed class CommissionController(
|
||||
ICommissionService commissionService,
|
||||
ICurrentUser currentUser,
|
||||
ICurrentTenant currentTenant) : ControllerBase
|
||||
ITenantContext currentTenant) : ControllerBase
|
||||
{
|
||||
[HttpGet("settings")]
|
||||
public async Task<ActionResult<CommissionSettingsItem>> Settings(CancellationToken cancellationToken) =>
|
||||
|
||||
@@ -13,7 +13,7 @@ namespace Tiku.Api.Controllers;
|
||||
public sealed class CrmController(
|
||||
ICrmService crmService,
|
||||
ICurrentUser currentUser,
|
||||
ICurrentTenant currentTenant) : ControllerBase
|
||||
ITenantContext currentTenant) : ControllerBase
|
||||
{
|
||||
[HttpGet("config")]
|
||||
[EndpointSummary("查询 CRM 推送配置")]
|
||||
|
||||
@@ -13,7 +13,7 @@ namespace Tiku.Api.Controllers;
|
||||
public sealed class LearningController(
|
||||
ILearningActivityService learningActivityService,
|
||||
ICurrentUser currentUser,
|
||||
ICurrentTenant currentTenant) : ControllerBase
|
||||
ITenantContext currentTenant) : ControllerBase
|
||||
{
|
||||
[HttpGet("stats")]
|
||||
[EndpointSummary("查询学习统计")]
|
||||
|
||||
@@ -13,7 +13,7 @@ namespace Tiku.Api.Controllers;
|
||||
public sealed class PointsController(
|
||||
IPointService pointService,
|
||||
ICurrentUser currentUser,
|
||||
ICurrentTenant currentTenant) : ControllerBase
|
||||
ITenantContext currentTenant) : ControllerBase
|
||||
{
|
||||
[HttpGet("summary")]
|
||||
[EndpointSummary("查询当前用户积分摘要")]
|
||||
|
||||
@@ -14,7 +14,7 @@ namespace Tiku.Api.Controllers;
|
||||
public sealed class ProfileController(
|
||||
IProfileService profileService,
|
||||
ICurrentUser currentUser,
|
||||
ICurrentTenant currentTenant) : ControllerBase
|
||||
ITenantContext currentTenant) : ControllerBase
|
||||
{
|
||||
[HttpGet("me")]
|
||||
[EndpointSummary("获取当前学生资料")]
|
||||
|
||||
@@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Application.Growth;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
@@ -15,8 +16,9 @@ namespace Tiku.Api.Controllers;
|
||||
public sealed class ReferralController(
|
||||
IReferralService referralService,
|
||||
ICurrentUser currentUser,
|
||||
ICurrentTenant currentTenant,
|
||||
TikuDbContext dbContext) : ControllerBase
|
||||
ITenantContext currentTenant,
|
||||
ITenantContextInitializer tenantInitializer,
|
||||
ITenantDirectory tenantDirectory) : ControllerBase
|
||||
{
|
||||
[HttpPost("invite-code")]
|
||||
[Authorize(Policy = TikuPolicies.CurrentTenantMember)]
|
||||
@@ -223,13 +225,9 @@ public sealed class ReferralController(
|
||||
throw new TenantNotFoundException();
|
||||
}
|
||||
|
||||
var tenantId = await dbContext.Tenants
|
||||
.Where(tenant =>
|
||||
tenant.Slug == resolvedTenantCode.Trim() &&
|
||||
tenant.Status == TenantStatus.Active)
|
||||
.Select(tenant => (Guid?)tenant.Id)
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
|
||||
return tenantId ?? throw new TenantNotFoundException();
|
||||
var tenant = await tenantDirectory.FindByCodeAsync(resolvedTenantCode.Trim(), cancellationToken)
|
||||
?? throw new TenantNotFoundException();
|
||||
tenantInitializer.Initialize(tenant.TenantId, tenant.TenantCode, TenantResolutionSource.TenantCode);
|
||||
return tenant.TenantId;
|
||||
}
|
||||
}
|
||||
|
||||
45
Tiku.Api/Controllers/RuntimeController.cs
Normal file
45
Tiku.Api/Controllers/RuntimeController.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[AllowAnonymous]
|
||||
[Produces("application/json")]
|
||||
[Route("api/runtime")]
|
||||
public sealed class RuntimeController(
|
||||
ITenantContext tenantContext,
|
||||
ITenantFrontendConfigService frontendConfigService) : ControllerBase
|
||||
{
|
||||
[HttpGet("bootstrap")]
|
||||
[EndpointSummary("获取租户前端运行时配置")]
|
||||
[ProducesResponseType<TenantRuntimeBootstrap>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status304NotModified)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<TenantRuntimeBootstrap>> Bootstrap(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!tenantContext.TenantId.HasValue)
|
||||
{
|
||||
return NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Tenant was not found.",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
|
||||
var runtime = await frontendConfigService.GetRuntimeAsync(
|
||||
tenantContext.TenantId.Value,
|
||||
cancellationToken);
|
||||
var etag = $"\"{runtime.TenantCode}-{runtime.ConfigVersion}\"";
|
||||
if (Request.Headers.IfNoneMatch.Any(value => string.Equals(value, etag, StringComparison.Ordinal)))
|
||||
{
|
||||
return StatusCode(StatusCodes.Status304NotModified);
|
||||
}
|
||||
|
||||
Response.Headers.ETag = etag;
|
||||
Response.Headers.CacheControl = "public,max-age=60,must-revalidate";
|
||||
return Ok(runtime);
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ namespace Tiku.Api.Controllers;
|
||||
[Route("api/scoreline")]
|
||||
public sealed class ScorelineController(
|
||||
IScorelineQueryService scorelineQueryService,
|
||||
ICurrentTenant currentTenant,
|
||||
ITenantContext currentTenant,
|
||||
TikuDbContext dbContext) : ControllerBase
|
||||
{
|
||||
private static readonly string[] DynamicPrefixes = ["field.", "min.", "max."];
|
||||
|
||||
@@ -9,7 +9,7 @@ namespace Tiku.Api.Controllers;
|
||||
[Route("api/_security")]
|
||||
public sealed class SecurityDiagnosticsController(
|
||||
ICurrentUser currentUser,
|
||||
ICurrentTenant currentTenant) : ControllerBase
|
||||
ITenantContext currentTenant) : ControllerBase
|
||||
{
|
||||
[Authorize(Policy = TikuPolicies.AuthenticatedUser)]
|
||||
[HttpGet("authenticated")]
|
||||
@@ -29,7 +29,7 @@ public sealed class SecurityDiagnosticsController(
|
||||
return Ok(new
|
||||
{
|
||||
currentTenant.TenantId,
|
||||
currentTenant.Role
|
||||
currentUser.TenantRole
|
||||
});
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ public sealed class SecurityDiagnosticsController(
|
||||
return Ok(new
|
||||
{
|
||||
currentTenant.TenantId,
|
||||
currentTenant.Role
|
||||
currentUser.TenantRole
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
36
Tiku.Api/Controllers/TaxonomyController.cs
Normal file
36
Tiku.Api/Controllers/TaxonomyController.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Security;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize(Policy = TikuPolicies.CurrentTenantMember)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/taxonomy/nodes")]
|
||||
public sealed class TaxonomyController(
|
||||
ITenantContext tenantContext,
|
||||
ITaxonomyService taxonomyService) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public Task<IReadOnlyCollection<TaxonomyNodeItem>> List(CancellationToken cancellationToken)
|
||||
{
|
||||
return taxonomyService.ListAsync(RequireTenantId(), cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Policy = TikuPolicies.TenantAdmin)]
|
||||
public Task<TaxonomyNodeItem> Create(
|
||||
CreateTaxonomyNodeDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return taxonomyService.CreateAsync(RequireTenantId(), request.ToCommand(), cancellationToken);
|
||||
}
|
||||
|
||||
private Guid RequireTenantId()
|
||||
{
|
||||
return tenantContext.TenantId ?? throw new InvalidOperationException("Tenant was not resolved.");
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ namespace Tiku.Api.Controllers;
|
||||
public sealed class TenantAdminDirectController(
|
||||
ITenantAdminDirectService tenantAdminService,
|
||||
ICurrentUser currentUser,
|
||||
ICurrentTenant currentTenant) : ControllerBase
|
||||
ITenantContext currentTenant) : ControllerBase
|
||||
{
|
||||
[HttpGet("classes")]
|
||||
[EndpointSummary("查询租户班级")]
|
||||
@@ -406,7 +406,7 @@ public sealed class TenantAdminDirectController(
|
||||
}
|
||||
|
||||
var role = Enum.TryParse<TenantRole>(
|
||||
currentTenant.Role?.Replace("_", string.Empty, StringComparison.Ordinal),
|
||||
currentUser.TenantRole?.Replace("_", string.Empty, StringComparison.Ordinal),
|
||||
ignoreCase: true,
|
||||
out var parsedRole)
|
||||
? parsedRole
|
||||
|
||||
@@ -13,7 +13,7 @@ namespace Tiku.Api.Controllers;
|
||||
public sealed class TenantCommerceController(
|
||||
ICommerceAdminService commerceAdminService,
|
||||
ICurrentUser currentUser,
|
||||
ICurrentTenant currentTenant) : ControllerBase
|
||||
ITenantContext currentTenant) : ControllerBase
|
||||
{
|
||||
[HttpGet("payment-accounts")]
|
||||
[EndpointSummary("查询租户支付账号")]
|
||||
|
||||
@@ -16,7 +16,7 @@ public sealed class TenantContentController(
|
||||
IAssetManagementService assetManagementService,
|
||||
IContentManagementService contentManagementService,
|
||||
ICurrentUser currentUser,
|
||||
ICurrentTenant currentTenant) : ControllerBase
|
||||
ITenantContext currentTenant) : ControllerBase
|
||||
{
|
||||
[HttpGet("entries")]
|
||||
[EndpointSummary("查询租户内容入口")]
|
||||
|
||||
@@ -17,7 +17,7 @@ namespace Tiku.Api.Controllers;
|
||||
public sealed class TenantContentDirectController(
|
||||
IDirectContentService directContentService,
|
||||
ICurrentUser currentUser,
|
||||
ICurrentTenant currentTenant) : ControllerBase
|
||||
ITenantContext currentTenant) : ControllerBase
|
||||
{
|
||||
[HttpPost("questions")]
|
||||
[EndpointSummary("创建题目及首个版本")]
|
||||
|
||||
51
Tiku.Api/Controllers/TenantFrontendConfigController.cs
Normal file
51
Tiku.Api/Controllers/TenantFrontendConfigController.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize(Policy = TikuPolicies.TenantAdmin)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/tenant-admin/frontend-config")]
|
||||
public sealed class TenantFrontendConfigController(
|
||||
ITenantContext tenantContext,
|
||||
ITenantFrontendConfigService frontendConfigService) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public Task<TenantFrontendConfigItem> Get(CancellationToken cancellationToken)
|
||||
{
|
||||
return frontendConfigService.GetAsync(RequireTenantId(), cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPut("draft")]
|
||||
public Task<TenantFrontendConfigItem> SaveDraft(
|
||||
SaveTenantFrontendConfigDraftDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return frontendConfigService.SaveDraftAsync(
|
||||
RequireTenantId(),
|
||||
request.ToDraft(),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPost("publish")]
|
||||
public Task<TenantFrontendConfigItem> Publish(
|
||||
PublishTenantFrontendConfigDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return frontendConfigService.PublishAsync(
|
||||
RequireTenantId(),
|
||||
request.ExpectedVersion,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private Guid RequireTenantId()
|
||||
{
|
||||
return tenantContext.TenantId ?? throw new TenantFrontendConfigException(
|
||||
"tenant_not_resolved",
|
||||
"Tenant was not resolved.");
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
@@ -14,7 +16,11 @@ namespace Tiku.Api.Controllers;
|
||||
[AllowAnonymous]
|
||||
[Produces("application/json")]
|
||||
[Route("api/tenant")]
|
||||
public sealed class TenantPublicController(TikuDbContext dbContext) : ControllerBase
|
||||
public sealed class TenantPublicController(
|
||||
TikuDbContext dbContext,
|
||||
ITenantContext tenantContext,
|
||||
ITenantContextInitializer tenantContextInitializer,
|
||||
ITenantDirectory tenantDirectory) : ControllerBase
|
||||
{
|
||||
[HttpGet("resolve")]
|
||||
[EndpointSummary("解析当前租户")]
|
||||
@@ -28,9 +34,23 @@ public sealed class TenantPublicController(TikuDbContext dbContext) : Controller
|
||||
var tenantCode = NormalizeTenantCode(query.TenantCode ?? Request.Headers["x-tenant-code"].FirstOrDefault());
|
||||
var host = NormalizeHost(query.Host ?? Request.Host.Host);
|
||||
|
||||
var tenant = !string.IsNullOrWhiteSpace(tenantCode)
|
||||
? await FindActiveTenantByCodeAsync(tenantCode, cancellationToken)
|
||||
: await FindActiveTenantByHostAsync(host, cancellationToken);
|
||||
var directoryEntry = tenantContext.ResolutionSource == TenantResolutionSource.Host
|
||||
? await tenantDirectory.FindByHostAsync(Request.Host.Host, cancellationToken)
|
||||
: !string.IsNullOrWhiteSpace(tenantCode)
|
||||
? await tenantDirectory.FindByCodeAsync(tenantCode, cancellationToken)
|
||||
: string.IsNullOrWhiteSpace(host)
|
||||
? null
|
||||
: await tenantDirectory.FindByHostAsync(host, cancellationToken);
|
||||
|
||||
TenantLookupResult? tenant = directoryEntry is null
|
||||
? null
|
||||
: new TenantLookupResult(
|
||||
directoryEntry.TenantId,
|
||||
directoryEntry.TenantCode,
|
||||
directoryEntry.Name,
|
||||
directoryEntry.Status,
|
||||
directoryEntry.Mode,
|
||||
directoryEntry.Host);
|
||||
|
||||
if (tenant is null)
|
||||
{
|
||||
@@ -42,6 +62,11 @@ public sealed class TenantPublicController(TikuDbContext dbContext) : Controller
|
||||
});
|
||||
}
|
||||
|
||||
tenantContextInitializer.Initialize(
|
||||
tenant.Id,
|
||||
tenant.Slug,
|
||||
tenant.Host is null ? TenantResolutionSource.TenantCode : TenantResolutionSource.Host);
|
||||
|
||||
return Ok(await BuildResponseAsync(tenant, tenant.Host, cancellationToken));
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ namespace Tiku.Api.Controllers;
|
||||
[Route("api/tenants")]
|
||||
public sealed class TenantsController(
|
||||
ICurrentUser currentUser,
|
||||
ICurrentTenant currentTenant,
|
||||
ITenantContext currentTenant,
|
||||
TikuDbContext dbContext) : ControllerBase
|
||||
{
|
||||
[HttpGet("current")]
|
||||
|
||||
@@ -6,11 +6,9 @@ public sealed class CurrentPrincipalMiddleware(RequestDelegate next)
|
||||
{
|
||||
public async Task InvokeAsync(
|
||||
HttpContext context,
|
||||
ICurrentUser currentUser,
|
||||
ICurrentTenant currentTenant)
|
||||
ICurrentUser currentUser)
|
||||
{
|
||||
currentUser.Load(context.User);
|
||||
currentTenant.Load(context.User);
|
||||
await next(context);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Api.Controllers;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Commerce;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Growth;
|
||||
using Tiku.Application.Points;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
using Tiku.Application.Storage;
|
||||
using Tiku.Infrastructure.Content;
|
||||
using Tiku.Infrastructure.Learning;
|
||||
@@ -13,6 +15,7 @@ using Tiku.Infrastructure.Profile;
|
||||
using Tiku.Infrastructure.QuestionBanks;
|
||||
using Tiku.Infrastructure.Scoreline;
|
||||
using Tiku.Application.TenantAdmin;
|
||||
using Tiku.Application.Tenancy;
|
||||
|
||||
namespace Tiku.Api.Middleware;
|
||||
|
||||
@@ -45,6 +48,52 @@ public sealed class ExceptionHandlingMiddleware(
|
||||
return;
|
||||
}
|
||||
|
||||
if (exception is TenantContextConflictException)
|
||||
{
|
||||
await WriteProblemAsync(
|
||||
context,
|
||||
exception.Message,
|
||||
StatusCodes.Status403Forbidden,
|
||||
"tenant_context_conflict");
|
||||
return;
|
||||
}
|
||||
|
||||
if (exception is TenantFrontendConfigException frontendConfigException)
|
||||
{
|
||||
var status = frontendConfigException.Code switch
|
||||
{
|
||||
"tenant_not_found" or "frontend_config_not_found" => StatusCodes.Status404NotFound,
|
||||
"frontend_config_version_conflict" => StatusCodes.Status409Conflict,
|
||||
_ => StatusCodes.Status400BadRequest
|
||||
};
|
||||
await WriteProblemAsync(
|
||||
context,
|
||||
frontendConfigException.Message,
|
||||
status,
|
||||
frontendConfigException.Code);
|
||||
return;
|
||||
}
|
||||
|
||||
if (exception is PublicQuestionAccessDeniedException publicQuestionAccessDeniedException)
|
||||
{
|
||||
await WriteProblemAsync(
|
||||
context,
|
||||
publicQuestionAccessDeniedException.Message,
|
||||
StatusCodes.Status403Forbidden,
|
||||
publicQuestionAccessDeniedException.Code);
|
||||
return;
|
||||
}
|
||||
|
||||
if (exception is QuestionLocatorException questionLocatorException)
|
||||
{
|
||||
await WriteProblemAsync(
|
||||
context,
|
||||
questionLocatorException.Message,
|
||||
StatusCodes.Status404NotFound,
|
||||
questionLocatorException.Code);
|
||||
return;
|
||||
}
|
||||
|
||||
if (exception is RequiredFieldException)
|
||||
{
|
||||
await WriteProblemAsync(
|
||||
|
||||
74
Tiku.Api/Middleware/TenantResolutionMiddleware.cs
Normal file
74
Tiku.Api/Middleware/TenantResolutionMiddleware.cs
Normal file
@@ -0,0 +1,74 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Api.Options;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
|
||||
namespace Tiku.Api.Middleware;
|
||||
|
||||
public sealed class TenantResolutionMiddleware(RequestDelegate next)
|
||||
{
|
||||
public async Task InvokeAsync(
|
||||
HttpContext context,
|
||||
ITenantDirectory tenantDirectory,
|
||||
ITenantContextInitializer tenantInitializer,
|
||||
IOptions<TenantResolutionOptions> options)
|
||||
{
|
||||
var host = NormalizeHost(context.Request.Host.Host);
|
||||
var isPlatformHost = options.Value.PlatformHosts.Any(candidate =>
|
||||
string.Equals(NormalizeHost(candidate), host, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
TenantDirectoryEntry? tenant = null;
|
||||
if (!isPlatformHost && host is not null)
|
||||
{
|
||||
tenant = await tenantDirectory.FindByHostAsync(host, context.RequestAborted);
|
||||
if (tenant is null && !IsExemptPath(context.Request.Path, options.Value.ExemptPathPrefixes))
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
await context.Response.WriteAsJsonAsync(new ProblemDetails
|
||||
{
|
||||
Title = "Tenant was not found.",
|
||||
Status = StatusCodes.Status404NotFound,
|
||||
Detail = "The request host is not assigned to an active tenant."
|
||||
}, context.RequestAborted);
|
||||
return;
|
||||
}
|
||||
}
|
||||
else if (isPlatformHost && IsAllowedTenantCodePath(
|
||||
context.Request.Path,
|
||||
options.Value.TenantCodePathPrefixes))
|
||||
{
|
||||
var tenantCode = context.Request.Headers["x-tenant-code"].FirstOrDefault()
|
||||
?? context.Request.Query["tenantCode"].FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(tenantCode))
|
||||
{
|
||||
tenant = await tenantDirectory.FindByCodeAsync(tenantCode.Trim(), context.RequestAborted);
|
||||
}
|
||||
}
|
||||
|
||||
if (tenant is not null)
|
||||
{
|
||||
tenantInitializer.Initialize(
|
||||
tenant.TenantId,
|
||||
tenant.TenantCode,
|
||||
tenant.Host is null ? TenantResolutionSource.TenantCode : TenantResolutionSource.Host);
|
||||
}
|
||||
|
||||
await next(context);
|
||||
}
|
||||
|
||||
private static string? NormalizeHost(string? host)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(host) ? null : host.Trim().TrimEnd('.').ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static bool IsExemptPath(PathString path, IEnumerable<string> prefixes)
|
||||
{
|
||||
return prefixes.Any(prefix => path.StartsWithSegments(prefix, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static bool IsAllowedTenantCodePath(PathString path, IEnumerable<string> prefixes)
|
||||
{
|
||||
return prefixes.Any(prefix => path.StartsWithSegments(prefix, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
12
Tiku.Api/Options/TenantResolutionOptions.cs
Normal file
12
Tiku.Api/Options/TenantResolutionOptions.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace Tiku.Api.Options;
|
||||
|
||||
public sealed class TenantResolutionOptions
|
||||
{
|
||||
public const string SectionName = "Tenancy:Resolution";
|
||||
|
||||
public string[] PlatformHosts { get; set; } = ["localhost", "127.0.0.1"];
|
||||
public string[] ExemptPathPrefixes { get; set; } = ["/health", "/openapi", "/scalar"];
|
||||
public string[] TenantCodePathPrefixes { get; set; } =
|
||||
["/api/auth", "/api/tenant", "/api/catalog", "/api/assets", "/api/scoreline", "/api/referral", "/api/commerce/payments/notify"];
|
||||
public string[] TrustedProxyAddresses { get; set; } = [];
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using System.Net;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Scalar.AspNetCore;
|
||||
@@ -16,6 +18,7 @@ using Tiku.Api.Options;
|
||||
using Tiku.Api.Security;
|
||||
using Tiku.Application;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Infrastructure;
|
||||
using Tiku.Infrastructure.Commerce;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
@@ -49,6 +52,30 @@ try
|
||||
});
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddApplication();
|
||||
builder.Services.AddOptions<TenantResolutionOptions>()
|
||||
.Bind(builder.Configuration.GetSection(TenantResolutionOptions.SectionName));
|
||||
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||
{
|
||||
options.ForwardedHeaders =
|
||||
ForwardedHeaders.XForwardedFor |
|
||||
ForwardedHeaders.XForwardedHost |
|
||||
ForwardedHeaders.XForwardedProto;
|
||||
options.ForwardLimit = 1;
|
||||
options.KnownProxies.Clear();
|
||||
options.KnownIPNetworks.Clear();
|
||||
var resolution = builder.Configuration
|
||||
.GetSection(TenantResolutionOptions.SectionName)
|
||||
.Get<TenantResolutionOptions>() ?? new TenantResolutionOptions();
|
||||
foreach (var address in resolution.TrustedProxyAddresses)
|
||||
{
|
||||
if (IPAddress.TryParse(address, out var proxy))
|
||||
{
|
||||
options.KnownProxies.Add(proxy);
|
||||
}
|
||||
}
|
||||
});
|
||||
builder.Services.AddOptions<DomainLifecycleOptions>()
|
||||
.Bind(builder.Configuration.GetSection("TenantDomains"));
|
||||
builder.Services.AddOptions<CorsOptions>()
|
||||
.Bind(builder.Configuration.GetSection(CorsOptions.SectionName))
|
||||
.ValidateDataAnnotations()
|
||||
@@ -218,6 +245,26 @@ try
|
||||
{
|
||||
OnTokenValidated = async context =>
|
||||
{
|
||||
var tenantIdValue = context.Principal?.FindFirst(TikuClaimTypes.TenantId)?.Value;
|
||||
if (!Guid.TryParse(tenantIdValue, out var tenantId))
|
||||
{
|
||||
context.Fail("Missing tenant claim.");
|
||||
return;
|
||||
}
|
||||
|
||||
var tenantInitializer = context.HttpContext.RequestServices
|
||||
.GetRequiredService<ITenantContextInitializer>();
|
||||
try
|
||||
{
|
||||
tenantInitializer.Initialize(tenantId, null, TenantResolutionSource.Jwt);
|
||||
}
|
||||
catch (TenantContextConflictException)
|
||||
{
|
||||
context.HttpContext.Items["tenant_context_conflict"] = true;
|
||||
context.Fail("Authenticated tenant does not match the request host.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!jwtOptions.ValidateSessions)
|
||||
{
|
||||
return;
|
||||
@@ -242,6 +289,20 @@ try
|
||||
{
|
||||
context.Fail("Session has been revoked or expired.");
|
||||
}
|
||||
},
|
||||
OnChallenge = async context =>
|
||||
{
|
||||
if (context.HttpContext.Items.ContainsKey("tenant_context_conflict"))
|
||||
{
|
||||
context.HandleResponse();
|
||||
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
await context.Response.WriteAsJsonAsync(new ProblemDetails
|
||||
{
|
||||
Title = "Authenticated tenant does not match the request host.",
|
||||
Status = StatusCodes.Status403Forbidden,
|
||||
Extensions = { ["code"] = "tenant_context_conflict" }
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
@@ -276,9 +337,11 @@ try
|
||||
|
||||
app.UseSerilogRequestLogging(SerilogRequestLogging.ConfigureRequestLogging);
|
||||
app.UseMiddleware<ExceptionHandlingMiddleware>();
|
||||
app.UseForwardedHeaders();
|
||||
app.UseHttpsRedirection();
|
||||
app.UseRouting();
|
||||
app.UseCors(CorsOptions.PolicyName);
|
||||
app.UseMiddleware<TenantResolutionMiddleware>();
|
||||
app.UseAuthentication();
|
||||
if (rateLimitOptions.Enabled)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user